refactor(electron): 重构项目结构并引入服务层架构

- 将 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
- 更新构建配置以支持新的目录结构
- 修复主入口文件路径配置
This commit is contained in:
ZenEcho committed 2026-02-18 15:49:27 +08:00
1 parent 450ca875f9
commit 23938d6479
28 files changed
+1905 -1334

No files matched your search

View File
Whitespace-only changes.
+64
View File
@@ -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` 后缀),确保规则匹配的准确性。
File renamed without changes.
@@ -1,13 +1,27 @@
import fs from 'fs-extra'
import path from 'node:path'
/**
* 本地 JSON 存储类配置选项
*/
export interface StoreOptions<T> {
/** 存储文件名称 (不含后缀) */
name: string
/** 默认数据 */
defaults: T
/** 存储目录路径 */
cwd: string
/** 保留的备份文件数量,默认为 5 */
backupCount?: number
}
/**
* 简单的本地 JSON 文件存储
*
* 提供数据的持久化存储、读取、自动备份功能。
*
* @template T - 存储数据的类型接口
*/
export class JsonStore<T> {
private filePath: string
private backupDir: string
+71
View File
@@ -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<string>
/** 更新时间 */
updated_at: Generated<string>
}
/**
* 配置文件表结构
* 存储分流规则集合
*/
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<string>
/** 更新时间 */
updated_at: Generated<string>
}
/**
* 游戏分类表结构
*/
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<string>
/** 更新时间 */
updated_at: Generated<string>
}
/**
* 游戏表结构
*/
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<string>
/** 更新时间 */
updated_at: Generated<string>
}
/**
* 数据库全表接口定义
*/
export interface Database {
nodes: NodesTable
profiles: ProfilesTable
-40
View File
@@ -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)
})
}
}
-1144
View File
File diff suppressed because it is too large. Load diff
+320
View File
@@ -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(/<filter[\s\S]*?<\/filter>/gi, '')
.replace(/\sfilter="url\(#.*?\)"/gi, '')
const image = toImage(sanitized)
if (!image.isEmpty()) return image
const fallbackSvg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<rect width="256" height="256" rx="48" fill="#0f172a"/>
<path d="M145 20 L78 131 L136 131 L118 236 L202 111 L141 111 L168 20 Z"
fill="#22c55e" stroke="#ffffff" stroke-width="6" stroke-linejoin="round" />
</svg>`
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))
})
+265
View File
@@ -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<void> = 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<void>) {
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<AppLogEntry, 'id' | 'timestamp'>, 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<AppLogEntry>) => {
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())
})
}
@@ -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<string, Map<(...args: any[]) => void, (...args: any[]) => void>>()
@@ -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<Category[]>
*/
public async getAll() {
this.categoriesCache = await this.db.getAllCategories()
return this.categoriesCache
}
/**
* 保存分类(新增或更新)
* @param category - 分类对象
* @returns Promise<Category[]> - 返回最新的所有分类列表
*/
public async save(category: Category) {
const result = await this.db.saveCategory(category)
this.categoriesCache = result
return result
}
/**
* 删除分类
* @param id - 分类 ID
* @returns Promise<Category[]> - 返回最新的所有分类列表
*/
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<string | null> {
// Use cache for synchronous matching logic if needed, or just use current state
const categories = this.categoriesCache
const pNames = Array.isArray(processNames) ? processNames : [processNames]
@@ -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<Database>
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 || []),
+400
View File
@@ -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<string[]> {
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<string[]> {
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<string | null> {
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<string[]> {
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<string>()
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<LocalGameScanResult[]> {
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<LocalGameScanResult[]> {
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<string, string>) {
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<T>(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<Map<string, string>> {
const map = new Map<string, string>()
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>([^<]+)<\/DisplayName>/i)?.[1]?.trim() || ''
const identityName = content.match(/<Identity[^>]*\sName="([^"]+)"/i)?.[1]?.trim() || ''
if (displayName && !/^ms-resource:/i.test(displayName)) return displayName
return identityName
}
async function getManifestDisplayNameHints(xboxRoots: string[]): Promise<Map<string, string>> {
const map = new Map<string, string>()
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<LocalGameScanResult[]> {
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<string, string>([...registryHints, ...manifestHints])
return base.map(game => {
const hintName = pickBestNameFromHints(game.installDir, hints)
return hintName ? { ...game, name: hintName } : game
})
}
export class GameScannerService {
async scanLocalGamesFromPlatforms(): Promise<LocalGameScanResult[]> {
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<string, LocalGameScanResult>()
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<string[]> {
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
}
}
+63
View File
@@ -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)
})
}
}
@@ -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()
}
@@ -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<string[]> - 返回进程名列表 (如 ['chrome.exe', 'svchost.exe'])
*/
async scanProcesses(): Promise<string[]> {
try {
let command = ''
@@ -59,7 +81,15 @@ export class ProcessManager {
}
}
async getProcessTree(): Promise<any[]> {
/**
* 获取系统进程树
*
* 仅支持 Windows 平台 (使用 wmic)。
* 返回包含 PID, PPID, Name, ExecutablePath 的树状结构或扁平列表。
*
* @returns Promise<any[]> - 进程树根节点列表
*/
async getProcessTree(): Promise<ProcessNode[]> {
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<number, any>()
const processMap = new Map<number, ProcessNode>()
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)
}
@@ -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<string> = new Set();
private activeGameId: string | null = null;
/** 已检测到的并加入监控的子进程集合 */
private detectedChildProcesses: Set<string> = 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<string>();
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();
}
}
@@ -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<void> {
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<string>()
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<string> {
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)
+126
View File
@@ -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<SystemCommandResult> {
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<SystemCommandResult> {
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 }
}
})
}
}
+83
View File
@@ -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<UpdateInfo> {
try {
const fetchGithub = async (url: string): Promise<string> => {
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' }
}
}
}
+45
View File
@@ -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) })
})
})
}
+70
View File
@@ -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<T>(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)
}
+12
View File
@@ -0,0 +1,12 @@
import { v4 as uuidv4 } from 'uuid'
/**
* 生成全局唯一的 UUID
*
* 统一封装 UUID 生成逻辑,方便后续统一替换生成算法。
*
* @returns string - UUID v4 字符串
*/
export function generateId(): string {
return uuidv4()
}
@@ -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<PingResult> - 测试结果
*/
export function ping(host: string, timeout = 2000): Promise<PingResult> {
return new Promise((resolve) => {
// Windows: -n 1 (count), -w timeout (ms)
@@ -60,6 +74,17 @@ export function ping(host: string, timeout = 2000): Promise<PingResult> {
})
}
/**
* 执行 TCP Ping 测试
*
* 尝试建立 TCP 连接来测试端口连通性和延迟。
* 比 ICMP Ping 更能反映实际服务的可用性。
*
* @param host - 目标主机
* @param port - 目标端口
* @param timeout - 超时时间 (毫秒),默认 2000ms
* @returns Promise<PingResult> - 测试结果
*/
export function tcpPing(host: string, port: number, timeout = 2000): Promise<PingResult> {
return new Promise((resolve) => {
const start = Date.now()
@@ -1,6 +1,16 @@
import net from 'net'
/**
* 查找可用端口
*
* 从指定的起始端口开始,寻找连续 count 个可用端口。
*
* @param startPort - 起始端口号
* @param count - 需要的连续可用端口数量,默认 1
* @returns Promise<number> - 找到的第一个可用端口号
* @throws Error - 如果遍历到 65535 仍未找到可用端口
*/
export async function findAvailablePort(startPort: number, count: number = 1): Promise<number> {
let port = startPort
while (true) {
@@ -24,6 +34,14 @@ export async function findAvailablePort(startPort: number, count: number = 1): P
}
}
/**
* 检查指定端口是否可用
*
* 尝试在本地监听该端口,如果成功则认为可用。
*
* @param port - 要检查的端口号
* @returns Promise<boolean> - true 表示可用,false 表示已被占用
*/
function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer()
+49
View File
@@ -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<string>()
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])
}
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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: {},
}),