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:
1 parent
450ca875f9
commit
23938d6479
28 files changed
+1905
-1334
No files matched your search
Whitespace-only changes.
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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))
|
||||
})
|
||||
@@ -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 || []),
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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 }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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: {},
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user