From 23938d6479bf599d60e7b45a4b1a271aeb2253fd Mon Sep 17 00:00:00 2001 From: ZenEcho Date: Wed, 18 Feb 2026 15:49:27 +0800 Subject: [PATCH] =?UTF-8?q?refactor(electron):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E7=BB=93=E6=9E=84=E5=B9=B6=E5=BC=95=E5=85=A5?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=B1=82=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 electron 代码重构为基于服务的模块化架构,提升可维护性 - 将 `electron/games/manager.ts` 移至 `electron/services/game.ts` 并重构为 GameService - 新增多个服务模块:category, database, game-scanner, node, process, proxy-monitor, singbox, system, updater - 新增工具模块:command, format, id, ping, port, process-helper - 新增公共模块:store(基于 JSON 的持久化存储) - 新增文档:singbox-config.md 和 electron-structure.md 说明新架构 - 更新主入口文件 `electron/main/index.ts` 以初始化所有服务 - 更新预加载脚本 `electron/preload/index.ts` 暴露新的 API - 更新构建配置以支持新的目录结构 - 修复主入口文件路径配置 --- .trae/.ignore | 0 docs/electron-structure.md | 64 + {src/utils => docs}/singbox-config.md | 0 electron/{config => common}/store.ts | 14 + electron/db/schema.ts | 71 + electron/games/manager.ts | 40 - electron/main.ts | 1144 ----------------- electron/main/index.ts | 320 +++++ electron/main/logger.ts | 265 ++++ electron/{preload.ts => preload/index.ts} | 6 + .../manager.ts => services/category.ts} | 59 +- .../{db/index.ts => services/database.ts} | 146 ++- electron/services/game-scanner.ts | 400 ++++++ electron/services/game.ts | 63 + .../{nodes/manager.ts => services/node.ts} | 21 +- .../manager.ts => services/process.ts} | 44 +- .../monitor.ts => services/proxy-monitor.ts} | 80 +- .../manager.ts => services/singbox.ts} | 68 +- electron/services/system.ts | 126 ++ electron/services/updater.ts | 83 ++ electron/utils/command.ts | 45 + electron/utils/format.ts | 70 + electron/utils/id.ts | 12 + electron/{network => utils}/ping.ts | 25 + electron/{network => utils}/port.ts | 18 + electron/utils/process-helper.ts | 49 + package.json | 2 +- vite.config.ts | 4 +- 28 files changed, 1905 insertions(+), 1334 deletions(-) create mode 100644 .trae/.ignore create mode 100644 docs/electron-structure.md rename {src/utils => docs}/singbox-config.md (100%) rename electron/{config => common}/store.ts (86%) delete mode 100644 electron/games/manager.ts delete mode 100644 electron/main.ts create mode 100644 electron/main/index.ts create mode 100644 electron/main/logger.ts rename electron/{preload.ts => preload/index.ts} (95%) rename electron/{categories/manager.ts => services/category.ts} (55%) rename electron/{db/index.ts => services/database.ts} (84%) create mode 100644 electron/services/game-scanner.ts create mode 100644 electron/services/game.ts rename electron/{nodes/manager.ts => services/node.ts} (74%) rename electron/{process/manager.ts => services/process.ts} (74%) rename electron/{proxy/monitor.ts => services/proxy-monitor.ts} (62%) rename electron/{singbox/manager.ts => services/singbox.ts} (93%) create mode 100644 electron/services/system.ts create mode 100644 electron/services/updater.ts create mode 100644 electron/utils/command.ts create mode 100644 electron/utils/format.ts create mode 100644 electron/utils/id.ts rename electron/{network => utils}/ping.ts (79%) rename electron/{network => utils}/port.ts (61%) create mode 100644 electron/utils/process-helper.ts diff --git a/.trae/.ignore b/.trae/.ignore new file mode 100644 index 0000000..e69de29 diff --git a/docs/electron-structure.md b/docs/electron-structure.md new file mode 100644 index 0000000..08ea0ca --- /dev/null +++ b/docs/electron-structure.md @@ -0,0 +1,64 @@ +# Electron 主进程项目结构说明 + +本文档详细描述了 `electron/` 目录下的代码结构与模块功能。本项目采用 **SOA (面向服务架构)** 设计,将业务逻辑拆分为独立的服务模块。 + +## 目录树 + +``` +electron/ +├── common/ # [公共层] 跨模块共享的通用代码 +│ └── store.ts # 简单持久化存储封装 (基于 electron-store) +│ +├── db/ # [数据层] 数据库定义 +│ └── schema.ts # SQLite 数据库表结构定义 (Kysely Schema) +│ +├── main/ # [入口层] 主进程核心引导 +│ ├── index.ts # **应用入口**:负责 App 生命周期、窗口创建、IPC 注册、服务组装 +│ └── logger.ts # 日志系统:负责日志文件写入、轮转及向渲染进程广播日志 +│ +├── preload/ # [桥接层] 预加载脚本 +│ └── index.ts # 暴露给渲染进程的 API 定义 (ContextBridge) +│ +├── services/ # [业务层] 核心业务逻辑服务 (Service) +│ ├── category.ts # 游戏分类服务:管理侧边栏分类数据 +│ ├── database.ts # 数据库服务:封装 SQLite 连接与基础 CRUD 操作 +│ ├── game-scanner.ts # 游戏扫描服务:自动识别 Steam/Epic/Xbox/EA 本地已安装游戏 +│ ├── game.ts # 游戏管理服务:管理“我的游戏”列表、配置与状态 +│ ├── node.ts # 节点管理服务:管理代理节点/订阅信息 +│ ├── process.ts # 进程管理服务:提供进程树查找、父子进程分析能力 +│ ├── proxy-monitor.ts # 监控服务:实时监控游戏进程启动,自动触发加速路由 +│ ├── singbox.ts # **核心加速服务**:管理 sing-box 内核进程、生成配置、处理路由规则 +│ ├── system.ts # 系统服务:提供 DNS 刷新、虚拟网卡重置、网络测试等底层能力 +│ └── updater.ts # 更新服务:检查 GitHub Releases 新版本 +│ +└── utils/ # [工具层] 通用工具函数库 + ├── command.ts # 命令行工具:封装 exec/spawn 为 Promise,支持超时控制 + ├── format.ts # 格式化工具:JSON 安全解析、URL 校验、类型转换 + ├── id.ts # ID 工具:统一的 UUID 生成器 + ├── ping.ts # 网络工具:实现 ICMP Ping 和 TCP Ping 延迟测试 + ├── port.ts # 端口工具:查找可用端口、检测端口占用 + └── process-helper.ts # 进程工具:进程名称标准化、跨平台路径处理、数组比较 +``` + +## 模块详细说明 + +### 1. 入口层 (`main/`) +- **`index.ts`**: + - 是整个 Electron 应用的起点。 + - 负责初始化所有 Service 实例(依赖注入)。 + - 处理窗口管理(最小化、最大化、关闭)。 + - 处理系统托盘 (Tray) 逻辑。 +- **`logger.ts`**: + - 实现了双向日志系统:既记录后端日志,也接收前端日志。 + - 包含日志文件自动清理策略(限制文件大小和总占用空间)。 + +### 2. 业务层 (`services/`) +这是代码最集中的地方,每个文件对应一个具体的业务领域: +- **`singbox.ts`**: 最核心的文件。负责下载 sing-box 内核、生成 `config.json`、启动/停止内核、动态更新路由规则。 +- **`proxy-monitor.ts`**: 实现了智能进程捕获。它会轮询系统进程树,当发现已配置的游戏进程及其子进程启动时,自动通知 `singbox.ts` 添加分流规则。 +- **`game-scanner.ts`**: 包含复杂的跨平台(目前主要是 Windows)扫描逻辑,读取注册表、Steam 库文件 (`libraryfolders.vdf`) 等信息来定位游戏。 + +### 3. 工具层 (`utils/`) +提炼了纯函数工具,不依赖具体业务逻辑,易于测试和复用。 +- **`command.ts`**: 解决了 Node.js 原生 `exec` 在 Windows 下的一些编码和窗口隐藏问题。 +- **`process-helper.ts`**: 统一了全项目的进程名处理逻辑(如忽略大小写、去除 `.exe` 后缀),确保规则匹配的准确性。 diff --git a/src/utils/singbox-config.md b/docs/singbox-config.md similarity index 100% rename from src/utils/singbox-config.md rename to docs/singbox-config.md diff --git a/electron/config/store.ts b/electron/common/store.ts similarity index 86% rename from electron/config/store.ts rename to electron/common/store.ts index 418f918..0c63574 100644 --- a/electron/config/store.ts +++ b/electron/common/store.ts @@ -1,13 +1,27 @@ import fs from 'fs-extra' import path from 'node:path' +/** + * 本地 JSON 存储类配置选项 + */ export interface StoreOptions { + /** 存储文件名称 (不含后缀) */ name: string + /** 默认数据 */ defaults: T + /** 存储目录路径 */ cwd: string + /** 保留的备份文件数量,默认为 5 */ backupCount?: number } +/** + * 简单的本地 JSON 文件存储 + * + * 提供数据的持久化存储、读取、自动备份功能。 + * + * @template T - 存储数据的类型接口 + */ export class JsonStore { private filePath: string private backupDir: string diff --git a/electron/db/schema.ts b/electron/db/schema.ts index dd37a50..fefc195 100644 --- a/electron/db/schema.ts +++ b/electron/db/schema.ts @@ -1,71 +1,142 @@ import { Generated } from 'kysely' +/** + * 节点表结构 + * 存储代理服务器配置信息 + */ export interface NodesTable { + /** 唯一 ID (UUID) */ id: string + /** 节点类型 (如 shadowsocks, vmess, trojan) */ type: string + /** 节点标签/名称 */ tag: string + /** 服务器地址 */ server: string + /** 服务器端口 */ server_port: number + /** UUID (vmess/vless 等使用) */ uuid: string | null + /** 密码 (shadowsocks/trojan 等使用) */ password: string | null + /** 加密方式 */ method: string | null + /** 插件名称 */ plugin: string | null + /** 插件参数 */ plugin_opts: string | null + /** 传输协议网络类型 (tcp/ws/grpc 等) */ network: string | null + /** 安全类型 (tls/xtls 等) */ security: string | null + /** 路径 (ws/grpc 等使用) */ path: string | null + /** 主机名 (ws/grpc 等使用) */ host: string | null + /** 服务名称 (grpc 使用) */ service_name: string | null + /** ALPN */ alpn: string | null + /** 指纹 */ fingerprint: string | null + /** TLS 配置 (JSON 字符串) */ tls: string | null // JSON + /** 流控 (xtls 等使用) */ flow: string | null + /** 数据包编码 */ packet_encoding: string | null + /** 用户名 */ username: string | null + /** 创建时间 */ created_at: Generated + /** 更新时间 */ updated_at: Generated } +/** + * 配置文件表结构 + * 存储分流规则集合 + */ export interface ProfilesTable { + /** 唯一 ID */ id: string + /** 配置文件名称 */ name: string + /** 描述信息 */ description: string | null + /** 规则列表 (JSON 字符串) */ rules: string // JSON: { type, value, outbound }[] + /** 是否启用链式代理 (0/1) */ chain_proxy: number // boolean 0/1 + /** 创建时间 */ created_at: Generated + /** 更新时间 */ updated_at: Generated } +/** + * 游戏分类表结构 + */ export interface CategoriesTable { + /** 唯一 ID */ id: string + /** 分类名称 */ name: string + /** 父分类 ID */ parent_id: string | null + /** 匹配规则 (JSON 字符串数组) */ rules: string | null // JSON string[] + /** 图标 URL 或 Base64 */ icon: string | null + /** 排序索引 */ order_index: number + /** 创建时间 */ created_at: Generated + /** 更新时间 */ updated_at: Generated } +/** + * 游戏表结构 + */ export interface GamesTable { + /** 唯一 ID */ id: string + /** 游戏名称 */ name: string + /** 游戏图标 */ icon: string | null + /** 进程名称列表 (JSON 字符串数组) */ process_name: string // JSON string[] or comma separated? Let's use JSON for string[] + /** 所属分类 ID */ category_id: string + /** 标签列表 (JSON 字符串数组) */ tags: string | null // JSON string[] + /** 关联的配置文件 ID */ profile_id: string | null + /** 最后运行时间戳 */ last_played: number | null + /** 状态 (idle/running 等) */ status: string | null + /** 延迟数据 */ latency: number | null + /** 指定的出口节点 ID */ node_id: string | null + /** 代理模式 ('process' | 'routing') */ proxy_mode: string | null // 'process' | 'routing' + /** 路由规则 (JSON 字符串数组) */ routing_rules: string | null // JSON string[] + /** 是否启用链式代理 (0/1) */ chain_proxy: number // boolean 0/1 + /** 创建时间 */ created_at: Generated + /** 更新时间 */ updated_at: Generated } +/** + * 数据库全表接口定义 + */ export interface Database { nodes: NodesTable profiles: ProfilesTable diff --git a/electron/games/manager.ts b/electron/games/manager.ts deleted file mode 100644 index 329213c..0000000 --- a/electron/games/manager.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { ipcMain } from 'electron' -import { DatabaseManager } from '../db' - -export interface Game { - id?: string - name: string - iconUrl?: string - processName: string | string[] - category: string - tags?: string[] - profileId?: string - lastPlayed?: number - status?: 'idle' | 'accelerating' - latency?: number - nodeId?: string - proxyMode?: 'process' | 'routing' - routingRules?: string[] - chainProxy?: boolean -} - -export class GameManager { - private db: DatabaseManager - - constructor(db: DatabaseManager) { - this.db = db - this.registerIPC() - } - - private registerIPC() { - ipcMain.handle('games:get-all', () => this.db.getAllGames()) - - ipcMain.handle('games:save', async (_, game: Game) => { - return this.db.saveGame(game) - }) - - ipcMain.handle('games:delete', async (_, id: string) => { - return this.db.deleteGame(id) - }) - } -} diff --git a/electron/main.ts b/electron/main.ts deleted file mode 100644 index 1af69a4..0000000 --- a/electron/main.ts +++ /dev/null @@ -1,1144 +0,0 @@ -import { app, BrowserWindow, ipcMain, dialog, Tray, Menu, nativeImage, NativeImage, shell, net } from 'electron' -// import { autoUpdater } from 'electron-updater' -import { fileURLToPath, pathToFileURL } from 'node:url' -import path from 'node:path' -import nodeNet from 'node:net' -import { spawn } from 'node:child_process' -import { inspect } from 'node:util' -import { randomUUID } from 'node:crypto' -import fs from 'fs-extra' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -// Handle __filename error in ESM -const __filename = fileURLToPath(import.meta.url) -// Polyfill global.__filename and global.__dirname for better-sqlite3 -// @ts-ignore -global.__filename = __filename -// @ts-ignore -global.__dirname = __dirname - -import { SingBoxManager } from './singbox/manager' -import { ProcessManager } from './process/manager' -import { GameManager } from './games/manager' -import { CategoryManager } from './categories/manager' -import { ProxyMonitor } from './proxy/monitor' -import { NodeManager } from './nodes/manager' -import { DatabaseManager } from './db' -import { ping, tcpPing } from './network/ping' -import { findAvailablePort } from './network/port' -import pkg from '../package.json' - -process.env.APP_ROOT = path.join(__dirname, '..') -const APP_ID = 'com.' + pkg.name + '.client' - -// 🚧 Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x -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) { - app.setPath('userData', path.join(process.env.APP_ROOT, '.' + pkg.name + '-dev')) -} -if (process.platform === 'win32') { - app.setAppUserModelId(APP_ID) -} - -let win: BrowserWindow | null -let tray: Tray | null = null -let singboxManager: SingBoxManager | null = null -let processManager: ProcessManager | null = null -let gameManager: GameManager | null = null -let categoryManager: CategoryManager | null = null -let proxyMonitor: ProxyMonitor | null = null -let nodeManager: NodeManager | null = null -let dbManager: DatabaseManager | null = null - -type AppLogLevel = 'debug' | 'info' | 'warn' | 'error' -type AppLogCategory = 'frontend' | 'backend' | 'core' - -type AppLogEntry = { - id: string - timestamp: number - level: AppLogLevel - category: AppLogCategory - source: string - message: string - detail?: string -} - -const MAX_LOG_ENTRIES = 3000 -const appLogs: AppLogEntry[] = [] -const MAX_LOG_FILE_BYTES = 5 * 1024 * 1024 -const MAX_LOG_DIR_BYTES = 500 * 1024 * 1024 -const LOG_FILE_PREFIX = pkg.name -let logWriteChain: Promise = Promise.resolve() -let sessionLogFilePath = '' - -function getAppLogDir() { - return path.join(app.getPath('userData'), 'logs') -} - -function getAppLogFilePath() { - if (sessionLogFilePath) return sessionLogFilePath - const stamp = new Date().toISOString().replace(/[:.]/g, '-') - sessionLogFilePath = path.join(getAppLogDir(), `${LOG_FILE_PREFIX}-${stamp}-${process.pid}.log`) - return sessionLogFilePath -} - -function queueLogWrite(task: () => Promise) { - logWriteChain = logWriteChain - .then(task) - .catch(() => { - // Avoid recursive logging when filesystem write fails. - }) -} - -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` -} - -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) - if (!exists) return - - const dirents = await fs.readdir(logDir, { withFileTypes: true }) - const files: Array<{ path: string, size: number, mtimeMs: number }> = [] - - for (const dirent of dirents) { - if (!dirent.isFile() || !dirent.name.toLowerCase().endsWith('.log')) continue - if (!dirent.name.toLowerCase().startsWith(`${LOG_FILE_PREFIX}-`)) continue - const full = path.join(logDir, dirent.name) - try { - const st = await fs.stat(full) - files.push({ path: full, size: st.size, mtimeMs: st.mtimeMs }) - } catch { - // ignore read failures - } - } - - files.sort((a, b) => a.mtimeMs - b.mtimeMs) - let total = files.reduce((sum, f) => sum + f.size, 0) - - for (const file of files) { - if (total <= MAX_LOG_DIR_BYTES) break - const active = path.normalize(file.path) === path.normalize(getAppLogFilePath()) - if (active && files.length > 1) continue - - try { - await fs.remove(file.path) - total -= file.size - } catch { - // ignore delete failures - } - } -} - -async function writeStartupSection() { - const logFilePath = getAppLogFilePath() - const now = new Date() - const section = [ - '', - '='.repeat(88), - `Session Start: ${now.toISOString()}`, - `Version: ${app.getVersion()} | PID: ${process.pid} | Platform: ${process.platform} ${process.arch}`, - `Electron: ${process.versions.electron} | Node: ${process.versions.node} | Chromium: ${process.versions.chrome}`, - '='.repeat(88), - '' - ].join('\n') - - await fs.ensureDir(path.dirname(logFilePath)) - await fs.writeFile(logFilePath, section, 'utf8') - await pruneLogDirIfNeeded(path.dirname(logFilePath)) -} - -function appendLogToFile(row: AppLogEntry) { - const line = formatLogLine(row) - const logFilePath = getAppLogFilePath() - - queueLogWrite(async () => { - await fs.ensureDir(path.dirname(logFilePath)) - await ensureSingleFileLimit(logFilePath, Buffer.byteLength(line, 'utf8')) - await fs.appendFile(logFilePath, line, 'utf8') - await pruneLogDirIfNeeded(path.dirname(logFilePath)) - }) -} - -function pushAppLog(entry: Omit) { - const row: AppLogEntry = { - id: randomUUID(), - timestamp: Date.now(), - ...entry - } - appLogs.push(row) - if (appLogs.length > MAX_LOG_ENTRIES) appLogs.shift() - win?.webContents.send('app-log:new', row) - appendLogToFile(row) -} - -function stringifyConsoleArg(value: unknown): string { - if (typeof value === 'string') return value - if (value instanceof Error) return value.stack || value.message - return inspect(value, { depth: 3, breakLength: 120 }) -} - -function truncateLogText(text: string, max = 4000) { - if (text.length <= max) return text - return `${text.slice(0, max)}...` -} - -function inferLogCategory(message: string): AppLogCategory { - return message.includes('[SingBox]') ? 'core' : 'backend' -} - -function installMainConsoleLogCapture() { - const original = { - debug: console.debug.bind(console), - info: console.info.bind(console), - warn: console.warn.bind(console), - error: console.error.bind(console), - log: console.log.bind(console) - } - - const install = (method: keyof typeof original, level: AppLogLevel) => { - ; (console as any)[method] = (...args: unknown[]) => { - original[method](...args as any[]) - const text = truncateLogText(args.map(stringifyConsoleArg).join(' ').trim()) - pushAppLog({ - level, - category: inferLogCategory(text), - source: 'main', - message: text || '(empty)' - }) - } - } - - install('debug', 'debug') - install('info', 'info') - install('warn', 'warn') - install('error', 'error') - install('log', 'info') -} - -installMainConsoleLogCapture() -queueLogWrite(writeStartupSection) -pushAppLog({ - level: 'info', - category: 'backend', - source: 'logger', - message: `App log file: ${getAppLogFilePath()}` -}) - -ipcMain.handle('logs:get-all', () => appLogs) -ipcMain.handle('logs:clear', () => { - appLogs.length = 0 - const logDir = getAppLogDir() - queueLogWrite(async () => { - if (!await fs.pathExists(logDir)) return - const dirents = await fs.readdir(logDir, { withFileTypes: true }) - for (const dirent of dirents) { - if (!dirent.isFile() || !dirent.name.toLowerCase().endsWith('.log')) continue - if (!dirent.name.toLowerCase().startsWith(`${LOG_FILE_PREFIX}-`)) continue - await fs.remove(path.join(logDir, dirent.name)).catch(() => { }) - } - }) -}) -ipcMain.handle('logs:get-file-path', () => getAppLogFilePath()) -ipcMain.handle('logs:push-frontend', (_, payload: Partial) => { - const level: AppLogLevel = payload.level === 'debug' || payload.level === 'warn' || payload.level === 'error' - ? payload.level - : 'info' - - pushAppLog({ - level, - category: 'frontend', - source: String(payload.source || 'renderer'), - message: truncateLogText(String(payload.message || '(empty)')), - detail: payload.detail ? truncateLogText(String(payload.detail)) : undefined - }) -}) - -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 -} - -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}`) - } - - // Fallback: strip filters for platforms with poor SVG filter support. - const sanitized = svg - .replace(//gi, '') - .replace(/\sfilter="url\(#.*?\)"/gi, '') - - const image = toImage(sanitized) - if (!image.isEmpty()) return image - - // Last fallback: a minimal SVG that Electron can decode reliably on Windows. - const fallbackSvg = ` - - - -` - const fallbackImage = toImage(fallbackSvg) - if (!fallbackImage.isEmpty()) { - console.warn('[Main] logo.svg decode failed, using built-in fallback icon') - return fallbackImage - } - - console.warn('[Main] Failed to load icon from:', svgPath) - console.warn('[Main] Recommended fallback: add public/logo.png or public/logo.ico') - 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() - - win = new BrowserWindow({ - width: 1200, - height: 800, - minWidth: 860, - minHeight: 620, - frame: false, - webPreferences: { - preload: path.join(__dirname, 'preload.mjs'), - nodeIntegration: false, - contextIsolation: true, - webSecurity: false, // Allow loading local resources (file://) - }, - backgroundColor: '#1e1e1e', - ...(appIcon ? { icon: appIcon } : {}), - }) - - dbManager = new DatabaseManager() - singboxManager = new SingBoxManager(win) - processManager = new ProcessManager() - gameManager = new GameManager(dbManager) - categoryManager = new CategoryManager(dbManager) - proxyMonitor = new ProxyMonitor(win, processManager, singboxManager) - nodeManager = new NodeManager(dbManager) - - // Register generic DB IPCs - ipcMain.handle('db:export', () => dbManager?.exportData()) - ipcMain.handle('db:import', (_, data) => dbManager?.importData(data)) - - if (VITE_DEV_SERVER_URL) { - win.loadURL(VITE_DEV_SERVER_URL) - win.webContents.openDevTools() - } else { - win.loadFile(path.join(RENDERER_DIST, 'index.html')) - } - - createTray(appIcon) -} - -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() - } -}) - -app.whenReady() - .then(() => { - createWindow() - }) - .catch((error) => { - const message = formatStartupError(error) - console.error('[Main] App startup failed:', message) - dialog.showErrorBox('LagZero startup failed', message) - app.quit() - }) - -process.on('unhandledRejection', (reason) => { - const message = formatStartupError(reason) - console.error('[Main] Unhandled rejection:', message) -}) - -app.on('before-quit', () => { - tray?.destroy() - tray = null -}) - -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()) - -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 filenames only, as users usually just need the process name (e.g. game.exe) - // But maybe they want full path? The UI shows "e.g. League of Legends.exe". - // The backend matcher uses `game.processName` which seems to be just filename usually, - // but let's see. - // `matchRunningGames` in store uses `t.toLowerCase() === pNameLower`. `pName` from `scanProcesses` is just name. - // So we should return basenames. - - return result.filePaths.map(p => path.basename(p)) -}) - -async function scanDir(dir: string, maxDepth: number, currentDepth: number = 1): Promise { - let results: string[] = [] - try { - const dirents = await fs.readdir(dir, { withFileTypes: true }) - for (const dirent of dirents) { - if (dirent.isDirectory()) { - if (maxDepth === -1 || currentDepth < maxDepth) { - const subResults = await scanDir(path.join(dir, dirent.name), maxDepth, currentDepth + 1) - results.push(...subResults) - } - } else if (dirent.isFile() && dirent.name.toLowerCase().endsWith('.exe')) { - results.push(dirent.name) - } - } - } catch (err) { - console.error(`Error scanning directory ${dir}:`, err) - } - return results -} - -type LocalGameScanResult = { - name: string - 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 - }) -} - -async function scanLocalGamesFromPlatforms(): Promise { - if (process.platform !== 'win32') return [] - const drives = await getWindowsDriveRoots() - - const [steam, microsoft, epic, ea] = await Promise.all([ - scanSteamGames(), - scanMicrosoftGames(), - scanFlatPlatformFolder('epic', Array.from(new Set( - drives.flatMap(root => [ - path.join(root, 'Epic Games'), - path.join(root, 'Program Files', 'Epic Games'), - path.join(root, 'Program Files (x86)', 'Epic Games') - ]) - ))), - scanFlatPlatformFolder('ea', Array.from(new Set( - drives.flatMap(root => [ - path.join(root, 'EA Games'), - path.join(root, 'Electronic Arts'), - path.join(root, 'Program Files', 'EA Games'), - path.join(root, 'Program Files', 'Electronic Arts'), - path.join(root, 'Program Files (x86)', 'EA Games'), - path.join(root, 'Program Files (x86)', 'Electronic Arts') - ]) - ))) - ]) - - const dedup = new Map() - for (const game of [...steam, ...microsoft, ...epic, ...ea]) { - const key = `${game.name.toLowerCase()}|${game.processName.toLowerCase()}` - if (!dedup.has(key)) dedup.set(key, game) - } - - return Array.from(dedup.values()) -} - -ipcMain.handle('dialog:pick-process-folder', async (_, maxDepth: number = 1) => { - if (!win) return null - const result = await dialog.showOpenDialog(win, { - properties: ['openDirectory'] - }) - if (result.canceled || result.filePaths.length === 0) return null - const dir = result.filePaths[0] - return scanDir(dir, maxDepth) -}) - -ipcMain.handle('system:ping', async (_, host: string) => ping(host)) -ipcMain.handle('system:tcp-ping', async (_, host: string, port: number) => { - try { - return await tcpPing(host, port) - } catch (e: any) { - return { latency: -1, loss: 100 } - } -}) -ipcMain.handle('system:scan-local-games', async () => { - try { - return await scanLocalGamesFromPlatforms() - } catch (error) { - console.error('[GameScan] Failed to scan local games:', error) - return [] - } -}) - -type SystemCommandResult = { - ok: boolean - code: number - output: string - message: string -} - -function runCommand(command: string, args: string[], timeoutMs: number = 15000): Promise<{ code: number, output: string }> { - return new Promise((resolve) => { - const p = spawn(command, args, { windowsHide: true }) - 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) - - let timedOut = false - const timer = setTimeout(() => { - timedOut = true - p.kill() - }, timeoutMs) - - p.on('close', (code) => { - clearTimeout(timer) - const output = Buffer.concat(chunks).toString('utf8').trim() - if (timedOut) { - resolve({ code: -1, output: output || 'Command timeout' }) - return - } - resolve({ code: typeof code === 'number' ? code : -1, output }) - }) - - p.on('error', (err) => { - clearTimeout(timer) - resolve({ code: -1, output: String(err?.message || err) }) - }) - }) -} - -async function flushDnsCache(): Promise { - if (process.platform === 'win32') { - const { code, output } = await runCommand('ipconfig', ['/flushdns']) - return { - ok: code === 0, - code, - output, - message: code === 0 ? 'DNS cache flushed.' : 'Failed to flush DNS cache. Try running LagZero as administrator.' - } - } - - return { - ok: false, - code: -1, - output: '', - message: `Unsupported platform: ${process.platform}` - } -} - -async function reinstallTunAdapter(interfaceName: string = 'LagZero'): Promise { - if (process.platform !== 'win32') { - return { - ok: false, - code: -1, - output: '', - message: `Unsupported platform: ${process.platform}` - } - } - - // Try to remove old adapter instance, then sing-box will recreate it on next start. - const psScript = [ - `$name = '${interfaceName.replace(/'/g, "''")}'`, - "$adapter = Get-NetAdapter -Name $name -ErrorAction SilentlyContinue", - "if ($null -eq $adapter) { Write-Output \"TUN adapter not found: $name\"; exit 2 }", - "Disable-NetAdapter -Name $name -Confirm:$false -ErrorAction SilentlyContinue | Out-Null", - "Start-Sleep -Milliseconds 400", - "if (Get-Command Remove-NetAdapter -ErrorAction SilentlyContinue) {", - " Remove-NetAdapter -Name $name -Confirm:$false -ErrorAction Stop", - " Write-Output \"TUN adapter removed: $name\"", - " exit 0", - "}", - "Write-Output \"Remove-NetAdapter not available\"", - "exit 3" - ].join('; ') - - const { code, output } = await runCommand('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', psScript], 25000) - const ok = code === 0 || code === 2 - return { - ok, - code, - output, - message: ok - ? 'TUN adapter reset finished. Restart acceleration to recreate adapter.' - : 'Failed to reset TUN adapter. Try running LagZero as administrator.' - } -} - -ipcMain.handle('system:flush-dns-cache', () => flushDnsCache()) -ipcMain.handle('system:tun-reinstall', async (_, interfaceName?: string) => { - return reinstallTunAdapter(interfaceName || 'LagZero') -}) -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 new Promise<{ ok: boolean, statusLine: string, error?: string }>((resolve) => { - const socket = nodeNet.createConnection({ host: '127.0.0.1', port: proxyPort }) - let settled = false - let buf = '' - - const done = (payload: { ok: boolean, statusLine: string, error?: string }) => { - if (settled) return - settled = true - socket.destroy() - resolve(payload) - } - - socket.setTimeout(Math.max(1000, timeoutMs)) - socket.on('timeout', () => done({ ok: false, statusLine: '', error: 'timeout' })) - socket.on('error', (err: any) => done({ ok: false, statusLine: '', error: String(err?.message || err) })) - socket.on('connect', () => { - const req = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\nProxy-Connection: Keep-Alive\r\n\r\n` - socket.write(req) - }) - socket.on('data', (chunk: Buffer) => { - buf += chunk.toString('utf8') - const lineEnd = buf.indexOf('\r\n') - if (lineEnd < 0) return - const statusLine = buf.slice(0, lineEnd).trim() - done({ ok: /^HTTP\/1\.[01]\s+200\b/i.test(statusLine), statusLine }) - }) - }) -}) - -// Update & Info handlers -ipcMain.handle('app:get-version', () => app.getVersion()) -ipcMain.handle('app:check-update', async () => { - try { - const fetchGithub = async (url: string): Promise => { - return new Promise((resolve, reject) => { - const request = net.request(url) - request.setHeader('User-Agent', 'LagZero-Client') - request.on('response', (response) => { - if (response.statusCode !== 200) { - reject(new Error(`GitHub API Error: ${response.statusCode}`)) - return - } - let data = '' - response.on('data', (chunk) => { data += chunk }) - response.on('end', () => resolve(data)) - }) - request.on('error', (err) => reject(err)) - request.end() - }) - } - - let latestVersion = '' - let releaseDate = '' - let releaseNotes = '' - let hasUpdate = false - - try { - // 1. Try releases/latest first - const rawRelease = await fetchGithub('https://api.github.com/repos/ZenEcho/LagZero/releases/latest') - const release = JSON.parse(rawRelease) - latestVersion = release.tag_name.replace(/^v/, '') - releaseDate = new Date(release.published_at).toLocaleDateString() - releaseNotes = release.body || '' - } catch (e) { - // 2. Fallback to tags if latest release is not available - const rawTags = await fetchGithub('https://api.github.com/repos/ZenEcho/LagZero/tags') - const tags = JSON.parse(rawTags) - if (Array.isArray(tags) && tags.length > 0) { - latestVersion = tags[0].name.replace(/^v/, '') - releaseNotes = `Tag: ${tags[0].name}` - } else { - throw new Error('No version info found') - } - } - - const currentVersion = app.getVersion() - const v1Parts = latestVersion.split('.').map(Number) - const v2Parts = currentVersion.split('.').map(Number) - - for (let i = 0; i < Math.max(v1Parts.length, v2Parts.length); i++) { - const u = v1Parts[i] || 0 - const c = v2Parts[i] || 0 - if (u > c) { - hasUpdate = true - break - } - if (u < c) { - hasUpdate = false - break - } - } - - return { - updateAvailable: hasUpdate, - version: latestVersion, - releaseDate, - releaseNotes - } - } catch (e: any) { - return { error: e.message || 'Update check failed' } - } -}) - -ipcMain.handle('app:open-url', (_, url: string) => { - shell.openExternal(url) -}) diff --git a/electron/main/index.ts b/electron/main/index.ts new file mode 100644 index 0000000..c56fcd2 --- /dev/null +++ b/electron/main/index.ts @@ -0,0 +1,320 @@ +import { app, BrowserWindow, ipcMain, dialog, Tray, Menu, nativeImage, NativeImage, shell } from 'electron' +import { fileURLToPath, pathToFileURL } from 'node:url' +import path from 'node:path' +import fs from 'fs-extra' +import pkg from '../../package.json' + +import { SingBoxService } from '../services/singbox' +import { ProcessService } from '../services/process' +import { GameService } from '../services/game' +import { CategoryService } from '../services/category' +import { ProxyMonitorService } from '../services/proxy-monitor' +import { NodeService } from '../services/node' +import { DatabaseService } from '../services/database' +import { GameScannerService } from '../services/game-scanner' +import { UpdaterService } from '../services/updater' +import { SystemService } from '../services/system' +import { setupLogger } from './logger' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const __filename = fileURLToPath(import.meta.url) + +// Polyfill global.__filename and global.__dirname for better-sqlite3 +// @ts-ignore +global.__filename = __filename +// @ts-ignore +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) { + app.setPath('userData', path.join(process.env.APP_ROOT, '.' + pkg.name + '-dev')) +} +if (process.platform === 'win32') { + app.setAppUserModelId(APP_ID) +} + +let win: BrowserWindow | null = null +let tray: Tray | null = null + +// Services +let dbService: DatabaseService | null = null +let singboxService: SingBoxService | null = null +let processService: ProcessService | null = null +let gameService: GameService | null = null +let categoryService: CategoryService | null = null +let proxyMonitorService: ProxyMonitorService | null = null +let nodeService: NodeService | null = null +let gameScannerService: GameScannerService | null = null +let updaterService: UpdaterService | null = null +let systemService: SystemService | null = null + +// Setup Logger +setupLogger(() => win) + +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() + + win = new BrowserWindow({ + width: 1200, + height: 800, + minWidth: 860, + minHeight: 620, + frame: false, + webPreferences: { + preload: path.join(__dirname, '../preload/index.mjs'), // Adjusted path + nodeIntegration: false, + contextIsolation: true, + webSecurity: false, + }, + backgroundColor: '#1e1e1e', + ...(appIcon ? { icon: appIcon } : {}), + }) + + // Initialize Services + dbService = new DatabaseService() + singboxService = new SingBoxService(win) + processService = new ProcessService() + gameService = new GameService(dbService) + categoryService = new CategoryService(dbService) + proxyMonitorService = new ProxyMonitorService(win, processService, singboxService) + nodeService = new NodeService(dbService) + gameScannerService = new GameScannerService() + updaterService = new UpdaterService() + systemService = new SystemService() + + // Register generic DB IPCs + ipcMain.handle('db:export', () => dbService?.exportData()) + ipcMain.handle('db:import', (_, data) => dbService?.importData(data)) + + // Register Scanner IPCs + ipcMain.handle('system:scan-local-games', async () => { + try { + return await gameScannerService?.scanLocalGamesFromPlatforms() || [] + } catch (error) { + console.error('[GameScan] Failed to scan local games:', error) + return [] + } + }) + ipcMain.handle('dialog:pick-process-folder', async (_, maxDepth: number = 1) => { + if (!win) return null + const result = await dialog.showOpenDialog(win, { + properties: ['openDirectory'] + }) + if (result.canceled || result.filePaths.length === 0) return null + const dir = result.filePaths[0] + return gameScannerService?.scanDir(dir, maxDepth) + }) + + // Register Updater IPCs + 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')) + } + + createTray(appIcon) +} + +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() + } +}) + +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() + }) + .catch((error) => { + const message = formatStartupError(error) + console.error('[Main] App startup failed:', message) + dialog.showErrorBox('LagZero startup failed', message) + app.quit() + }) + +process.on('unhandledRejection', (reason) => { + const message = formatStartupError(reason) + console.error('[Main] Unhandled rejection:', message) +}) + +app.on('before-quit', () => { + tray?.destroy() + tray = null +}) + +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()) + +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)) +}) diff --git a/electron/main/logger.ts b/electron/main/logger.ts new file mode 100644 index 0000000..8b80aed --- /dev/null +++ b/electron/main/logger.ts @@ -0,0 +1,265 @@ +import { app, BrowserWindow, ipcMain } from 'electron' +import path from 'path' +import fs from 'fs-extra' +import { inspect } from 'util' +import { generateId } from '../utils/id' +import pkg from '../../package.json' + +/** + * 应用日志级别 + */ +type AppLogLevel = 'debug' | 'info' | 'warn' | 'error' + +/** + * 应用日志分类 + * - frontend: 渲染进程日志 + * - backend: 主进程业务日志 + * - core: sing-box 内核日志 + */ +type AppLogCategory = 'frontend' | 'backend' | 'core' + +/** + * 日志条目结构 + */ +export type AppLogEntry = { + /** 唯一日志 ID */ + id: string + /** 时间戳 */ + timestamp: number + /** 日志级别 */ + level: AppLogLevel + /** 日志分类 */ + category: AppLogCategory + /** 日志来源模块 */ + source: string + /** 主要消息内容 */ + message: string + /** 详细信息 (可选) */ + detail?: string +} + +const MAX_LOG_ENTRIES = 3000 +const appLogs: AppLogEntry[] = [] +const MAX_LOG_FILE_BYTES = 5 * 1024 * 1024 +const MAX_LOG_DIR_BYTES = 500 * 1024 * 1024 +const LOG_FILE_PREFIX = pkg.name +let logWriteChain: Promise = Promise.resolve() +let sessionLogFilePath = '' + +/** + * 获取日志文件存储目录 + */ +function getAppLogDir() { + return path.join(app.getPath('userData'), 'logs') +} + +/** + * 获取当前会话的日志文件路径 + * 格式: {userData}/logs/{appName}-{timestamp}-{pid}.log + */ +function getAppLogFilePath() { + if (sessionLogFilePath) return sessionLogFilePath + const stamp = new Date().toISOString().replace(/[:.]/g, '-') + sessionLogFilePath = path.join(getAppLogDir(), `${LOG_FILE_PREFIX}-${stamp}-${process.pid}.log`) + return sessionLogFilePath +} + +/** + * 将日志写入任务加入队列,确保按顺序写入文件 + */ +function queueLogWrite(task: () => Promise) { + logWriteChain = logWriteChain + .then(task) + .catch(() => { + // Avoid recursive logging when filesystem write fails. + }) +} + +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` +} + +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) + if (!exists) return + + const dirents = await fs.readdir(logDir, { withFileTypes: true }) + const files: Array<{ path: string, size: number, mtimeMs: number }> = [] + + for (const dirent of dirents) { + if (!dirent.isFile() || !dirent.name.toLowerCase().endsWith('.log')) continue + if (!dirent.name.toLowerCase().startsWith(`${LOG_FILE_PREFIX}-`)) continue + const full = path.join(logDir, dirent.name) + try { + const st = await fs.stat(full) + files.push({ path: full, size: st.size, mtimeMs: st.mtimeMs }) + } catch { + // ignore read failures + } + } + + files.sort((a, b) => a.mtimeMs - b.mtimeMs) + let total = files.reduce((sum, f) => sum + f.size, 0) + + for (const file of files) { + if (total <= MAX_LOG_DIR_BYTES) break + const active = path.normalize(file.path) === path.normalize(getAppLogFilePath()) + if (active && files.length > 1) continue + + try { + await fs.remove(file.path) + total -= file.size + } catch { + // ignore delete failures + } + } +} + +async function writeStartupSection() { + const logFilePath = getAppLogFilePath() + const now = new Date() + const section = [ + '', + '='.repeat(88), + `Session Start: ${now.toISOString()}`, + `Version: ${app.getVersion()} | PID: ${process.pid} | Platform: ${process.platform} ${process.arch}`, + `Electron: ${process.versions.electron} | Node: ${process.versions.node} | Chromium: ${process.versions.chrome}`, + '='.repeat(88), + '' + ].join('\n') + + await fs.ensureDir(path.dirname(logFilePath)) + await fs.writeFile(logFilePath, section, 'utf8') + await pruneLogDirIfNeeded(path.dirname(logFilePath)) +} + +function appendLogToFile(row: AppLogEntry) { + const line = formatLogLine(row) + const logFilePath = getAppLogFilePath() + + queueLogWrite(async () => { + await fs.ensureDir(path.dirname(logFilePath)) + await ensureSingleFileLimit(logFilePath, Buffer.byteLength(line, 'utf8')) + await fs.appendFile(logFilePath, line, 'utf8') + await pruneLogDirIfNeeded(path.dirname(logFilePath)) + }) +} + +function pushAppLog(entry: Omit, win?: BrowserWindow | null) { + const row: AppLogEntry = { + id: generateId(), + timestamp: Date.now(), + ...entry + } + appLogs.push(row) + if (appLogs.length > MAX_LOG_ENTRIES) appLogs.shift() + win?.webContents.send('app-log:new', row) + appendLogToFile(row) +} + +function stringifyConsoleArg(value: unknown): string { + if (typeof value === 'string') return value + if (value instanceof Error) return value.stack || value.message + return inspect(value, { depth: 3, breakLength: 120 }) +} + +function truncateLogText(text: string, max = 4000) { + if (text.length <= max) return text + return `${text.slice(0, max)}...` +} + +function inferLogCategory(message: string): AppLogCategory { + return message.includes('[SingBox]') ? 'core' : 'backend' +} + +/** + * 初始化并配置日志系统 + * + * 1. 拦截 console 方法,将日志重定向到文件和内存存储 + * 2. 写入启动日志头 + * 3. 注册 IPC 处理程序,供前端获取和推送日志 + * + * @param getWin - 获取主窗口实例的回调函数,用于向前端广播新日志 + */ +export function setupLogger(getWin: () => BrowserWindow | null) { + const original = { + debug: console.debug.bind(console), + info: console.info.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), + log: console.log.bind(console) + } + + const install = (method: keyof typeof original, level: AppLogLevel) => { + ; (console as any)[method] = (...args: unknown[]) => { + original[method](...args as any[]) + const text = truncateLogText(args.map(stringifyConsoleArg).join(' ').trim()) + pushAppLog({ + level, + category: inferLogCategory(text), + source: 'main', + message: text || '(empty)' + }, getWin()) + } + } + + install('debug', 'debug') + install('info', 'info') + install('warn', 'warn') + install('error', 'error') + install('log', 'info') + + queueLogWrite(writeStartupSection) + pushAppLog({ + level: 'info', + category: 'backend', + source: 'logger', + message: `App log file: ${getAppLogFilePath()}` + }, getWin()) + + ipcMain.handle('logs:get-all', () => appLogs) + ipcMain.handle('logs:clear', () => { + appLogs.length = 0 + const logDir = getAppLogDir() + queueLogWrite(async () => { + if (!await fs.pathExists(logDir)) return + const dirents = await fs.readdir(logDir, { withFileTypes: true }) + for (const dirent of dirents) { + if (!dirent.isFile() || !dirent.name.toLowerCase().endsWith('.log')) continue + if (!dirent.name.toLowerCase().startsWith(`${LOG_FILE_PREFIX}-`)) continue + await fs.remove(path.join(logDir, dirent.name)).catch(() => { }) + } + }) + }) + ipcMain.handle('logs:get-file-path', () => getAppLogFilePath()) + ipcMain.handle('logs:push-frontend', (_, payload: Partial) => { + const level: AppLogLevel = payload.level === 'debug' || payload.level === 'warn' || payload.level === 'error' + ? payload.level + : 'info' + + pushAppLog({ + level, + category: 'frontend', + source: String(payload.source || 'renderer'), + message: truncateLogText(String(payload.message || '(empty)')), + detail: payload.detail ? truncateLogText(String(payload.detail)) : undefined + }, getWin()) + }) +} diff --git a/electron/preload.ts b/electron/preload/index.ts similarity index 95% rename from electron/preload.ts rename to electron/preload/index.ts index ad38dde..3f1ddf6 100644 --- a/electron/preload.ts +++ b/electron/preload/index.ts @@ -1,3 +1,9 @@ +/** + * Electron 预加载脚本 + * + * 使用 ContextBridge 将主进程 API 安全地暴露给渲染进程。 + * 定义了 electron, singbox, system, proxyMonitor, nodes, games, categories, app, logs 等全局对象。 + */ import { contextBridge, ipcRenderer } from 'electron' const ipcListenerMap = new Map void, (...args: any[]) => void>>() diff --git a/electron/categories/manager.ts b/electron/services/category.ts similarity index 55% rename from electron/categories/manager.ts rename to electron/services/category.ts index 1ba2ef7..ba5e8ee 100644 --- a/electron/categories/manager.ts +++ b/electron/services/category.ts @@ -1,25 +1,51 @@ import { ipcMain } from 'electron' -import { DatabaseManager } from '../db' +import { DatabaseService } from './database' +/** + * 游戏分类接口定义 + */ export interface Category { + /** 分类唯一标识 */ id: string + /** 分类名称 */ name: string + /** 父分类 ID(暂未使用,保留层级扩展能力) */ parentId?: string + /** + * 自动分类规则 + * 存储正则表达式字符串,用于匹配游戏名或进程名 + */ rules?: string[] + /** 分类图标 URL 或 Base64 */ icon?: string + /** 排序权重,越小越靠前 */ order?: number } -export class CategoryManager { - private db: DatabaseManager +/** + * 游戏分类管理服务 + * + * 负责管理左侧侧边栏的游戏分类,支持 CRUD 操作以及 + * 基于规则的自动分类匹配。 + */ +export class CategoryService { + private db: DatabaseService + /** 内存缓存,减少数据库查询频率 */ private categoriesCache: Category[] = [] - constructor(db: DatabaseManager) { + /** + * 初始化分类服务 + * @param db - 数据库服务实例 + */ + constructor(db: DatabaseService) { this.db = db this.refreshCache() this.registerIPC() } + /** + * 刷新分类缓存 + */ private async refreshCache() { try { this.categoriesCache = await this.db.getAllCategories() @@ -28,24 +54,47 @@ export class CategoryManager { } } + /** + * 获取所有分类 + * @returns Promise + */ public async getAll() { this.categoriesCache = await this.db.getAllCategories() return this.categoriesCache } + /** + * 保存分类(新增或更新) + * @param category - 分类对象 + * @returns Promise - 返回最新的所有分类列表 + */ public async save(category: Category) { const result = await this.db.saveCategory(category) this.categoriesCache = result return result } + /** + * 删除分类 + * @param id - 分类 ID + * @returns Promise - 返回最新的所有分类列表 + */ public async delete(id: string) { const result = await this.db.deleteCategory(id) this.categoriesCache = result return result } - public matchCategory(gameName: string, processNames: string | string[]): string | null { + /** + * 根据游戏名称或进程名自动匹配分类 + * + * 遍历所有分类的 rules (正则),如果匹配成功则返回该分类 ID。 + * + * @param gameName - 游戏显示名称 + * @param processNames - 游戏进程名列表 + * @returns string | null - 匹配到的分类 ID,无匹配则返回 null + */ + public async matchCategory(gameName: string, processNames: string | string[]): Promise { // Use cache for synchronous matching logic if needed, or just use current state const categories = this.categoriesCache const pNames = Array.isArray(processNames) ? processNames : [processNames] diff --git a/electron/db/index.ts b/electron/services/database.ts similarity index 84% rename from electron/db/index.ts rename to electron/services/database.ts index dfee75b..c17f9e2 100644 --- a/electron/db/index.ts +++ b/electron/services/database.ts @@ -3,12 +3,20 @@ import { Kysely, SqliteDialect } from 'kysely' import { app } from 'electron' import path from 'path' import fs from 'fs-extra' -import { Database } from './schema' -import { v4 as uuidv4 } from 'uuid' +import { Database } from '../db/schema' +import { generateId } from '../utils/id' +import { isIconUrl, normalizeNodeType, parseStringArray, safeJsonParse } from '../utils/format' -export class DatabaseManager { +/** + * 数据库服务类 + * + * 负责 SQLite 数据库的连接、初始化、迁移以及所有核心数据的 CRUD 操作。 + * 使用 better-sqlite3 作为驱动,Kysely 作为查询构建器。 + */ +export class DatabaseService { private db: Kysely private sqlite: DatabaseConstructor.Database + /** 缓存 "Other/其他" 分类的 ID,避免频繁查询 */ private otherCategoryId: string | null | undefined constructor() { @@ -27,33 +35,11 @@ export class DatabaseManager { this.initSchema() } - private isIconUrl(value: string) { - const v = value.trim() - return ( - v.startsWith('http://') || - v.startsWith('https://') || - v.startsWith('file://') || - v.startsWith('data:') - ) - } - - private normalizeNodeType(type: unknown): string { - const t = String(type ?? '').trim().toLowerCase() - if (t === 'ss' || t === 'shadowsocks') return 'shadowsocks' - return t - } - - private parseStringArray(value: string): string[] { - try { - const parsed = JSON.parse(value) - if (Array.isArray(parsed)) { - return parsed.map(v => String(v).trim()).filter(Boolean) - } - } catch { - } - return value.split(/[,,]/).map(v => v.trim()).filter(Boolean) - } - + /** + * 解析并获取默认的 "Other" 分类 ID + * + * 如果数据库中不存在,会自动创建一个名为 "Other" 的分类。 + */ private async resolveOtherCategoryId() { if (this.otherCategoryId !== undefined) return this.otherCategoryId @@ -79,7 +65,7 @@ export class DatabaseManager { return this.otherCategoryId } - const id = uuidv4() + const id = generateId() const now = new Date().toISOString() await this.db .insertInto('categories') @@ -98,6 +84,12 @@ export class DatabaseManager { return this.otherCategoryId } + /** + * 初始化数据库 Schema + * + * 创建所需的表结构 (nodes, profiles, categories, games) 并执行必要的字段迁移。 + * 同时初始化默认的分类和游戏数据。 + */ private initSchema() { this.sqlite.exec(` CREATE TABLE IF NOT EXISTS nodes ( @@ -178,6 +170,9 @@ export class DatabaseManager { this.initDefaultData() } + /** + * 确保表字段存在(轻量级迁移) + */ private ensureColumn(table: string, column: string, definition: string) { try { const cols = this.sqlite.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }> @@ -189,6 +184,12 @@ export class DatabaseManager { } } + /** + * 初始化默认数据 + * + * 如果分类表为空,插入默认的游戏分类(FPS, MOBA 等)。 + * 如果游戏表为空,插入默认的全局模式游戏项。 + */ private async initDefaultData() { try { const result = this.sqlite.prepare('SELECT COUNT(*) as count FROM categories').get() as { count: number } @@ -211,7 +212,7 @@ export class DatabaseManager { const insertMany = this.sqlite.transaction((categories: typeof defaultCategories) => { for (const cat of categories) { insert.run({ - id: uuidv4(), + id: generateId(), name: cat.name, order: cat.order }) @@ -228,7 +229,7 @@ export class DatabaseManager { console.log('正在初始化默认模式...') const defaultGames = [ { - id: uuidv4(), + id: generateId(), name: '加速海外游戏', process_name: '[]', category_id: otherCategoryId, @@ -238,7 +239,7 @@ export class DatabaseManager { latency: 0 }, { - id: uuidv4(), + id: generateId(), name: '加速全部游戏', process_name: '[]', category_id: otherCategoryId, @@ -268,15 +269,22 @@ export class DatabaseManager { } } + /** + * 获取所有节点 + */ async getAllNodes() { const rows = await this.db.selectFrom('nodes').selectAll().execute() return rows.map(row => ({ ...row, - type: this.normalizeNodeType(row.type), - tls: row.tls ? JSON.parse(row.tls) : undefined + type: normalizeNodeType(row.type), + tls: safeJsonParse(row.tls || 'null', undefined) })) } + /** + * 保存节点(新增或更新) + * @param node - 节点对象 + */ async saveNode(node: any) { const validColumns = [ 'id', 'type', 'tag', 'server', 'server_port', @@ -286,7 +294,7 @@ export class DatabaseManager { ] const now = new Date().toISOString() - const id = node.id || uuidv4() + const id = node.id || generateId() const nodeData: any = { id, @@ -303,7 +311,7 @@ export class DatabaseManager { nodeData[key] = node[key] } } - nodeData.type = this.normalizeNodeType(nodeData.type) + nodeData.type = normalizeNodeType(nodeData.type) if (!node.created_at && !node.id) { nodeData.created_at = now @@ -324,18 +332,26 @@ export class DatabaseManager { return this.getAllNodes() } + /** + * 删除节点 + * @param id - 节点 ID + */ async deleteNode(id: string) { await this.db.deleteFrom('nodes').where('id', '=', id).execute() return this.getAllNodes() } + /** + * 批量导入节点 + * @param nodes - 节点数组 + */ async importNodes(nodes: any[]) { if (nodes.length === 0) return this.getAllNodes() const values = nodes.map(node => ({ ...node, - id: node.id || uuidv4(), - type: this.normalizeNodeType(node.type), + id: node.id || generateId(), + type: normalizeNodeType(node.type), tls: node.tls ? JSON.stringify(node.tls) : null, updated_at: new Date().toISOString() })) @@ -347,37 +363,47 @@ export class DatabaseManager { return this.getAllNodes() } + /** + * 获取所有游戏 + * + * 包含数据格式化:解析 JSON 字段、处理图标 URL 等。 + */ async getAllGames() { const rows = await this.db.selectFrom('games').selectAll().execute() const otherCategoryId = await this.resolveOtherCategoryId() return rows.map(row => ({ id: row.id, name: row.name, - iconUrl: row.icon && this.isIconUrl(row.icon) ? row.icon : undefined, - processName: this.parseStringArray(row.process_name), + iconUrl: row.icon && isIconUrl(row.icon) ? row.icon : undefined, + processName: parseStringArray(row.process_name), category: row.category_id === 'other' ? (otherCategoryId || row.category_id) : row.category_id, - tags: row.tags ? JSON.parse(row.tags) : undefined, + tags: safeJsonParse(row.tags || 'null', undefined), profileId: row.profile_id || undefined, lastPlayed: row.last_played || undefined, status: row.status || undefined, latency: row.latency || undefined, nodeId: row.node_id || undefined, proxyMode: (row.proxy_mode || 'process') as any, - routingRules: row.routing_rules ? this.parseStringArray(row.routing_rules) : undefined, + routingRules: row.routing_rules ? parseStringArray(row.routing_rules) : undefined, chainProxy: Boolean(row.chain_proxy) })) } + /** + * 保存游戏配置 + * + * 自动处理 "Other" 分类归属,并序列化 JSON 字段。 + */ async saveGame(game: any) { const otherCategoryId = await this.resolveOtherCategoryId() const iconValue = game.iconUrl || game.icon || '' - const icon = typeof iconValue === 'string' && iconValue.trim() && this.isIconUrl(iconValue) ? iconValue.trim() : null + const icon = typeof iconValue === 'string' && iconValue.trim() && isIconUrl(iconValue) ? iconValue.trim() : null const categoryId = game.category && game.category !== 'other' ? game.category : (otherCategoryId || 'other') const gameData = { - id: game.id || uuidv4(), + id: game.id || generateId(), name: game.name, icon, process_name: JSON.stringify(Array.isArray(game.processName) ? game.processName : [game.processName]), @@ -413,7 +439,7 @@ export class DatabaseManager { id: row.id, name: row.name, parentId: row.parent_id || undefined, - rules: row.rules ? JSON.parse(row.rules) : undefined, + rules: safeJsonParse(row.rules || 'null', undefined), icon: row.icon || undefined, order: row.order_index })) @@ -421,7 +447,7 @@ export class DatabaseManager { async saveCategory(category: any) { const data = { - id: category.id || uuidv4(), + id: category.id || generateId(), name: category.name, parent_id: category.parentId || null, rules: category.rules ? JSON.stringify(category.rules) : null, @@ -449,14 +475,14 @@ export class DatabaseManager { id: row.id, name: row.name, description: row.description || undefined, - rules: JSON.parse(row.rules), + rules: safeJsonParse(row.rules || '[]', []), chainProxy: Boolean(row.chain_proxy) })) } async saveProfile(profile: any) { const data = { - id: profile.id || uuidv4(), + id: profile.id || generateId(), name: profile.name, description: profile.description || null, rules: JSON.stringify(profile.rules || []), @@ -477,6 +503,11 @@ export class DatabaseManager { return this.getAllProfiles() } + /** + * 导出所有数据 + * + * 用于备份或迁移,包含节点、游戏、分类、配置文件等所有信息。 + */ async exportData() { const nodes = await this.getAllNodes() const games = await this.getAllGames() @@ -493,6 +524,11 @@ export class DatabaseManager { } } + /** + * 导入数据 + * + * 覆盖或新增数据,使用事务确保原子性。 + */ async importData(data: any) { const otherCategoryId = await this.resolveOtherCategoryId() await this.db.transaction().execute(async (trx) => { @@ -500,8 +536,8 @@ export class DatabaseManager { for (const node of data.nodes) { const nodeData = { ...node, - id: node.id || uuidv4(), - type: this.normalizeNodeType(node.type), + id: node.id || generateId(), + type: normalizeNodeType(node.type), tls: node.tls ? JSON.stringify(node.tls) : null, updated_at: new Date().toISOString() } @@ -512,7 +548,7 @@ export class DatabaseManager { if (data.categories) { for (const cat of data.categories) { const catData = { - id: cat.id || uuidv4(), + id: cat.id || generateId(), name: cat.name, parent_id: cat.parentId || null, rules: cat.rules ? JSON.stringify(cat.rules) : null, @@ -531,7 +567,7 @@ export class DatabaseManager { ? game.category : (otherCategoryId || 'other') const gameData = { - id: game.id || uuidv4(), + id: game.id || generateId(), name: game.name, icon: game.iconUrl || game.icon || null, process_name: JSON.stringify(Array.isArray(game.processName) ? game.processName : [game.processName]), @@ -554,7 +590,7 @@ export class DatabaseManager { if (data.profiles) { for (const profile of data.profiles) { const pData = { - id: profile.id || uuidv4(), + id: profile.id || generateId(), name: profile.name, description: profile.description || null, rules: JSON.stringify(profile.rules || []), diff --git a/electron/services/game-scanner.ts b/electron/services/game-scanner.ts new file mode 100644 index 0000000..c1279f1 --- /dev/null +++ b/electron/services/game-scanner.ts @@ -0,0 +1,400 @@ +import fs from 'fs-extra' +import path from 'path' +import { runCommand } from '../utils/command' + +/** + * 本地游戏扫描结果接口 + */ +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 { + async scanLocalGamesFromPlatforms(): Promise { + if (process.platform !== 'win32') return [] + const drives = await getWindowsDriveRoots() + + const [steam, microsoft, epic, ea] = await Promise.all([ + scanSteamGames(), + scanMicrosoftGames(), + scanFlatPlatformFolder('epic', Array.from(new Set( + drives.flatMap(root => [ + path.join(root, 'Epic Games'), + path.join(root, 'Program Files', 'Epic Games'), + path.join(root, 'Program Files (x86)', 'Epic Games') + ]) + ))), + scanFlatPlatformFolder('ea', Array.from(new Set( + drives.flatMap(root => [ + path.join(root, 'EA Games'), + path.join(root, 'Electronic Arts'), + path.join(root, 'Program Files', 'EA Games'), + path.join(root, 'Program Files', 'Electronic Arts'), + path.join(root, 'Program Files (x86)', 'EA Games'), + path.join(root, 'Program Files (x86)', 'Electronic Arts') + ]) + ))) + ]) + + const dedup = new Map() + for (const game of [...steam, ...microsoft, ...epic, ...ea]) { + const key = `${game.name.toLowerCase()}|${game.processName.toLowerCase()}` + if (!dedup.has(key)) dedup.set(key, game) + } + + return Array.from(dedup.values()) + } + + async scanDir(dir: string, maxDepth: number, currentDepth: number = 1): Promise { + let results: string[] = [] + try { + const dirents = await fs.readdir(dir, { withFileTypes: true }) + for (const dirent of dirents) { + if (dirent.isDirectory()) { + if (maxDepth === -1 || currentDepth < maxDepth) { + const subResults = await this.scanDir(path.join(dir, dirent.name), maxDepth, currentDepth + 1) + results.push(...subResults) + } + } else if (dirent.isFile() && dirent.name.toLowerCase().endsWith('.exe')) { + results.push(dirent.name) + } + } + } catch (err) { + console.error(`Error scanning directory ${dir}:`, err) + } + return results + } +} diff --git a/electron/services/game.ts b/electron/services/game.ts new file mode 100644 index 0000000..9583ed7 --- /dev/null +++ b/electron/services/game.ts @@ -0,0 +1,63 @@ +import { ipcMain } from 'electron' +import { DatabaseService } from './database' + +/** + * 游戏对象接口 + */ +export interface Game { + /** 游戏唯一 ID */ + id?: string + /** 游戏名称 */ + name: string + /** 图标 URL */ + iconUrl?: string + /** 游戏进程名 (字符串或数组) */ + processName: string | string[] + /** 分类 ID */ + category: string + /** 标签列表 */ + tags?: string[] + /** 关联的配置文件 ID */ + profileId?: string + /** 最后运行时间 */ + lastPlayed?: number + /** 当前状态 */ + status?: 'idle' | 'accelerating' + /** 延迟 (ms) */ + latency?: number + /** 指定的节点 ID */ + nodeId?: string + /** 代理模式 */ + proxyMode?: 'process' | 'routing' + /** 路由规则 */ + routingRules?: string[] + /** 是否启用链式代理 */ + chainProxy?: boolean +} + +/** + * 游戏管理服务 + * + * 只是 DatabaseService 的一层薄封装,主要负责通过 IPC 暴露游戏相关的 CRUD 操作。 + * 具体的业务逻辑目前主要在 DatabaseService 中处理。 + */ +export class GameService { + private db: DatabaseService + + constructor(db: DatabaseService) { + this.db = db + this.registerIPC() + } + + private registerIPC() { + ipcMain.handle('games:get-all', () => this.db.getAllGames()) + + ipcMain.handle('games:save', async (_, game: Game) => { + return this.db.saveGame(game) + }) + + ipcMain.handle('games:delete', async (_, id: string) => { + return this.db.deleteGame(id) + }) + } +} diff --git a/electron/nodes/manager.ts b/electron/services/node.ts similarity index 74% rename from electron/nodes/manager.ts rename to electron/services/node.ts index ee2bf49..061123f 100644 --- a/electron/nodes/manager.ts +++ b/electron/services/node.ts @@ -1,8 +1,13 @@ import { ipcMain } from 'electron' -import { DatabaseManager } from '../db' -import { v4 as uuidv4 } from 'uuid' +import { DatabaseService } from './database' // Reuse types from frontend via shared or duplicate for now to avoid complexity +/** + * 代理节点配置接口 + * + * 定义了节点的所有属性,包括服务器地址、端口、协议类型及各协议特定的配置项。 + * 尽量复用前端定义的类型结构。 + */ interface NodeConfig { id: string type: string @@ -40,10 +45,16 @@ interface NodeConfig { username?: string } -export class NodeManager { - private db: DatabaseManager +/** + * 节点管理服务 + * + * 负责代理节点的增删改查及导入导出。 + * 实际数据存储委托给 DatabaseService。 + */ +export class NodeService { + private db: DatabaseService - constructor(db: DatabaseManager) { + constructor(db: DatabaseService) { this.db = db this.registerIPC() } diff --git a/electron/process/manager.ts b/electron/services/process.ts similarity index 74% rename from electron/process/manager.ts rename to electron/services/process.ts index a5887d9..d8a937d 100644 --- a/electron/process/manager.ts +++ b/electron/services/process.ts @@ -4,7 +4,24 @@ import { promisify } from 'util' const execAsync = promisify(exec) -export class ProcessManager { +/** + * 进程节点结构 + */ +export interface ProcessNode { + path: string + name: string + ppid: number + pid: number + children: ProcessNode[] +} + +/** + * 进程管理服务 + * + * 提供跨平台的进程扫描和进程树获取功能。 + * 主要用于识别游戏进程及其父子关系。 + */ +export class ProcessService { constructor() { this.setupIPC() } @@ -18,6 +35,11 @@ export class ProcessManager { }) } + /** + * 扫描当前运行的所有进程 + * + * @returns Promise - 返回进程名列表 (如 ['chrome.exe', 'svchost.exe']) + */ async scanProcesses(): Promise { try { let command = '' @@ -59,7 +81,15 @@ export class ProcessManager { } } - async getProcessTree(): Promise { + /** + * 获取系统进程树 + * + * 仅支持 Windows 平台 (使用 wmic)。 + * 返回包含 PID, PPID, Name, ExecutablePath 的树状结构或扁平列表。 + * + * @returns Promise - 进程树根节点列表 + */ + async getProcessTree(): Promise { try { if (process.platform === 'win32') { // Use wmic to get process tree info @@ -78,17 +108,17 @@ export class ProcessManager { ppid: parseInt(parts[3], 10), pid: parseInt(parts[4], 10), children: [] - } - }).filter(p => p && p.pid) + } as ProcessNode + }).filter((p): p is ProcessNode => !!p && !!p.pid) // Build tree - const processMap = new Map() + const processMap = new Map() processes.forEach(p => processMap.set(p.pid, p)) - const rootProcesses: any[] = [] + const rootProcesses: ProcessNode[] = [] processes.forEach(p => { if (p.ppid && processMap.has(p.ppid)) { - processMap.get(p.ppid).children.push(p) + processMap.get(p.ppid)!.children.push(p) } else { rootProcesses.push(p) } diff --git a/electron/proxy/monitor.ts b/electron/services/proxy-monitor.ts similarity index 62% rename from electron/proxy/monitor.ts rename to electron/services/proxy-monitor.ts index ab051aa..bf8ab07 100644 --- a/electron/proxy/monitor.ts +++ b/electron/services/proxy-monitor.ts @@ -1,27 +1,30 @@ import { BrowserWindow, ipcMain } from 'electron'; -import { ProcessManager } from '../process/manager'; -import { SingBoxManager } from '../singbox/manager'; +import { ProcessService, ProcessNode } from './process'; +import { SingBoxService } from './singbox'; +import { normalizeProcessNames, normalizeProcessName } from '../utils/process-helper'; -interface ProcessInfo { - pid: number; - ppid: number; - name: string; - children?: ProcessInfo[]; -} - -export class ProxyMonitor { +/** + * 代理监控服务 + * + * 负责监控指定游戏的进程启动情况。 + * 当检测到游戏进程或其子进程启动时,自动通知 SingBoxService 更新路由规则。 + * 实现了"链式代理"检测逻辑,即自动代理游戏启动的所有子进程。 + */ +export class ProxyMonitorService { private mainWindow: BrowserWindow; - private processManager: ProcessManager; - private singboxManager: SingBoxManager; + private processService: ProcessService; + private singboxService: SingBoxService; private interval: NodeJS.Timeout | null = null; + /** 当前正在监控的目标进程名集合 */ private monitoredProcessNames: Set = new Set(); private activeGameId: string | null = null; + /** 已检测到的并加入监控的子进程集合 */ private detectedChildProcesses: Set = new Set(); - constructor(window: BrowserWindow, processManager: ProcessManager, singboxManager: SingBoxManager) { + constructor(window: BrowserWindow, processService: ProcessService, singboxService: SingBoxService) { this.mainWindow = window; - this.processManager = processManager; - this.singboxManager = singboxManager; + this.processService = processService; + this.singboxService = singboxService; this.setupIPC(); } @@ -35,10 +38,16 @@ export class ProxyMonitor { }); } + /** + * 开始监控指定游戏 + * + * @param gameId - 游戏 ID + * @param processNames - 游戏的初始进程名列表 + */ startMonitoring(gameId: string, processNames: string[]) { this.stopMonitoring(); this.activeGameId = gameId; - this.monitoredProcessNames = new Set(this.normalizeProcessNames(processNames)); + this.monitoredProcessNames = new Set(normalizeProcessNames(processNames)); this.detectedChildProcesses.clear(); console.log(`[ProxyMonitor] Started monitoring for game ${gameId} with processes:`, [...this.monitoredProcessNames]); @@ -47,9 +56,12 @@ export class ProxyMonitor { this.interval = setInterval(() => this.checkChainProxy(), 3000); this.mainWindow.webContents.send('proxy-monitor:status', { status: 'active', gameId }); - void this.singboxManager.updateProcessNames([...this.monitoredProcessNames]); + void this.singboxService.updateProcessNames([...this.monitoredProcessNames]); } + /** + * 停止监控 + */ stopMonitoring() { if (this.interval) { clearInterval(this.interval); @@ -62,12 +74,15 @@ export class ProxyMonitor { console.log('[ProxyMonitor] Stopped monitoring'); } + /** + * 周期性检查进程树,发现新启动的子进程 + */ private async checkChainProxy() { if (!this.activeGameId) return; try { - const tree = await this.processManager.getProcessTree(); - const newChildren = this.normalizeProcessNames(this.findNewChildren(tree)) + const tree = await this.processService.getProcessTree(); + const newChildren = normalizeProcessNames(this.findNewChildren(tree)) .filter(name => !this.monitoredProcessNames.has(name)); if (newChildren.length > 0) { @@ -81,19 +96,19 @@ export class ProxyMonitor { // Notify frontend this.mainWindow.webContents.send('proxy-monitor:detected', newChildren); - await this.singboxManager.updateProcessNames([...this.monitoredProcessNames]); + await this.singboxService.updateProcessNames([...this.monitoredProcessNames]); } } catch (error) { console.error('Proxy monitor error:', error); } } - private findNewChildren(nodes: ProcessInfo[]): string[] { + private findNewChildren(nodes: ProcessNode[]): string[] { const found: string[] = []; // Recursive traversal to find children of monitored processes - const traverse = (node: ProcessInfo, isProxiedParent: boolean) => { - const normalizedName = this.normalizeProcessName(node.name); + const traverse = (node: ProcessNode, isProxiedParent: boolean) => { + const normalizedName = normalizeProcessName(node.name); if (!normalizedName) return; const isMonitored = this.monitoredProcessNames.has(normalizedName); @@ -112,23 +127,4 @@ export class ProxyMonitor { nodes.forEach(node => traverse(node, false)); return found; } - - private normalizeProcessNames(processNames: string[]): string[] { - const set = new Set(); - processNames.forEach(name => { - const normalized = this.normalizeProcessName(name); - if (!normalized) return; - set.add(normalized); - const lower = normalized.toLowerCase(); - if (lower !== normalized) set.add(lower); - }); - return Array.from(set); - } - - private normalizeProcessName(processName: string): string { - const raw = String(processName || '').trim(); - if (!raw) return ''; - const normalized = raw.replace(/\\/g, '/'); - return (normalized.split('/').pop() || normalized).trim(); - } } diff --git a/electron/singbox/manager.ts b/electron/services/singbox.ts similarity index 93% rename from electron/singbox/manager.ts rename to electron/services/singbox.ts index 69becb3..f4883e0 100644 --- a/electron/singbox/manager.ts +++ b/electron/services/singbox.ts @@ -8,11 +8,22 @@ import { pipeline } from 'stream/promises' import { createWriteStream } from 'fs' import { Readable } from 'stream' import https from 'node:https' -import { randomUUID } from 'node:crypto' +import { generateId } from '../utils/id' + +import { normalizeProcessNames, areStringArraysEqual } from '../utils/process-helper' const BIN_DIR = path.join(app.getPath('userData'), 'bin') -export class SingBoxManager { +/** + * 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 @@ -166,6 +177,9 @@ export class SingBoxManager { this.retryCount = 0 } + /** + * 停止 sing-box + */ stop() { if (this.process) { this.isStopping = true @@ -175,14 +189,14 @@ export class SingBoxManager { 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) + 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') { @@ -268,6 +282,14 @@ console.log(text); 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)) @@ -283,7 +305,7 @@ console.log(text); if (!await fs.pathExists(configPath)) return if (!this.process) return - const normalized = this.normalizeProcessNames(processNames) + const normalized = normalizeProcessNames(processNames) if (normalized.length === 0) return let configRaw = '' @@ -304,7 +326,7 @@ console.log(text); 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 (!this.sameStringArray(routeRule.process_name, normalized)) { + if (!areStringArraysEqual(routeRule.process_name, normalized)) { routeRule.process_name = normalized changed = true } @@ -320,7 +342,7 @@ console.log(text); if (hasRemotePrimaryServer) { const dnsRule = dnsRules.find((r: any) => Array.isArray(r?.process_name) && r?.server === 'remote-primary') if (dnsRule) { - if (!this.sameStringArray(dnsRule.process_name, normalized)) { + if (!areStringArraysEqual(dnsRule.process_name, normalized)) { dnsRule.process_name = normalized changed = true } @@ -364,26 +386,6 @@ console.log(text); }) } - private normalizeProcessNames(processes: string[]): string[] { - const set = new Set() - for (const item of processes) { - const normalized = String(item || '').replace(/\\/g, '/').split('/').pop()?.trim() || '' - if (!normalized) continue - set.add(normalized) - const lower = normalized.toLowerCase() - if (lower !== normalized) set.add(lower) - } - return Array.from(set) - } - - private sameStringArray(a: unknown, b: unknown): boolean { - if (!Array.isArray(a) || !Array.isArray(b)) return false - if (a.length !== b.length) return false - const left = [...a].map(v => String(v)).sort() - const right = [...b].map(v => String(v)).sort() - return left.every((v, i) => v === right[i]) - } - private async downloadAndInstallBinary(platform: NodeJS.Platform, arch: string): Promise { const ext = platform === 'win32' ? '.exe' : '' const binPath = path.join(BIN_DIR, `sing-box${ext}`) @@ -395,7 +397,7 @@ console.log(text); 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-${randomUUID()}`) + const extractDir = path.join(BIN_DIR, `tmp-${generateId()}`) try { await fs.ensureDir(extractDir) diff --git a/electron/services/system.ts b/electron/services/system.ts new file mode 100644 index 0000000..6aa1669 --- /dev/null +++ b/electron/services/system.ts @@ -0,0 +1,126 @@ +import { ipcMain } from 'electron' +import { runCommand } from '../utils/command' +import { findAvailablePort } from '../utils/port' +import { ping, tcpPing } from '../utils/ping' +import nodeNet from 'node:net' + +type SystemCommandResult = { + ok: boolean + code: number + output: string + message: string +} + +export class SystemService { + constructor() { + this.registerIPC() + } + + async flushDnsCache(): Promise { + if (process.platform === 'win32') { + const { code, output } = await runCommand('ipconfig', ['/flushdns']) + return { + ok: code === 0, + code, + output, + message: code === 0 ? 'DNS cache flushed.' : 'Failed to flush DNS cache. Try running LagZero as administrator.' + } + } + + return { + ok: false, + code: -1, + output: '', + message: `Unsupported platform: ${process.platform}` + } + } + + async reinstallTunAdapter(interfaceName: string = 'LagZero'): Promise { + if (process.platform !== 'win32') { + return { + ok: false, + code: -1, + output: '', + message: `Unsupported platform: ${process.platform}` + } + } + + // Try to remove old adapter instance, then sing-box will recreate it on next start. + const psScript = [ + `$name = '${interfaceName.replace(/'/g, "''")}'`, + "$adapter = Get-NetAdapter -Name $name -ErrorAction SilentlyContinue", + "if ($null -eq $adapter) { Write-Output \"TUN adapter not found: $name\"; exit 2 }", + "Disable-NetAdapter -Name $name -Confirm:$false -ErrorAction SilentlyContinue | Out-Null", + "Start-Sleep -Milliseconds 400", + "if (Get-Command Remove-NetAdapter -ErrorAction SilentlyContinue) {", + " Remove-NetAdapter -Name $name -Confirm:$false -ErrorAction Stop", + " Write-Output \"TUN adapter removed: $name\"", + " exit 0", + "}", + "Write-Output \"Remove-NetAdapter not available\"", + "exit 3" + ].join('; ') + + const { code, output } = await runCommand('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', psScript], 25000) + const ok = code === 0 || code === 2 + return { + ok, + code, + output, + message: ok + ? 'TUN adapter reset finished. Restart acceleration to recreate adapter.' + : 'Failed to reset TUN adapter. Try running LagZero as administrator.' + } + } + + 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 }) + let settled = false + let buf = '' + + const done = (payload: { ok: boolean, statusLine: string, error?: string }) => { + if (settled) return + settled = true + socket.destroy() + resolve(payload) + } + + socket.setTimeout(Math.max(1000, timeoutMs)) + socket.on('timeout', () => done({ ok: false, statusLine: '', error: 'timeout' })) + socket.on('error', (err: any) => done({ ok: false, statusLine: '', error: String(err?.message || err) })) + socket.on('connect', () => { + const req = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\nProxy-Connection: Keep-Alive\r\n\r\n` + socket.write(req) + }) + socket.on('data', (chunk: Buffer) => { + buf += chunk.toString('utf8') + const lineEnd = buf.indexOf('\r\n') + if (lineEnd < 0) return + const statusLine = buf.slice(0, lineEnd).trim() + done({ ok: /^HTTP\/1\.[01]\s+200\b/i.test(statusLine), statusLine }) + }) + }) + } + + private registerIPC() { + ipcMain.handle('system:flush-dns-cache', () => this.flushDnsCache()) + ipcMain.handle('system:tun-reinstall', async (_, interfaceName?: string) => { + return this.reinstallTunAdapter(interfaceName || 'LagZero') + }) + 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) + }) + ipcMain.handle('system:ping', async (_, host: string) => ping(host)) + ipcMain.handle('system:tcp-ping', async (_, host: string, port: number) => { + try { + return await tcpPing(host, port) + } catch (e: any) { + return { latency: -1, loss: 100 } + } + }) + } +} diff --git a/electron/services/updater.ts b/electron/services/updater.ts new file mode 100644 index 0000000..a8d82ef --- /dev/null +++ b/electron/services/updater.ts @@ -0,0 +1,83 @@ +import { net, app } from 'electron' + +export type UpdateInfo = { + updateAvailable?: boolean + version?: string + releaseDate?: string + releaseNotes?: string + error?: string +} + +export class UpdaterService { + async checkUpdate(): Promise { + try { + const fetchGithub = async (url: string): Promise => { + return new Promise((resolve, reject) => { + const request = net.request(url) + request.setHeader('User-Agent', 'LagZero-Client') + request.on('response', (response) => { + if (response.statusCode !== 200) { + reject(new Error(`GitHub API Error: ${response.statusCode}`)) + return + } + let data = '' + response.on('data', (chunk) => { data += chunk }) + response.on('end', () => resolve(data)) + }) + request.on('error', (err) => reject(err)) + request.end() + }) + } + + let latestVersion = '' + let releaseDate = '' + let releaseNotes = '' + let hasUpdate = false + + try { + // 1. Try releases/latest first + const rawRelease = await fetchGithub('https://api.github.com/repos/ZenEcho/LagZero/releases/latest') + const release = JSON.parse(rawRelease) + latestVersion = release.tag_name.replace(/^v/, '') + releaseDate = new Date(release.published_at).toLocaleDateString() + releaseNotes = release.body || '' + } catch (e) { + // 2. Fallback to tags if latest release is not available + const rawTags = await fetchGithub('https://api.github.com/repos/ZenEcho/LagZero/tags') + const tags = JSON.parse(rawTags) + if (Array.isArray(tags) && tags.length > 0) { + latestVersion = tags[0].name.replace(/^v/, '') + releaseNotes = `Tag: ${tags[0].name}` + } else { + throw new Error('No version info found') + } + } + + const currentVersion = app.getVersion() + const v1Parts = latestVersion.split('.').map(Number) + const v2Parts = currentVersion.split('.').map(Number) + + for (let i = 0; i < Math.max(v1Parts.length, v2Parts.length); i++) { + const u = v1Parts[i] || 0 + const c = v2Parts[i] || 0 + if (u > c) { + hasUpdate = true + break + } + if (u < c) { + hasUpdate = false + break + } + } + + return { + updateAvailable: hasUpdate, + version: latestVersion, + releaseDate, + releaseNotes + } + } catch (e: any) { + return { error: e.message || 'Update check failed' } + } + } +} diff --git a/electron/utils/command.ts b/electron/utils/command.ts new file mode 100644 index 0000000..82285ac --- /dev/null +++ b/electron/utils/command.ts @@ -0,0 +1,45 @@ +import { spawn } from 'child_process' + +/** + * 执行系统命令并返回结果 + * + * 封装了 child_process.spawn,支持超时控制和 Windows 窗口隐藏。 + * + * @param command - 要执行的命令(如 'ping', 'ipconfig') + * @param args - 命令参数数组 + * @param timeoutMs - 超时时间(毫秒),默认 15000ms + * @returns Promise<{ code: number, output: string }> + * - code: 进程退出码,超时或错误时为 -1 + * - output: 标准输出和标准错误合并后的字符串 + */ +export function runCommand(command: string, args: string[], timeoutMs: number = 15000): Promise<{ code: number, output: string }> { + return new Promise((resolve) => { + const p = spawn(command, args, { windowsHide: true }) + 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) + + let timedOut = false + const timer = setTimeout(() => { + timedOut = true + p.kill() + }, timeoutMs) + + p.on('close', (code) => { + clearTimeout(timer) + const output = Buffer.concat(chunks).toString('utf8').trim() + if (timedOut) { + resolve({ code: -1, output: output || 'Command timeout' }) + return + } + resolve({ code: typeof code === 'number' ? code : -1, output }) + }) + + p.on('error', (err) => { + clearTimeout(timer) + resolve({ code: -1, output: String(err?.message || err) }) + }) + }) +} diff --git a/electron/utils/format.ts b/electron/utils/format.ts new file mode 100644 index 0000000..94d7ef7 --- /dev/null +++ b/electron/utils/format.ts @@ -0,0 +1,70 @@ +/** + * 检查字符串是否为有效的图标 URL + * + * 支持 http/https/file 协议及 data URI scheme。 + * + * @param value - 要检查的字符串 + * @returns boolean - 如果是有效图标 URL 则返回 true + */ +export function isIconUrl(value: string): boolean { + const v = value.trim() + return ( + v.startsWith('http://') || + v.startsWith('https://') || + v.startsWith('file://') || + v.startsWith('data:') + ) +} + +/** + * 标准化节点类型字符串 + * + * 将输入的节点类型转换为统一的内部标识符。 + * 例如:'ss' -> 'shadowsocks' + * + * @param type - 原始节点类型 + * @returns string - 标准化后的节点类型 + */ +export function normalizeNodeType(type: unknown): string { + const t = String(type ?? '').trim().toLowerCase() + if (t === 'ss' || t === 'shadowsocks') return 'shadowsocks' + return t +} + +/** + * 安全解析 JSON 字符串 + * + * 尝试解析 JSON,如果失败则返回默认值,避免抛出异常。 + * + * @template T - 返回值的类型 + * @param value - JSON 字符串 + * @param fallback - 解析失败时返回的默认值 + * @returns T - 解析结果或默认值 + */ +export function safeJsonParse(value: string, fallback: T): T { + try { + return JSON.parse(value) as T + } catch { + return fallback + } +} + +/** + * 解析字符串数组 + * + * 支持解析 JSON 数组字符串或逗号分隔的字符串。 + * + * @param value - 输入字符串 + * @returns string[] - 解析后的字符串数组 + */ +export function parseStringArray(value: string): string[] { + try { + const parsed = JSON.parse(value) + if (Array.isArray(parsed)) { + return parsed.map(v => String(v).trim()).filter(Boolean) + } + } catch { + // ignore + } + return value.split(/[,,]/).map(v => v.trim()).filter(Boolean) +} diff --git a/electron/utils/id.ts b/electron/utils/id.ts new file mode 100644 index 0000000..5fd5d49 --- /dev/null +++ b/electron/utils/id.ts @@ -0,0 +1,12 @@ +import { v4 as uuidv4 } from 'uuid' + +/** + * 生成全局唯一的 UUID + * + * 统一封装 UUID 生成逻辑,方便后续统一替换生成算法。 + * + * @returns string - UUID v4 字符串 + */ +export function generateId(): string { + return uuidv4() +} diff --git a/electron/network/ping.ts b/electron/utils/ping.ts similarity index 79% rename from electron/network/ping.ts rename to electron/utils/ping.ts index 22e8f97..8d33d51 100644 --- a/electron/network/ping.ts +++ b/electron/utils/ping.ts @@ -3,13 +3,27 @@ import { exec } from 'node:child_process' import net from 'node:net' import os from 'node:os' +/** + * Ping 测试结果接口 + */ interface PingResult { + /** 延迟 (毫秒),-1 表示超时或不可达 */ latency: number + /** 丢包率 (0-100) */ loss: number } const isWindows = os.platform() === 'win32' +/** + * 执行 ICMP Ping 测试 + * + * 使用系统 ping 命令测试目标主机的连通性和延迟。 + * + * @param host - 目标主机 (域名或 IP) + * @param timeout - 超时时间 (毫秒),默认 2000ms + * @returns Promise - 测试结果 + */ export function ping(host: string, timeout = 2000): Promise { return new Promise((resolve) => { // Windows: -n 1 (count), -w timeout (ms) @@ -60,6 +74,17 @@ export function ping(host: string, timeout = 2000): Promise { }) } +/** + * 执行 TCP Ping 测试 + * + * 尝试建立 TCP 连接来测试端口连通性和延迟。 + * 比 ICMP Ping 更能反映实际服务的可用性。 + * + * @param host - 目标主机 + * @param port - 目标端口 + * @param timeout - 超时时间 (毫秒),默认 2000ms + * @returns Promise - 测试结果 + */ export function tcpPing(host: string, port: number, timeout = 2000): Promise { return new Promise((resolve) => { const start = Date.now() diff --git a/electron/network/port.ts b/electron/utils/port.ts similarity index 61% rename from electron/network/port.ts rename to electron/utils/port.ts index b5e3157..de16f30 100644 --- a/electron/network/port.ts +++ b/electron/utils/port.ts @@ -1,6 +1,16 @@ import net from 'net' +/** + * 查找可用端口 + * + * 从指定的起始端口开始,寻找连续 count 个可用端口。 + * + * @param startPort - 起始端口号 + * @param count - 需要的连续可用端口数量,默认 1 + * @returns Promise - 找到的第一个可用端口号 + * @throws Error - 如果遍历到 65535 仍未找到可用端口 + */ export async function findAvailablePort(startPort: number, count: number = 1): Promise { let port = startPort while (true) { @@ -24,6 +34,14 @@ export async function findAvailablePort(startPort: number, count: number = 1): P } } +/** + * 检查指定端口是否可用 + * + * 尝试在本地监听该端口,如果成功则认为可用。 + * + * @param port - 要检查的端口号 + * @returns Promise - true 表示可用,false 表示已被占用 + */ function isPortAvailable(port: number): Promise { return new Promise((resolve) => { const server = net.createServer() diff --git a/electron/utils/process-helper.ts b/electron/utils/process-helper.ts new file mode 100644 index 0000000..66e90fc --- /dev/null +++ b/electron/utils/process-helper.ts @@ -0,0 +1,49 @@ +/** + * 标准化进程名称 + * + * 去除路径前缀,只保留文件名,并去除首尾空格。 + * + * @param processName - 原始进程名或路径 + * @returns string - 标准化后的进程文件名 (如 'game.exe') + */ +export function normalizeProcessName(processName: string): string { + const raw = String(processName || '').trim() + if (!raw) return '' + const normalized = raw.replace(/\\/g, '/') + return (normalized.split('/').pop() || normalized).trim() +} + +/** + * 标准化进程名列表 + * + * 对列表中的每个进程名进行标准化,去重,并同时保留小写版本以支持忽略大小写的匹配。 + * + * @param processNames - 原始进程名列表 + * @returns string[] - 处理后的去重进程名列表 + */ +export function normalizeProcessNames(processNames: string[]): string[] { + const set = new Set() + for (const item of processNames) { + const normalized = normalizeProcessName(item) + if (!normalized) continue + set.add(normalized) + const lower = normalized.toLowerCase() + if (lower !== normalized) set.add(lower) + } + return Array.from(set) +} + +/** + * 比较两个字符串数组是否相等 (忽略顺序) + * + * @param a - 数组 A + * @param b - 数组 B + * @returns boolean - 如果包含相同的元素(忽略顺序)则返回 true + */ +export function areStringArraysEqual(a: unknown, b: unknown): boolean { + if (!Array.isArray(a) || !Array.isArray(b)) return false + if (a.length !== b.length) return false + const left = [...a].map(v => String(v)).sort() + const right = [...b].map(v => String(v)).sort() + return left.every((v, i) => v === right[i]) +} diff --git a/package.json b/package.json index 8fd9bd5..7ffda4f 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "productName": "LagZero", "private": true, "version": "0.1.0", - "main": "dist-electron/main.js", + "main": "dist-electron/main/index.js", "type": "module", "author": "LagZero Team", "description": "Cross-platform Game Accelerator", diff --git a/vite.config.ts b/vite.config.ts index 0601f81..cdb49bb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -32,7 +32,7 @@ export default defineConfig({ }), electron({ main: { - entry: 'electron/main.ts', + entry: 'electron/main/index.ts', vite: { build: { rollupOptions: { @@ -42,7 +42,7 @@ export default defineConfig({ }, }, preload: { - input: 'electron/preload.ts', + input: 'electron/preload/index.ts', }, renderer: {}, }),