feat: 新增游戏平台扫描、主题模式与重置功能

- 新增 BattleNet、WeGame、GOG 等游戏平台扫描支持
- 添加系统代理绕过列表和 Bootstrap DNS 配置
- 实现应用重置功能,支持清理数据并重启
- 增加主题模式选择(亮色/暗色/自动)
- 优化本地游戏扫描性能,支持并行扫描
- 改进代理监控,支持链式代理子进程检测
- 修复日志发送时的窗口销毁检查
- 更新图标和侧边栏布局
- 移除 prepare-singbox-core 脚本,简化构建流程
This commit is contained in:
ZenEcho committed 2026-02-22 03:57:55 +08:00
1 parent 4ed9f1441f
commit 489f3d4f1f
67 files changed
+3661 -1060

No files matched your search

+3 -3
View File
@@ -44,7 +44,7 @@ export class JsonStore<T> {
this.save()
}
} catch (e) {
console.error(`Failed to load store ${this.options.name}:`, e)
console.error(`加载存储 ${this.options.name} 失败:`, e)
// Attempt to load latest backup if main file is corrupted?
}
}
@@ -63,7 +63,7 @@ export class JsonStore<T> {
await this.backup()
await fs.writeJson(this.filePath, this.data, { spaces: 2 })
} catch (e) {
console.error(`Failed to save store ${this.options.name}:`, e)
console.error(`保存存储 ${this.options.name} 失败:`, e)
}
}
@@ -91,7 +91,7 @@ export class JsonStore<T> {
await Promise.all(toDelete.map(f => fs.remove(f)))
}
} catch (e) {
console.error(`Backup failed for ${this.options.name}:`, e)
console.error(`备份 ${this.options.name} 失败:`, e)
}
}
}
+7 -41
View File
@@ -81,19 +81,14 @@ export function ensureAdminAtStartup() {
process.exit(0)
}
console.warn('[Main] Failed to relaunch as administrator. Continuing without elevation.')
console.warn('[Main] 无法以管理员身份重启,将继续以普通权限运行。')
}
/**
* 加载应用图标
* 依次尝试加载 logo.png, logo.ico, logo.svg
* @returns NativeImage 对象或 null
*/
export function loadAppIcon(): NativeImage | null {
const baseDir = process.env.VITE_PUBLIC || ''
const candidates = [
path.join(baseDir, 'logo.png'),
path.join(baseDir, 'logo.ico'),
path.join(baseDir, 'logo.png'),
path.join(baseDir, 'logo.svg'),
]
@@ -109,37 +104,7 @@ export function loadAppIcon(): NativeImage | null {
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
}
console.warn('[Main] 未找到图标文件。')
return null
}
@@ -158,7 +123,8 @@ export function formatStartupError(error: unknown) {
'Failed to load better-sqlite3 native module due to architecture mismatch.',
'',
`Current runtime arch: ${process.arch}`,
'Fix command: pnpm rebuild better-sqlite3',
'Fix command: pnpm run rebuild:native',
'Fallback command: pnpm run rebuild:sqlite',
'',
'Details:',
raw
@@ -188,7 +154,7 @@ export function formatStartupError(error: unknown) {
*/
export function handleStartupError(error: unknown) {
const message = formatStartupError(error)
console.error('[Main] App startup failed:', message)
dialog.showErrorBox('LagZero startup failed', message)
console.error('[Main] 应用启动失败:', message)
dialog.showErrorBox('LagZero 启动失败', message)
app.quit()
}
+280 -9
View File
@@ -2,6 +2,7 @@ import { app, BrowserWindow, ipcMain, dialog, shell, session } from 'electron'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import fs from 'fs-extra'
import { spawn } from 'node:child_process'
import pkg from '../../package.json'
import { SingBoxService } from '../services/singbox'
@@ -19,7 +20,14 @@ import { ensureAdminAtStartup, loadAppIcon, handleStartupError } from './bootstr
import { WindowManager, VITE_DEV_SERVER_URL, MAIN_DIST, RENDERER_DIST } from './window'
import { TrayManager } from './tray'
/**
* 当前文件的目录路径
*/
const __dirname = path.dirname(fileURLToPath(import.meta.url))
/**
* 当前文件的完整路径
*/
const __filename = fileURLToPath(import.meta.url)
// Polyfill global.__filename and global.__dirname for better-sqlite3
@@ -28,14 +36,28 @@ global.__filename = __filename
// @ts-ignore
global.__dirname = __dirname
/**
* 应用根目录路径
*/
process.env.APP_ROOT = path.join(__dirname, '../')
/**
* 应用程序 ID,用于 Windows 上的 AppUserModelId
*/
const APP_ID = 'com.' + pkg.name + '.client'
/**
* 静态资源目录路径
* 开发环境下指向 public 目录,生产环境下指向渲染进程构建目录
*/
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, 'public') : RENDERER_DIST
// 设置开发环境下的 userData 目录,避免污染生产环境数据
if (VITE_DEV_SERVER_URL) {
app.setPath('userData', path.join(process.env.APP_ROOT, '.' + pkg.name + '-dev'))
}
// 设置 Windows 上的 AppUserModelId,确保任务栏图标和通知正常显示
if (process.platform === 'win32') {
app.setAppUserModelId(APP_ID)
}
@@ -43,10 +65,17 @@ if (process.platform === 'win32') {
// 确保以管理员权限启动
ensureAdminAtStartup()
/**
* 窗口管理器实例
*/
const windowManager = new WindowManager()
/**
* 托盘管理器实例
*/
const trayManager = new TrayManager(null as any) // 稍后更新 win 引用
// Services
// Services 实例引用
let dbService: DatabaseService | null = null
let singboxService: SingBoxService | null = null
let processService: ProcessService | null = null
@@ -57,11 +86,42 @@ let nodeService: NodeService | null = null
let gameScannerService: GameScannerService | null = null
let updaterService: UpdaterService | null = null
let systemService: SystemService | null = null
/**
* 是否已经开始退出清理流程
*/
let quitCleanupStarted = false
/**
* 是否正在为了重启而退出
*/
let quittingForRestart = false
/**
* 开启 Chromium 底层日志落盘
* 这类日志(如 disk_cache/gpu cache)不走 console.*,需要通过命令行开关单独捕获。
*/
function configureChromiumLogging() {
try {
const logDir = path.join(app.getPath('userData'), 'logs')
fs.ensureDirSync(logDir)
const chromiumLogFile = path.join(logDir, 'chromium.log')
app.commandLine.appendSwitch('enable-logging')
app.commandLine.appendSwitch('log-file', chromiumLogFile)
} catch (e) {
console.warn('[Main] 配置 Chromium 日志失败:', e)
}
}
configureChromiumLogging()
// 初始化日志系统
setupLogger(() => windowManager.get())
/**
* 构建内容安全策略 (CSP) 字符串
* @returns CSP 字符串
*/
function buildContentSecurityPolicy(): string {
return [
"default-src 'self'",
@@ -78,6 +138,10 @@ function buildContentSecurityPolicy(): string {
].join('; ')
}
/**
* 设置渲染进程的内容安全策略 (CSP)
* 拦截请求头并注入 CSP
*/
function setupRendererCsp() {
const csp = buildContentSecurityPolicy()
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
@@ -92,6 +156,11 @@ function setupRendererCsp() {
})
}
/**
* 将本地文件转换为 Data URL 格式
* @param filePath 文件绝对路径
* @returns Data URL 字符串 (例如: data:image/png;base64,...)
*/
function toImageDataUrl(filePath: string): string {
const ext = path.extname(filePath).toLowerCase()
const mimeMap: Record<string, string> = {
@@ -107,6 +176,144 @@ function toImageDataUrl(filePath: string): string {
return `data:${mime};base64,${data.toString('base64')}`
}
/**
* 重置标记文件名
*/
const RESET_MARKER_FILENAME = `${pkg.name}-pending-reset.marker`
/**
* 获取重置标记文件的路径
* @returns 临时目录下的标记文件路径
*/
function getResetMarkerPath(): string {
return path.join(app.getPath('temp'), RESET_MARKER_FILENAME)
}
/**
* 解析重置时需要清理的数据目录
* - 当前 userData 目录 开发目录.lagzero-dev
* - 当前 appDate 目录 C:\Users\user\AppData\Roaming\lagZero
* - Windows Roaming(appData) 下的 LagZero 目录(开发模式也会清理)
*/
function resolveResetTargetDirectories(): string[] {
const appData = app.getPath('appData')
const userData = app.getPath('userData')
const candidates = [
userData,
path.join(appData, app.getName()),
path.join(appData, pkg.productName || ''),
path.join(appData, pkg.name || '')
].filter(Boolean)
const seen = new Set<string>()
const targets: string[] = []
for (const p of candidates) {
const normalized = path.resolve(p)
const normalizedAppData = path.resolve(appData)
if (normalized === normalizedAppData) continue
if (!normalized.startsWith(normalizedAppData + path.sep) && normalized !== path.resolve(userData)) continue
if (seen.has(normalized)) continue
seen.add(normalized)
targets.push(normalized)
}
return targets
}
/**
* 清理单个目录下的全部内容
*/
async function clearDirectoryContents(targetDir: string) {
if (!await fs.pathExists(targetDir)) return []
const entries = await fs.readdir(targetDir)
const failed: string[] = []
await Promise.all(entries.map(async (name) => {
const target = path.join(targetDir, name)
try {
await fs.remove(target)
} catch {
failed.push(name)
}
}))
return failed
}
/**
* 清理用户数据目录
*/
async function clearUserDataDirectory() {
const targets = resolveResetTargetDirectories()
for (const targetDir of targets) {
const failed = await clearDirectoryContents(targetDir)
if (failed.length > 0) {
console.warn(`[Main] 无法完全清理用户数据目录(${targetDir}):`, failed.join(', '))
}
}
console.info('[Main] 已执行重置目录清理:', targets.join(' | '))
}
/**
* 检查是否存在待处理的重置标记,如果存在则清理用户数据
* 通常在应用启动时调用
*/
async function applyPendingResetIfNeeded() {
const markerPath = getResetMarkerPath()
if (!await fs.pathExists(markerPath)) return
console.log('[Main] 检测到待处理的重置请求,正在清理用户数据...')
try {
await clearUserDataDirectory()
} catch (e) {
console.error('[Main] 无法清理待重置的用户数据:', e)
} finally {
await fs.remove(markerPath).catch(() => { })
}
}
/**
* 获取重启时使用的可执行文件路径
* 处理 Windows 下的便携版情况
* @returns 可执行文件路径
*/
function getRelaunchExecPath(): string {
if (process.platform === 'win32') {
return process.env.PORTABLE_EXECUTABLE_FILE || process.execPath
}
return process.execPath
}
/**
* 安全地重启应用程序
* @returns 是否成功触发重启
*/
function relaunchAppSafely(): boolean {
try {
quittingForRestart = true
const args = process.argv.slice(1)
if (process.platform === 'win32') {
const execPath = getRelaunchExecPath()
const child = spawn(execPath, args, {
detached: true,
stdio: 'ignore',
windowsHide: true
})
child.unref()
app.exit(0)
return true
}
app.relaunch({ execPath: getRelaunchExecPath(), args })
app.exit(0)
return true
} catch (e) {
quittingForRestart = false
console.error('[Main] 无法重启应用:', e)
return false
}
}
/**
* 初始化所有业务服务并注册 IPC 监听
* @param win 主窗口实例
@@ -129,11 +336,15 @@ function initServices(win: BrowserWindow) {
ipcMain.handle('db:import', (_, data) => dbService?.importData(data))
// 注册扫描服务 IPC
ipcMain.handle('system:scan-local-games', async () => {
ipcMain.handle('system:scan-local-games', async (event) => {
try {
return await gameScannerService?.scanLocalGamesFromPlatforms() || []
return await gameScannerService?.scanLocalGamesFromPlatforms((status, details) => {
if (!event.sender.isDestroyed()) {
event.sender.send('system:scan-progress', { status, details })
}
}) || []
} catch (error) {
console.error('[GameScan] Failed to scan local games:', error)
console.error('[GameScan] 扫描本地游戏失败:', error)
return []
}
})
@@ -154,6 +365,60 @@ function initServices(win: BrowserWindow) {
ipcMain.handle('app:open-url', (_, url: string) => {
shell.openExternal(url)
})
ipcMain.handle('app:open-dir', async (_, dir: string) => {
const error = await shell.openPath(dir)
if (error) {
throw new Error(`Failed to open directory: ${error}`)
}
})
ipcMain.handle('app:restart', async () => {
if (VITE_DEV_SERVER_URL) {
console.info('[Main] 开发模式下跳过应用重启。')
return false
}
return relaunchAppSafely()
})
ipcMain.handle('app:reset', async () => {
try {
await systemService?.clearSystemProxy()
} catch (e) {
console.warn('[Main] 应用重置期间清理系统代理失败:', e)
}
try {
singboxService?.stop()
await new Promise((resolve) => setTimeout(resolve, 800))
} catch (e) {
console.warn('[Main] 应用重置期间停止 sing-box 失败:', e)
}
try {
await dbService?.close()
} catch (e) {
console.warn('[Main] 应用重置期间关闭数据库失败:', e)
}
try {
await session.defaultSession.clearStorageData()
await session.defaultSession.clearCache()
} catch (e) {
console.warn('[Main] 应用重置期间清理浏览器存储/缓存失败:', e)
}
try {
await clearUserDataDirectory()
} catch (e) {
console.warn('[Main] 应用重置期间立即清理用户数据失败:', e)
}
try {
await fs.outputFile(getResetMarkerPath(), new Date().toISOString(), 'utf8')
} catch (e) {
console.warn('[Main] 写入重置标记失败:', e)
}
return relaunchAppSafely()
})
// 注册系统对话框 IPC
ipcMain.handle('dialog:pick-image', async () => {
@@ -191,14 +456,14 @@ function initServices(win: BrowserWindow) {
function startApp() {
const appIcon = loadAppIcon()
const win = windowManager.create(appIcon)
// 重新创建托盘以绑定正确的窗口引用
trayManager.destroy()
// @ts-ignore: 我们知道这是在创建一个新的
const newTrayManager = new TrayManager(win)
newTrayManager.create(appIcon)
// 保持 trayManager 引用,防止被 GC
Object.assign(trayManager, newTrayManager)
Object.assign(trayManager, newTrayManager)
initServices(win)
}
@@ -221,7 +486,8 @@ app.on('activate', () => {
// 应用准备就绪
app.whenReady()
.then(() => {
.then(async () => {
await applyPendingResetIfNeeded()
setupRendererCsp()
startApp()
})
@@ -232,11 +498,16 @@ app.whenReady()
// 全局未捕获异常处理
process.on('unhandledRejection', (reason) => {
const message = String(reason)
console.error('[Main] Unhandled rejection:', message)
console.error('[Main] 未捕获的 Promise 拒绝:', message)
})
// 应用退出前清理
app.on('before-quit', (event) => {
if (quittingForRestart) {
trayManager.destroy()
return
}
trayManager.destroy()
if (quitCleanupStarted) return
@@ -246,7 +517,7 @@ app.on('before-quit', (event) => {
// 尝试清理系统代理设置
Promise.resolve(systemService?.clearSystemProxy())
.catch((e) => {
console.warn('[Main] Failed to clear system proxy on app quit:', e)
console.warn('[Main] 应用退出时清理系统代理失败:', e)
})
.finally(() => {
app.exit(0)
+13 -7
View File
@@ -190,7 +190,13 @@ function pushAppLog(entry: Omit<AppLogEntry, 'id' | 'timestamp'>, win?: BrowserW
}
appLogs.push(row)
if (appLogs.length > MAX_LOG_ENTRIES) appLogs.shift()
win?.webContents.send('app-log:new', row)
try {
if (win && !win.isDestroyed() && !win.webContents.isDestroyed()) {
win.webContents.send('app-log:new', row)
}
} catch {
// Ignore IPC emit failures during shutdown/restart.
}
appendLogToFile(row)
}
@@ -233,12 +239,12 @@ export function setupLogger(getWin: () => BrowserWindow | null) {
const text = truncateLogText(args.map(stringifyConsoleArg).join(' ').trim())
pushAppLog({
level,
category: inferLogCategory(text),
source: 'main',
message: text || '(empty)'
}, getWin())
}
category: inferLogCategory(text),
source: 'main',
message: text || '(空)'
}, getWin())
}
}
install('debug', 'debug')
install('info', 'info')
@@ -251,7 +257,7 @@ export function setupLogger(getWin: () => BrowserWindow | null) {
level: 'info',
category: 'backend',
source: 'logger',
message: `App log file: ${getAppLogFilePath()}`
message: `应用日志文件: ${getAppLogFilePath()}`
}, getWin())
ipcMain.handle('logs:get-all', () => appLogs)
+1 -1
View File
@@ -32,7 +32,7 @@ export class WindowManager {
path.join(__dirname, 'preload.mjs'),
]
const preloadPath = preloadCandidates.find(p => fs.existsSync(p)) || preloadCandidates[0]
console.info('[Main] preload selected:', preloadPath)
console.info('[Main] 已选择 preload 脚本:', preloadPath)
this.win = new BrowserWindow({
width: 1200,
+27 -3
View File
@@ -27,7 +27,7 @@ contextBridge.exposeInMainWorld('electron', {
pickProcess: () => ipcRenderer.invoke('dialog:pick-process'),
/** 选择并扫描文件夹中的可执行文件 */
pickProcessFolder: (maxDepth?: number) => ipcRenderer.invoke('dialog:pick-process-folder', maxDepth),
/**
* 注册 IPC 事件监听器
* @param channel 频道名称
@@ -40,7 +40,7 @@ contextBridge.exposeInMainWorld('electron', {
ipcListenerMap.set(channel, channelMap)
ipcRenderer.on(channel, wrapped)
},
/**
* 移除 IPC 事件监听器
* @param channel 频道名称
@@ -63,6 +63,10 @@ contextBridge.exposeInMainWorld('singbox', {
stop: () => ipcRenderer.invoke('singbox-stop'),
/** 重启内核 */
restart: (config: string) => ipcRenderer.invoke('singbox-restart', config),
/** 确保核心已安装 */
ensureCoreInstalled: () => ipcRenderer.invoke('singbox-ensure-core'),
/** 获取核心安装状态 */
getInstallInfo: () => ipcRenderer.invoke('singbox-get-install-info'),
})
// 系统底层功能
@@ -88,10 +92,26 @@ contextBridge.exposeInMainWorld('system', {
ipcRenderer.invoke('system:test-http-proxy-connect', proxyPort, targetHost, targetPort, timeoutMs),
/** 设置系统代理 */
setSystemProxy: (port: number, bypass?: string) => ipcRenderer.invoke('system:set-system-proxy', port, bypass),
/** 清除系统代理 */
/** 清楚系统代理 */
clearSystemProxy: (snapshot?: any) => ipcRenderer.invoke('system:clear-system-proxy', snapshot),
/** 获取当前系统代理状态 */
getSystemProxyState: () => ipcRenderer.invoke('system:get-system-proxy-state'),
/** 监听游戏扫描进度 */
onScanProgress: (callback: (data: { status: string, details?: string }) => void) => {
const wrapped = (_event: any, data: any) => callback(data)
const channelMap = ipcListenerMap.get('system:scan-progress') || new Map()
channelMap.set(callback, wrapped)
ipcListenerMap.set('system:scan-progress', channelMap)
ipcRenderer.on('system:scan-progress', wrapped)
},
/** 移除游戏扫描进度监听 */
offScanProgress: (callback: (data: { status: string, details?: string }) => void) => {
const channelMap = ipcListenerMap.get('system:scan-progress')
const wrapped = channelMap?.get(callback)
if (!wrapped) return
ipcRenderer.removeListener('system:scan-progress', wrapped)
channelMap?.delete(callback)
}
})
// 进程代理监控
@@ -130,6 +150,10 @@ contextBridge.exposeInMainWorld('app', {
getVersion: () => ipcRenderer.invoke('app:get-version'),
checkUpdate: () => ipcRenderer.invoke('app:check-update'),
openUrl: (url: string) => ipcRenderer.invoke('app:open-url', url),
/** 打开目录 */
openDir: (dir: string) => ipcRenderer.invoke('app:open-dir', dir),
restart: () => ipcRenderer.invoke('app:restart'),
reset: () => ipcRenderer.invoke('app:reset'),
})
// 日志系统
-1
View File
@@ -1 +0,0 @@
-64
View File
@@ -1,64 +0,0 @@
import fs from 'node:fs/promises'
import path from 'node:path'
const ROOT = process.cwd()
const DEV_BIN_DIR = path.join(ROOT, '.lagzero-dev', 'bin')
const TARGET_ROOT = path.join(ROOT, 'electron', 'resources', 'singbox')
const ARCH_ALIAS = {
x64: 'x64',
arm64: 'arm64',
ia32: 'x86'
}
function log(message) {
console.log(`[prepare-singbox-core] ${message}`)
}
async function ensureCleanDir(dir) {
await fs.rm(dir, { recursive: true, force: true })
await fs.mkdir(dir, { recursive: true })
}
async function pathExists(filePath) {
try {
await fs.access(filePath)
return true
} catch {
return false
}
}
async function copyDevCore() {
const exeName = process.platform === 'win32' ? 'sing-box.exe' : 'sing-box'
const sourceExe = path.join(DEV_BIN_DIR, exeName)
const hasDevCore = await pathExists(sourceExe)
const arch = ARCH_ALIAS[process.arch] || process.arch
const targetDir = path.join(TARGET_ROOT, `windows-${arch}`)
await fs.mkdir(TARGET_ROOT, { recursive: true })
if (!hasDevCore) {
log(`未找到 dev 核心: ${sourceExe}`)
log('保留现有内置资源不变;运行时仍可回退到在线下载。')
return
}
await ensureCleanDir(targetDir)
const files = await fs.readdir(DEV_BIN_DIR, { withFileTypes: true })
for (const entry of files) {
if (!entry.isFile()) continue
const lowerName = entry.name.toLowerCase()
if (lowerName === 'sing-box.exe' || lowerName.endsWith('.dll')) {
await fs.copyFile(path.join(DEV_BIN_DIR, entry.name), path.join(targetDir, entry.name))
}
}
log(`已内置 dev 核心到 ${targetDir}`)
}
copyDevCore().catch((error) => {
console.error('[prepare-singbox-core] 执行失败:', error)
process.exit(1)
})
+1 -1
View File
@@ -50,7 +50,7 @@ export class CategoryService {
try {
this.categoriesCache = await this.db.getAllCategories()
} catch (e) {
console.error('Failed to refresh categories cache:', e)
console.error('刷新分类缓存失败:', e)
}
}
+24 -5
View File
@@ -16,6 +16,7 @@ import { isIconUrl, normalizeNodeType, parseStringArray, safeJsonParse } from '.
export class DatabaseService {
private db: Kysely<Database>
private sqlite: DatabaseConstructor.Database
private isClosed = false
/** 缓存 "Other/其他" 分类的 ID,避免频繁查询 */
private otherCategoryId: string | null | undefined
private readonly platformCategoryNames = ['Steam', 'Microsoft', 'Epic', 'EA'] as const
@@ -292,7 +293,7 @@ export class DatabaseService {
node_id TEXT,
proxy_mode TEXT,
routing_rules TEXT,
chain_proxy INTEGER DEFAULT 0,
chain_proxy INTEGER DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
@@ -320,7 +321,7 @@ export class DatabaseService {
this.sqlite.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`)
}
} catch (e) {
console.error(`Failed to ensure column ${table}.${column}:`, e)
console.error(`确保字段 ${table}.${column} 失败:`, e)
}
}
@@ -580,7 +581,7 @@ export class DatabaseService {
node_id: game.nodeId || null,
proxy_mode: game.proxyMode || 'process',
routing_rules: game.routingRules ? JSON.stringify(game.routingRules) : null,
chain_proxy: game.chainProxy ? 1 : 0,
chain_proxy: game.chainProxy === false ? 0 : 1,
updated_at: new Date().toISOString()
}
@@ -647,7 +648,7 @@ export class DatabaseService {
async deleteCategory(id: string) {
const categories = await this.getAllCategories()
if (categories.length <= 1) {
throw new Error('At least one category must remain.')
throw new Error('至少需要保留一个分类。')
}
await this.db.deleteFrom('categories').where('id', '=', id).execute()
return this.getAllCategories()
@@ -780,7 +781,7 @@ export class DatabaseService {
node_id: game.nodeId || null,
proxy_mode: game.proxyMode || 'process',
routing_rules: game.routingRules ? JSON.stringify(game.routingRules) : null,
chain_proxy: game.chainProxy ? 1 : 0,
chain_proxy: game.chainProxy === false ? 0 : 1,
updated_at: new Date().toISOString()
}
await trx.insertInto('games').values(gameData).onConflict(oc => oc.column('id').doUpdateSet(gameData)).execute()
@@ -804,4 +805,22 @@ export class DatabaseService {
return true
}
/**
* 关闭数据库连接,供应用重置/退出时释放文件句柄。
*/
async close() {
if (this.isClosed) return
this.isClosed = true
try {
await this.db.destroy()
} catch (e) {
console.warn('销毁 Kysely 实例失败:', e)
}
try {
this.sqlite.close()
} catch (e) {
console.warn('关闭 SQLite 连接失败:', e)
}
}
}
+21 -33
View File
@@ -1,10 +1,14 @@
import fs from 'fs-extra'
import path from 'path'
import { LocalGameScanResult } from './scanners/types'
import { getWindowsDriveRoots } from './scanners/utils'
import { LocalGameScanResult, ScanProgressCallback } from './scanners/types'
import { mapWithConcurrency } from './scanners/utils'
import { scanSteamGames } from './scanners/steam'
import { scanMicrosoftGames } from './scanners/microsoft'
import { scanFlatPlatformFolder } from './scanners/flat'
import { scanEpicGames } from './scanners/epic'
import { scanEAGames } from './scanners/ea'
import { scanBattleNetGames } from './scanners/battlenet'
import { scanWeGameGames } from './scanners/wegame'
import { scanLocalShortcuts } from './scanners/local'
/**
* 游戏扫描服务
@@ -17,40 +21,24 @@ export class GameScannerService {
* 扫描所有支持平台的游戏
* @returns 扫描到的游戏列表 (去重后)
*/
async scanLocalGamesFromPlatforms(): Promise<LocalGameScanResult[]> {
async scanLocalGamesFromPlatforms(progressCallback?: ScanProgressCallback): 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'),
path.join(root, 'Games', 'Epic Games'),
path.join(root, 'Game', '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'),
path.join(root, 'Games', 'EA Games'),
path.join(root, 'Game', 'EA Games'),
path.join(root, 'Games', 'Electronic Arts')
])
)))
])
const scanners = [
scanSteamGames,
scanMicrosoftGames,
scanEpicGames,
scanEAGames,
scanBattleNetGames,
scanWeGameGames,
scanLocalShortcuts
]
const platformResults = await mapWithConcurrency(scanners, async (scan) => scan(progressCallback), 3)
const [steam, microsoft, epic, ea, battlenet, wegame, local] = platformResults
// 去重逻辑:名称和进程名列表都相同的视为同一个游戏
const dedup = new Map<string, LocalGameScanResult>()
for (const game of [...steam, ...microsoft, ...epic, ...ea]) {
for (const game of [...steam, ...microsoft, ...epic, ...ea, ...battlenet, ...wegame, ...local]) {
const processKey = Array.isArray(game.processName) ? game.processName.sort().join(',').toLowerCase() : ''
const key = `${game.name.toLowerCase()}|${processKey}`
if (!dedup.has(key)) dedup.set(key, game)
@@ -81,7 +69,7 @@ export class GameScannerService {
}
}
} catch (err) {
console.error(`Error scanning directory ${dir}:`, err)
console.error(`扫描目录 ${dir} 失败:`, err)
}
return results
}
+162 -38
View File
@@ -1,8 +1,9 @@
import { exec } from 'child_process'
import { exec, execFile } from 'child_process'
import { ipcMain } from 'electron'
import { promisify } from 'util'
const execAsync = promisify(exec)
const PROCESS_EXEC_MAX_BUFFER = 20 * 1024 * 1024
/**
* 进程节点结构
@@ -15,6 +16,137 @@ export interface ProcessNode {
children: ProcessNode[]
}
type FlatProcessNode = {
path: string
name: string
ppid: number
pid: number
}
function toInt(value: unknown): number {
const n = Number.parseInt(String(value ?? '').trim(), 10)
return Number.isFinite(n) ? n : 0
}
function unquoteCsvCell(value: string): string {
const raw = String(value || '').trim()
if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) {
return raw.slice(1, -1).replace(/""/g, '"')
}
return raw
}
/**
* 将扁平进程列表构建为树结构
*/
export function buildProcessTree(flatNodes: FlatProcessNode[]): ProcessNode[] {
const normalized: ProcessNode[] = flatNodes
.map((row) => ({
path: String(row.path || ''),
name: String(row.name || '').trim(),
ppid: toInt(row.ppid),
pid: toInt(row.pid),
children: []
}))
.filter((row) => row.pid > 0 && !!row.name)
const processMap = new Map<number, ProcessNode>()
for (const row of normalized) {
if (!processMap.has(row.pid)) {
processMap.set(row.pid, row)
}
}
const roots: ProcessNode[] = []
for (const row of processMap.values()) {
if (row.ppid > 0 && row.ppid !== row.pid && processMap.has(row.ppid)) {
processMap.get(row.ppid)!.children.push(row)
} else {
roots.push(row)
}
}
return roots
}
/**
* 解析 PowerShell/CIM 的 JSON 输出
*/
export function parsePowerShellProcessJson(stdout: string): ProcessNode[] {
const raw = String(stdout || '').trim()
if (!raw) return []
let parsed: any
try {
parsed = JSON.parse(raw)
} catch {
return []
}
const rows = Array.isArray(parsed) ? parsed : [parsed]
const flat: FlatProcessNode[] = rows.map((row: any) => ({
path: String(row?.ExecutablePath || ''),
name: String(row?.Name || ''),
ppid: toInt(row?.ParentProcessId),
pid: toInt(row?.ProcessId)
}))
return buildProcessTree(flat)
}
/**
* 解析 WMIC CSV 输出
* 支持 ExecutablePath 中包含逗号的情况。
*/
export function parseWmicProcessCsv(stdout: string): ProcessNode[] {
const lines = String(stdout || '')
.split(/\r?\n/g)
.map((line) => line.trim())
.filter(Boolean)
if (lines.length <= 1) return []
const rows = lines.slice(1)
const flat: FlatProcessNode[] = []
for (const line of rows) {
const rawParts = line.split(',')
if (rawParts.length < 5) continue
const parts = rawParts.map(unquoteCsvCell)
const pid = toInt(parts[parts.length - 1])
const ppid = toInt(parts[parts.length - 2])
const name = String(parts[parts.length - 3] || '').trim()
const exePath = parts.slice(1, parts.length - 3).join(',').trim()
if (!pid || !name) continue
flat.push({
path: exePath,
name,
ppid,
pid
})
}
return buildProcessTree(flat)
}
function execFileText(command: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
execFile(
command,
args,
{ windowsHide: true, maxBuffer: PROCESS_EXEC_MAX_BUFFER },
(error, stdout) => {
if (error) {
reject(error)
return
}
resolve(String(stdout || ''))
}
)
})
}
/**
* 进程管理服务
*
@@ -79,7 +211,7 @@ export class ProcessService {
})
}
} catch (error) {
console.error('Failed to scan processes:', error)
console.error('扫描进程失败:', error)
return []
}
}
@@ -87,50 +219,42 @@ export class ProcessService {
/**
* 获取系统进程树
*
* 仅支持 Windows 平台 (使用 wmic)。
* 仅支持 Windows 平台。
* 优先使用 PowerShell/CIM,在老系统或受限环境下回退到 WMIC。
* 返回包含 PID, PPID, Name, ExecutablePath 的树状结构或扁平列表。
*
* @returns Promise<any[]> - 进程树根节点列表
*/
async getProcessTree(): Promise<ProcessNode[]> {
try {
if (process.platform === 'win32') {
// Use wmic to get process tree info
const { stdout } = await execAsync('wmic process get ProcessId,ParentProcessId,Name,ExecutablePath /FORMAT:CSV')
const lines = stdout.trim().split('\r\n').slice(1) // Skip header
const processes = lines.map(line => {
const parts = line.split(',')
// Node,ExecutablePath,Name,ParentProcessId,ProcessId
// wmic CSV output might contain empty lines or different column order depending on locale?
// Actually wmic /FORMAT:CSV output order is fixed: Node, ...columns sorted alphabetically
// Columns: ExecutablePath, Name, ParentProcessId, ProcessId
if (parts.length < 5) return null
return {
path: parts[1],
name: parts[2],
ppid: parseInt(parts[3], 10),
pid: parseInt(parts[4], 10),
children: []
} as ProcessNode
}).filter((p): p is ProcessNode => !!p && !!p.pid)
if (process.platform !== 'win32') return []
// Build tree
const processMap = new Map<number, ProcessNode>()
processes.forEach(p => processMap.set(p.pid, p))
const rootProcesses: ProcessNode[] = []
processes.forEach(p => {
if (p.ppid && processMap.has(p.ppid)) {
processMap.get(p.ppid)!.children.push(p)
} else {
rootProcesses.push(p)
}
})
return rootProcesses
try {
const psScript = "$ErrorActionPreference='Stop'; Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,ExecutablePath | ConvertTo-Json -Compress"
const psOutput = await execFileText('powershell', [
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-Command',
psScript
])
const parsed = parsePowerShellProcessJson(psOutput)
if (parsed.length > 0) {
return parsed
}
return []
console.warn('[进程服务] PowerShell/CIM 进程树为空,尝试 WMIC 回退')
} catch (error) {
console.error('Failed to get process tree:', error)
console.warn('[进程服务] PowerShell/CIM 获取进程树失败,尝试 WMIC 回退:', error)
}
try {
const { stdout } = await execAsync(
'wmic process get ProcessId,ParentProcessId,Name,ExecutablePath /FORMAT:CSV',
{ maxBuffer: PROCESS_EXEC_MAX_BUFFER }
)
return parseWmicProcessCsv(String(stdout || ''))
} catch (error) {
console.error('获取进程树失败:', error)
return []
}
}
+30 -25
View File
@@ -3,6 +3,32 @@ import { ProcessService, ProcessNode } from './process';
import { SingBoxService } from './singbox';
import { normalizeProcessNames, normalizeProcessName } from '../utils/process-helper';
/**
* 从进程树中找出“已代理父进程”派生出的所有子进程名称
*/
export function findChainProxyChildren(nodes: ProcessNode[], monitoredProcessNames: Set<string>): string[] {
const found: string[] = []
const traverse = (node: ProcessNode, isProxiedParent: boolean) => {
const normalizedName = normalizeProcessName(node.name)
if (!normalizedName) return
const isMonitored = monitoredProcessNames.has(normalizedName)
const shouldProxy = isProxiedParent || isMonitored
if (shouldProxy && !isMonitored) {
found.push(normalizedName)
}
if (node.children) {
node.children.forEach((child) => traverse(child, shouldProxy))
}
}
nodes.forEach((node) => traverse(node, false))
return found
}
/**
* 代理监控服务
*
@@ -53,7 +79,7 @@ export class ProxyMonitorService {
this.monitoredProcessNames = new Set(normalizeProcessNames(processNames));
this.detectedChildProcesses.clear();
console.log(`[ProxyMonitor] Started monitoring for game ${gameId} with processes:`, [...this.monitoredProcessNames]);
console.log(`[代理监控] 开始监控游戏 ${gameId},进程:`, [...this.monitoredProcessNames]);
// Start polling
this.interval = setInterval(() => this.checkChainProxy(), 3000);
@@ -74,7 +100,7 @@ export class ProxyMonitorService {
this.monitoredProcessNames.clear();
this.detectedChildProcesses.clear();
this.mainWindow.webContents.send('proxy-monitor:status', { status: 'idle' });
console.log('[ProxyMonitor] Stopped monitoring');
console.log('[代理监控] 停止监控');
}
/**
@@ -102,32 +128,11 @@ export class ProxyMonitorService {
await this.singboxService.updateProcessNames([...this.monitoredProcessNames]);
}
} catch (error) {
console.error('Proxy monitor error:', error);
console.error('代理监控错误:', error);
}
}
private findNewChildren(nodes: ProcessNode[]): string[] {
const found: string[] = [];
// Recursive traversal to find children of monitored processes
const traverse = (node: ProcessNode, isProxiedParent: boolean) => {
const normalizedName = normalizeProcessName(node.name);
if (!normalizedName) return;
const isMonitored = this.monitoredProcessNames.has(normalizedName);
// If parent is proxied, child should be proxied (Chain Proxy)
const shouldProxy = isProxiedParent || isMonitored;
if (shouldProxy && !isMonitored) {
found.push(normalizedName);
}
if (node.children) {
node.children.forEach(child => traverse(child, shouldProxy));
}
};
nodes.forEach(node => traverse(node, false));
return found;
return findChainProxyChildren(nodes, this.monitoredProcessNames)
}
}
+40
View File
@@ -0,0 +1,40 @@
import path from 'path'
import { LocalGameScanResult, ScanProgressCallback } from './types'
import { getWindowsDriveRoots, normalizeFsPath } from './utils'
import { scanFlatPlatformFolder } from './flat'
/**
* 扫描战网 Battle.net 游戏
*/
export async function scanBattleNetGames(progressCallback?: ScanProgressCallback): Promise<LocalGameScanResult[]> {
progressCallback?.('scanning_platform', 'BattleNet')
const results: LocalGameScanResult[] = []
const dedup = new Map<string, LocalGameScanResult>()
const drives = await getWindowsDriveRoots()
const candidateRoots = Array.from(new Set(drives.flatMap(root => [
path.join(root, 'Program Files (x86)', 'Battle.net'),
path.join(root, 'Program Files', 'Battle.net'),
path.join(root, 'Program Files (x86)'),
path.join(root, 'Program Files'),
path.join(root, 'Games'),
path.join(root, 'Game')
])))
// 由于战网游戏通常直接放在 Program Files 或者 Games 下面(如 World of Warcraft, Overwatch)
// 我们可以通过寻找特定的包含 .build.info 的目录来精确打标,或者直接依赖 flatScan 后续配合名称匹配
// 这里直接使用 flatScan 寻找,因为 utils 里的 heuristics 能够挑选出 exe
// 但为了避免把大量非战网游戏标为 BattleNet,我们进行过滤
const flatResults = await scanFlatPlatformFolder('BattleNet', candidateRoots, progressCallback)
const battleNetGameNames = ['world of warcraft', 'overwatch', 'diablo', 'starcraft', 'hearthstone', 'heroes of the storm', 'call of duty']
for (const game of flatResults) {
const isBattleNetGame = battleNetGameNames.some(name => game.name.toLowerCase().includes(name))
if (isBattleNetGame && !dedup.has(normalizeFsPath(game.installDir))) {
dedup.set(normalizeFsPath(game.installDir), game)
}
}
results.push(...dedup.values())
return results
}
+167
View File
@@ -0,0 +1,167 @@
import path from 'path'
import { LocalGameScanResult, ScanProgressCallback } from './types'
import { getWindowsDriveRoots, normalizeFsPath } from './utils'
import { scanFlatPlatformFolder } from './flat'
import { runCommand } from '../../utils/command'
import fs from 'fs-extra'
import { normalizeDisplayName, pickRelatedExecutables } from './utils'
const EA_LAUNCHER_NAME_KEYWORDS = ['ea app', 'ea desktop', 'origin']
const EA_LAUNCHER_DIR_KEYWORDS = ['ea desktop', 'electronic arts\\ea desktop', '\\origin']
function isEALauncherEntry(name: string, installDir: string): boolean {
const lowerName = name.toLowerCase()
const lowerDir = installDir.toLowerCase()
return EA_LAUNCHER_NAME_KEYWORDS.some(k => lowerName.includes(k)) ||
EA_LAUNCHER_DIR_KEYWORDS.some(k => lowerDir.includes(k))
}
/**
* 获取 EA/Origin 游戏的注册表安装路径
*/
async function getEARegistryInstalls(): Promise<string[]> {
const dirs: string[] = []
if (process.platform !== 'win32') return dirs
try {
const script = `
$paths = @()
$keys = @(
"HKLM:\\SOFTWARE\\WOW6432Node\\Electronic Arts\\EA Games",
"HKLM:\\SOFTWARE\\Electronic Arts\\EA Games"
)
foreach ($key in $keys) {
if (Test-Path $key) {
$subkeys = Get-ChildItem -Path $key -ErrorAction SilentlyContinue
foreach ($subkey in $subkeys) {
$installDir = (Get-ItemProperty -Path $subkey.PSPath -Name "Install Dir" -ErrorAction SilentlyContinue)."Install Dir"
if ($installDir) {
$paths += $installDir
}
}
}
}
$paths | ConvertTo-Json -Compress
`
const { code, output } = await runCommand('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], 6000)
if (code === 0 && output) {
const text = output.trim()
if (text) {
const parsed = text.startsWith('[') ? JSON.parse(text) : [text.replace(/^"|"$/g, '')]
dirs.push(...parsed)
}
}
} catch (e) {
// ignore
}
return dirs.map(d => path.normalize(d.trim())).filter(d => Boolean(d))
}
/**
* 从卸载注册表读取 EA 游戏安装目录与名称
*/
async function getEAUninstallHints(): Promise<Array<{ name: string, installDir: string }>> {
if (process.platform !== 'win32') return []
try {
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 -and (($_.Publisher -match '(?i)Electronic\\s+Arts|\\bEA\\b') -or ($_.DisplayName -match '(?i)\\bEA\\b|\\bOrigin\\b|Electronic\\s+Arts')) } |",
" Select-Object DisplayName, InstallLocation",
"}",
"$items | ConvertTo-Json -Compress"
].join('; ')
const { code, output } = await runCommand('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], 8000)
if (code !== 0 || !output) return []
const text = output.trim()
const rows = text.startsWith('[') ? JSON.parse(text) : [JSON.parse(text)]
return rows
.map((row: any) => ({
name: String(row?.DisplayName || '').trim(),
installDir: path.normalize(String(row?.InstallLocation || '').trim())
}))
.filter((row: any) => row.name && row.installDir)
} catch {
return []
}
}
/**
* 扫描 EA Games
* 通过读取注册表以及扫描常见目录来发现 EA 游戏
*/
export async function scanEAGames(progressCallback?: ScanProgressCallback): Promise<LocalGameScanResult[]> {
progressCallback?.('scanning_platform', 'EA')
const results: LocalGameScanResult[] = []
const dedup = new Map<string, LocalGameScanResult>()
// 1. 通过注册表扫描
const regDirs = await getEARegistryInstalls()
for (const dir of regDirs) {
if (await fs.pathExists(dir)) {
const folderName = path.basename(dir)
if (isEALauncherEntry(folderName, dir)) continue
progressCallback?.('scanning_dir', dir)
const displayName = normalizeDisplayName(folderName)
const exes = await pickRelatedExecutables(dir, displayName, progressCallback)
if (exes && exes.length > 0) {
const game: LocalGameScanResult = {
name: displayName,
processName: exes.map(e => path.basename(e)),
source: 'EA',
installDir: dir
}
dedup.set(normalizeFsPath(dir), game)
}
}
}
// 1.1 通过卸载注册表补充(EA App 常见来源)
const uninstallHints = await getEAUninstallHints()
for (const hint of uninstallHints) {
if (isEALauncherEntry(hint.name, hint.installDir)) continue
if (!await fs.pathExists(hint.installDir)) continue
progressCallback?.('scanning_dir', hint.installDir)
const displayName = normalizeDisplayName(hint.name || path.basename(hint.installDir))
const exes = await pickRelatedExecutables(hint.installDir, displayName, progressCallback)
if (!exes || exes.length === 0) continue
const key = normalizeFsPath(hint.installDir)
if (!dedup.has(key)) {
dedup.set(key, {
name: displayName,
processName: exes.map(e => path.basename(e)),
source: 'EA',
installDir: hint.installDir
})
}
}
// 2. 兜底常见目录扫描
const drives = await getWindowsDriveRoots()
const candidateRoots = 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'),
path.join(root, 'Games', 'EA Games'),
path.join(root, 'Game', 'EA Games'),
path.join(root, 'Games', 'Electronic Arts')
])))
const flatResults = await scanFlatPlatformFolder('EA', candidateRoots, progressCallback)
for (const game of flatResults) {
if (isEALauncherEntry(game.name, game.installDir)) continue
const key = normalizeFsPath(game.installDir)
if (!dedup.has(key)) dedup.set(key, game)
}
results.push(...dedup.values())
return results
}
+70
View File
@@ -0,0 +1,70 @@
import fs from 'fs-extra'
import path from 'path'
import { LocalGameScanResult, ScanProgressCallback } from './types'
import { getWindowsDriveRoots, normalizeDisplayName, normalizeFsPath, pickRelatedExecutables, safeReadDir } from './utils'
import { scanFlatPlatformFolder } from './flat'
/**
* 扫描 Epic Games
* 优先读取 %ProgramData%/Epic/EpicGamesLauncher/Data/Manifests 下的配置
* 兜底扫描常见 Epic 游戏目录
*/
export async function scanEpicGames(progressCallback?: ScanProgressCallback): Promise<LocalGameScanResult[]> {
progressCallback?.('scanning_platform', 'Epic')
const results: LocalGameScanResult[] = []
const dedup = new Map<string, LocalGameScanResult>()
// 1. 从 Manifests 文件夹读取
const programData = process.env.PROGRAMDATA || 'C:\\ProgramData'
const manifestDir = path.join(programData, 'Epic', 'EpicGamesLauncher', 'Data', 'Manifests')
if (await fs.pathExists(manifestDir)) {
const files = await safeReadDir(manifestDir)
for (const file of files) {
if (file.isFile() && file.name.endsWith('.item')) {
try {
const content = await fs.readJSON(path.join(manifestDir, file.name))
if (content.InstallLocation && content.DisplayName) {
const installDir = path.normalize(content.InstallLocation)
if (await fs.pathExists(installDir)) {
const displayName = normalizeDisplayName(content.DisplayName)
progressCallback?.('scanning_dir', installDir)
const exes = await pickRelatedExecutables(installDir, displayName, progressCallback)
if (exes && exes.length > 0) {
const game: LocalGameScanResult = {
name: displayName,
processName: exes.map(e => path.basename(e)),
source: 'Epic',
installDir
}
dedup.set(normalizeFsPath(installDir), game)
}
}
}
} catch (e) {
// ignore parse error
}
}
}
}
// 2. 兜底扫描常见目录
const drives = await getWindowsDriveRoots()
const candidateRoots = 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'),
path.join(root, 'Games', 'Epic Games'),
path.join(root, 'Game', 'Epic Games')
])))
const flatResults = await scanFlatPlatformFolder('Epic', candidateRoots, progressCallback)
for (const game of flatResults) {
if (!dedup.has(normalizeFsPath(game.installDir))) {
dedup.set(normalizeFsPath(game.installDir), game)
}
}
results.push(...dedup.values())
return results
}
+25 -13
View File
@@ -1,7 +1,7 @@
import fs from 'fs-extra'
import path from 'path'
import { LocalGameScanResult, Platform } from './types'
import { normalizeDisplayName, pickRelatedExecutables, safeReadDir } from './utils'
import { LocalGameScanResult, Platform, ScanProgressCallback } from './types'
import { mapWithConcurrency, normalizeDisplayName, pickRelatedExecutables, safeReadDir } from './utils'
/**
* 扫描指定目录下的通用平台游戏 (Epic, EA, Microsoft Store)
@@ -9,27 +9,39 @@ import { normalizeDisplayName, pickRelatedExecutables, safeReadDir } from './uti
* 这种扫描方式主要依赖于目录结构和可执行文件的启发式匹配。
*
* @param source 游戏平台标识
* @param roots 需要扫描的根目录列表
* @param progressCallback 进度回调
* @returns 扫描到的游戏列表
*/
export async function scanFlatPlatformFolder(source: Platform, roots: string[]): Promise<LocalGameScanResult[]> {
export async function scanFlatPlatformFolder(
source: Platform,
roots: string[],
progressCallback?: ScanProgressCallback
): Promise<LocalGameScanResult[]> {
const results: LocalGameScanResult[] = []
for (const root of roots) {
if (!await fs.pathExists(root)) continue
const tasks = await mapWithConcurrency(roots, async (root) => {
if (!await fs.pathExists(root)) return []
progressCallback?.('scanning_dir', root)
const entries = await safeReadDir(root)
for (const entry of entries) {
if (!entry.isDirectory()) continue
// 目录中游戏项也并行扫描
const scannedGames = await mapWithConcurrency(entries, async (entry) => {
if (!entry.isDirectory()) return null
const installDir = path.join(root, entry.name)
const exes = await pickRelatedExecutables(installDir, entry.name)
if (!exes || exes.length === 0) continue
results.push({
const exes = await pickRelatedExecutables(installDir, entry.name, progressCallback)
if (!exes || exes.length === 0) return null
return {
name: normalizeDisplayName(entry.name),
processName: exes.map(e => path.basename(e)),
source,
installDir
})
}
}
}, 6)
return scannedGames.filter(g => g !== null) as LocalGameScanResult[]
}, 4)
for (const c of tasks) {
results.push(...c)
}
return results
+107
View File
@@ -0,0 +1,107 @@
import { shell } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import os from 'os'
import { LocalGameScanResult, ScanProgressCallback } from './types'
import { normalizeDisplayName, pickRelatedExecutables, shouldSkipExeByName } from './utils'
/**
* 读取本地快捷方式 (.lnk) 识别独立游戏
*/
export async function scanLocalShortcuts(progressCallback?: ScanProgressCallback): Promise<LocalGameScanResult[]> {
progressCallback?.('scanning_platform', 'Local')
if (process.platform !== 'win32') return []
const results: LocalGameScanResult[] = []
const dedup = new Set<string>()
const dirsToScan = [
path.join(os.homedir(), 'Desktop'),
path.join(process.env.PUBLIC || 'C:\\Users\\Public', 'Desktop'),
path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Microsoft', 'Windows', 'Start Menu', 'Programs'),
path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Microsoft', 'Windows', 'Start Menu', 'Programs')
]
const lnkFiles: string[] = []
// 递归收集 .lnk 文件 (Start Menu 有多层目录)
async function collectLnks(dir: string, depth = 0) {
if (depth > 3) return
if (!await fs.pathExists(dir)) return
try {
const items = await fs.readdir(dir, { withFileTypes: true })
const tasks = items.map(async (item) => {
const fullPath = path.join(dir, item.name)
if (item.isDirectory()) {
progressCallback?.('scanning_dir', fullPath)
await collectLnks(fullPath, depth + 1)
} else if (item.isFile() && item.name.toLowerCase().endsWith('.lnk')) {
lnkFiles.push(fullPath)
}
})
await Promise.all(tasks)
} catch { }
}
const collectTasks = dirsToScan.map(dir => collectLnks(dir))
await Promise.all(collectTasks)
// 并行解析 lnk
const parseTasks = lnkFiles.map(async (lnk) => {
try {
const shortcut = shell.readShortcutLink(lnk)
const target = shortcut.target
if (!target || !target.toLowerCase().endsWith('.exe')) return null
if (shouldSkipExeByName(path.basename(target))) return null
// 额外的:过滤掉知名非游戏软件 (如 Chrome, Edge, 办公软件等)
const lowerTarget = target.toLowerCase()
if (lowerTarget.includes('chrome.exe') ||
lowerTarget.includes('msedge.exe') ||
lowerTarget.includes('code.exe') ||
lowerTarget.includes('office') ||
lowerTarget.includes('system32')) {
return null
}
if (!await fs.pathExists(target)) return null
const installDir = path.dirname(target)
const exeKey = target.toLowerCase()
// 此处由于并非完全同步,使用 Set 进行去重可能有极小并发覆盖可能,但可以接受,都在最后过滤
if (dedup.has(exeKey)) return null
dedup.add(exeKey)
// 解析名字:使用快捷方式的名字,去掉后缀
let displayName = path.basename(lnk, '.lnk')
displayName = normalizeDisplayName(displayName)
// 使用 pickRelatedExecutables 获取更完整的主进程列表,但确保 target 本身被包含
let processNames = [path.basename(target)]
try {
const exes = await pickRelatedExecutables(installDir, displayName, progressCallback)
if (exes && exes.length > 0) {
processNames = Array.from(new Set([...processNames, ...exes.map(e => path.basename(e))]))
}
} catch { }
return {
name: displayName,
processName: processNames,
source: 'Local',
installDir
} as LocalGameScanResult
} catch (e) {
return null
}
})
const parsedResults = await Promise.all(parseTasks)
for (const res of parsedResults) {
if (res !== null) results.push(res)
}
return results
}
+8 -6
View File
@@ -1,6 +1,6 @@
import fs from 'fs-extra'
import path from 'path'
import { LocalGameScanResult } from './types'
import { LocalGameScanResult, ScanProgressCallback } from './types'
import { getWindowsDriveRoots, normalizeDisplayName, normalizeFsPath, safeReadDir, pickRelatedExecutables } from './utils'
import { runCommand } from '../../utils/command'
import { scanFlatPlatformFolder } from './flat'
@@ -124,7 +124,7 @@ async function getManifestDisplayNameHints(xboxRoots: string[]): Promise<Map<str
/**
* 扫描 UWP / 已安装的 AppxPackages
*/
async function scanAppxPackages(): Promise<LocalGameScanResult[]> {
async function scanAppxPackages(progressCallback?: ScanProgressCallback): Promise<LocalGameScanResult[]> {
if (process.platform !== 'win32') return []
const script = "Get-AppxPackage | Where-Object { $_.InstallLocation } | Select-Object Name, InstallLocation | ConvertTo-Json -Compress"
@@ -142,6 +142,7 @@ async function scanAppxPackages(): Promise<LocalGameScanResult[]> {
const manifestPath = path.join(location, 'AppxManifest.xml')
if (!await fs.pathExists(manifestPath)) continue
progressCallback?.('scanning_dir', location)
try {
const content = await fs.readFile(manifestPath, 'utf8')
@@ -153,7 +154,7 @@ async function scanAppxPackages(): Promise<LocalGameScanResult[]> {
if (isGame) {
const parsedName = parseDisplayNameFromManifest(content) || packageName
const exes = await pickRelatedExecutables(location, parsedName)
const exes = await pickRelatedExecutables(location, parsedName, progressCallback)
if (exes && exes.length > 0) {
results.push({
name: normalizeDisplayName(parsedName),
@@ -175,13 +176,14 @@ async function scanAppxPackages(): Promise<LocalGameScanResult[]> {
* 扫描 Microsoft Store / Xbox 游戏
* 结合目录扫描、注册表和 AppxManifest 信息来获取准确的游戏名称
*/
export async function scanMicrosoftGames(): Promise<LocalGameScanResult[]> {
export async function scanMicrosoftGames(progressCallback?: ScanProgressCallback): Promise<LocalGameScanResult[]> {
progressCallback?.('scanning_platform', 'Microsoft')
const drives = await getWindowsDriveRoots()
const roots = drives.map(root => path.join(root, 'XboxGames'))
const [base, appxBase] = await Promise.all([
scanFlatPlatformFolder('Microsoft', roots),
scanAppxPackages()
scanFlatPlatformFolder('Microsoft', roots, progressCallback),
scanAppxPackages(progressCallback)
])
// 去重 (相同的安装目录只保留一个)
+68 -25
View File
@@ -1,6 +1,6 @@
import fs from 'fs-extra'
import path from 'path'
import { LocalGameScanResult } from './types'
import { LocalGameScanResult, ScanProgressCallback } from './types'
import { getWindowsDriveRoots, normalizeDisplayName, normalizeFsPath, pickRelatedExecutables, safeReadDir } from './utils'
import { runCommand } from '../../utils/command'
@@ -23,6 +23,32 @@ async function readSteamLibraryRoots(): Promise<string[]> {
])
]))
// 主动嗅探宽泛的 steamapps 目录(搜索驱动器下的前两级目录)
// 专门对付脱离注册表的绿色版/遗留库
for (const root of drives) {
try {
const topLevelDirs = await safeReadDir(root)
const dirsToSearch = [root]
for (const d of topLevelDirs) {
if (d.isDirectory() && !['Windows', 'ProgramData', '$Recycle.Bin', 'System Volume Information'].includes(d.name)) {
dirsToSearch.push(path.join(root, d.name))
}
}
await Promise.all(dirsToSearch.map(async (searchDir) => {
try {
const steamAppsPath = path.join(searchDir, 'steamapps')
if (await fs.pathExists(steamAppsPath)) {
candidateSteamRoots.push(searchDir)
}
} catch { }
}))
} catch (e) {
// ignore root scan errors
}
}
// 尝试从注册表获取真实 Steam 安装目录
try {
const script = [
@@ -48,10 +74,10 @@ async function readSteamLibraryRoots(): Promise<string[]> {
const libs = new Set<string>()
for (const steamRoot of candidateSteamRoots) {
libs.add(path.normalize(steamRoot))
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/)
@@ -72,11 +98,11 @@ async function readSteamLibraryRoots(): Promise<string[]> {
}
}
} catch (e) {
console.warn('[GameScan] Failed to parse Steam libraryfolders.vdf:', e)
console.warn('[游戏扫描] 解析 Steam libraryfolders.vdf 失败:', e)
}
}
return Array.from(libs)
return Array.from(new Set(Array.from(libs).map(p => path.normalize(p).toLowerCase())))
}
function parseAcfValue(content: string, key: string): string {
@@ -85,38 +111,48 @@ function parseAcfValue(content: string, key: string): string {
return (match?.[1] || '').replace(/\\\\/g, '\\').trim()
}
async function scanSteamFromManifests(libRoot: string): Promise<LocalGameScanResult[]> {
async function scanSteamFromManifests(
libRoot: string,
progressCallback?: ScanProgressCallback
): Promise<LocalGameScanResult[]> {
const results: LocalGameScanResult[] = []
const steamAppsDir = path.join(libRoot, 'steamapps')
if (!await fs.pathExists(steamAppsDir)) return results
progressCallback?.('scanning_dir', steamAppsDir)
const entries = await safeReadDir(steamAppsDir)
const manifestFiles = entries.filter(entry => entry.isFile() && /^appmanifest_\d+\.acf$/i.test(entry.name))
for (const manifest of manifestFiles) {
const manifestTasks = manifestFiles.map(async (manifest) => {
const manifestPath = path.join(steamAppsDir, manifest.name)
try {
const content = await fs.readFile(manifestPath, 'utf8')
const appName = parseAcfValue(content, 'name')
const installFolder = parseAcfValue(content, 'installdir')
if (!installFolder) continue
if (!installFolder) return null
const installDir = path.join(steamAppsDir, 'common', installFolder)
if (!await fs.pathExists(installDir)) continue
if (!await fs.pathExists(installDir)) return null
const displayName = normalizeDisplayName(appName || installFolder)
const exes = await pickRelatedExecutables(installDir, displayName)
if (!exes || exes.length === 0) continue
const exes = await pickRelatedExecutables(installDir, displayName, progressCallback)
if (!exes || exes.length === 0) return null
results.push({
return {
name: displayName,
processName: exes.map(e => path.basename(e)),
source: 'Steam',
installDir
})
} as LocalGameScanResult
} catch {
// ignore malformed appmanifest
return null
}
})
const manifestResults = await Promise.all(manifestTasks)
for (const res of manifestResults) {
if (res !== null) results.push(res)
}
return results
@@ -126,13 +162,14 @@ async function scanSteamFromManifests(libRoot: string): Promise<LocalGameScanRes
* 扫描 Steam 游戏
* 优先使用 appmanifest 解析,兜底扫描 common 目录
*/
export async function scanSteamGames(): Promise<LocalGameScanResult[]> {
export async function scanSteamGames(progressCallback?: ScanProgressCallback): Promise<LocalGameScanResult[]> {
progressCallback?.('scanning_platform', 'Steam')
const roots = await readSteamLibraryRoots()
const results: LocalGameScanResult[] = []
const dedup = new Map<string, LocalGameScanResult>()
for (const libRoot of roots) {
const manifestResults = await scanSteamFromManifests(libRoot)
const manifestResults = await scanSteamFromManifests(libRoot, progressCallback)
for (const game of manifestResults) {
dedup.set(normalizeFsPath(game.installDir), game)
}
@@ -141,18 +178,24 @@ export async function scanSteamGames(): Promise<LocalGameScanResult[]> {
if (!await fs.pathExists(commonDir)) continue
const games = await safeReadDir(commonDir)
for (const entry of games) {
if (!entry.isDirectory()) continue
progressCallback?.('scanning_dir', commonDir)
const scanGamesTasks = games.map(async (entry) => {
if (!entry.isDirectory()) return null
const installDir = path.join(commonDir, entry.name)
if (dedup.has(normalizeFsPath(installDir))) continue
const exes = await pickRelatedExecutables(installDir, entry.name)
if (!exes || exes.length === 0) continue
dedup.set(normalizeFsPath(installDir), {
if (dedup.has(normalizeFsPath(installDir))) return null
const exes = await pickRelatedExecutables(installDir, entry.name, progressCallback)
if (!exes || exes.length === 0) return null
return {
name: normalizeDisplayName(entry.name),
processName: exes.map(e => path.basename(e)),
source: 'Steam',
installDir
})
installDir,
source: 'Steam' as const
}
})
const gamesResults = await Promise.all(scanGamesTasks)
for (const res of gamesResults) {
if (res !== null) dedup.set(normalizeFsPath(res.installDir), res)
}
}
+71 -22
View File
@@ -1,4 +1,9 @@
export type Platform = 'Steam' | 'Microsoft' | 'Epic' | 'EA'
export type Platform = 'Steam' | 'Microsoft' | 'Epic' | 'EA' | 'BattleNet' | 'WeGame' | 'Local' | 'GOG'
/**
* 扫描进度回调函数
*/
export type ScanProgressCallback = (status: string, details?: string) => void
/**
* 本地游戏扫描结果接口
@@ -18,38 +23,82 @@ export type LocalGameScanResult = {
* 扫描时需要忽略的常见非游戏目录名
*/
export const GAME_SCAN_IGNORE_DIR_NAMES = new Set([
'_commonredist',
'redist',
'redistributable',
'installer',
'installers',
'directx',
'vcredist',
'prereq',
'prerequisites',
'support',
'tools',
'launcher'
// --- 原有基础过滤 ---
'_commonredist', 'redist', 'redistributable', 'installer', 'installers',
'directx', 'vcredist', 'prereq', 'prerequisites', 'support', 'tools', 'launcher',
'windows', 'appdata', 'node_modules', 'programdata', '$recycle.bin', 'system volume information',
// --- 操作系统与开发环境残留 ---
'temp', 'tmp', 'perflogs', 'recovery', 'boot', 'efi', '.git', '.svn', '.vscode',
'__pycache__', '.cache', 'caches', 'github', 'windows nt', 'msbuild',
// --- 游戏引擎纯资源目录 (极其庞大,绝对不能进) ---
'content', 'assets', 'paks', 'pak', 'bundles', 'sound', 'sounds', 'audio', 'bgm',
'video', 'videos', 'movies', 'cinematics', 'art', 'textures', 'models', 'materials',
'shaders', 'fx', 'particles', 'animations', 'levels', 'maps', 'scenes',
// --- 日志、崩溃转储与调试 ---
'logs', 'log', 'crashlogs', 'crashreport', 'dump', 'dumps', 'profiling', 'debug',
// --- 常见扩展包与第三方库 ---
'plugins', 'modules', 'vendor', 'thirdparty', '3rdparty', 'extensions', 'addons',
// --- 其他无关内容 ---
'docs', 'documents', 'manual', 'manuals', 'extras', 'bonus', 'soundtrack', 'ost', 'wallpapers'
])
/**
* 扫描时需要忽略的常见非游戏主进程的可执行文件关键词
* 这里是“硬过滤”关键词:命中后会直接跳过。
*/
export const GAME_SCAN_IGNORE_EXE_KEYWORDS = [
'setup',
'unins',
'uninstall',
'installer',
'crashreport',
'updater',
'helper',
'bootstrap'
// 原有关键词
'setup', 'unins', 'uninstall', 'installer', 'crashreport',
'updater', 'bootstrap',
// 安装/更新/下载相关
'update', 'patcher', 'downloader', 'install', 'repair', 'extract',
// 崩溃收集与反馈
'sendreport', 'crash', 'dump', 'errorreporter',
// 配置工具 (通常不是我们需要加速的主进程)
'configurator', 'config', 'settings', 'options', 'autorun', 'autorunner',
// 服务端程序 (一般加速器只加速客户端进程)
'server', 'dedicated', 'headless',
// 第三方组件进程/内嵌浏览器
'cefprocess', 'cefsubsystem', 'webhelper', 'browser', 'overlay'
]
/**
* 软过滤关键词:
* - 不会直接排除文件
* - 只在评分阶段降权,避免误伤真实游戏相关进程
*/
export const GAME_SCAN_SOFT_IGNORE_EXE_KEYWORDS = [
'helper', 'subagent', 'reporter', 'bugreport', 'feedback', 'diagnostics', 'sysinfo'
]
/**
* 必须排除的特定可执行文件名
*/
export const GAME_SCAN_EXE_HARD_EXCLUDE = new Set([
// Unity 崩溃处理
'unitycrashhandler64.exe',
'unitycrashhandler32.exe'
'unitycrashhandler32.exe',
// 虚幻引擎默认组件
'unrealcefsubsystem.exe',
'minidump.exe',
'senddmp.exe',
'prerequisites.exe',
// 常见运行库安装包
'vcredist_x86.exe',
'vcredist_x64.exe',
'dxsetup.exe',
'vc_redist.x64.exe',
'vc_redist.x86.exe',
'dotnetfx.exe',
])
+119 -10
View File
@@ -1,6 +1,19 @@
import fs from 'fs-extra'
import path from 'path'
import { GAME_SCAN_IGNORE_DIR_NAMES, GAME_SCAN_IGNORE_EXE_KEYWORDS, GAME_SCAN_EXE_HARD_EXCLUDE } from './types'
import os from 'os'
import {
GAME_SCAN_IGNORE_DIR_NAMES,
GAME_SCAN_IGNORE_EXE_KEYWORDS,
GAME_SCAN_SOFT_IGNORE_EXE_KEYWORDS,
GAME_SCAN_EXE_HARD_EXCLUDE,
ScanProgressCallback
} from './types'
// 调整 libuv 的底层线程池大小,以最大化利用多核 CPU 处理海量并发文件的 I/O
process.env.UV_THREADPOOL_SIZE = String(Math.max(4, os.cpus().length * 2))
const DEFAULT_IO_CONCURRENCY = Math.max(4, Math.min(16, os.cpus().length))
const PICK_EXE_DEPTH_STAGES = [2, 4, 6]
const PICK_EXE_STAT_SAMPLE_LIMIT = 40
/**
* 标准化游戏显示名称
@@ -33,6 +46,33 @@ export async function safeReadDir(dir: string) {
}
}
/**
* 带并发限制的 map,避免一次性创建大量 I/O 任务导致磁盘抖动。
*/
export async function mapWithConcurrency<T, R>(
items: T[],
worker: (item: T, index: number) => Promise<R>,
concurrency: number = DEFAULT_IO_CONCURRENCY
): Promise<R[]> {
if (items.length === 0) return []
const limit = Math.max(1, Math.floor(concurrency))
const results = new Array<R>(items.length)
let nextIndex = 0
const run = async () => {
while (true) {
const idx = nextIndex
if (idx >= items.length) return
nextIndex += 1
results[idx] = await worker(items[idx], idx)
}
}
const workers = Array.from({ length: Math.min(limit, items.length) }, () => run())
await Promise.all(workers)
return results
}
/**
* 获取 Windows 系统下的所有驱动器根目录 (C:\, D:\, ...)
*/
@@ -64,22 +104,39 @@ export function shouldSkipExeByName(exeName: string) {
return GAME_SCAN_IGNORE_EXE_KEYWORDS.some(k => lower.includes(k))
}
function hasSoftIgnoreKeyword(exeName: string) {
const lower = String(exeName || '').toLowerCase()
return GAME_SCAN_SOFT_IGNORE_EXE_KEYWORDS.some((k) => lower.includes(k))
}
/**
* 递归收集目录下的所有 .exe 文件路径
* @param maxDepth 最大递归深度 (-1 表示无限)
* @param progressCallback 进度回调
*/
export async function collectExePaths(dir: string, maxDepth: number, currentDepth: number = 1): Promise<string[]> {
export async function collectExePaths(
dir: string,
maxDepth: number,
currentDepth: number = 1,
progressCallback?: ScanProgressCallback
): Promise<string[]> {
const results: string[] = []
const dirents = await safeReadDir(dir)
// 性能优化:单层目录文件异常多时直接跳过,防止卡死
if (dirents.length > 3000) {
return results
}
const subDirs: string[] = []
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)
subDirs.push(full)
}
continue
}
@@ -89,6 +146,17 @@ export async function collectExePaths(dir: string, maxDepth: number, currentDept
results.push(full)
}
// 多线程/并发处理所有合法的子目录
if (subDirs.length > 0) {
const subResults = await mapWithConcurrency(subDirs, async (subDir) => {
progressCallback?.('scanning_dir', subDir)
return await collectExePaths(subDir, maxDepth, currentDepth + 1, progressCallback)
}, DEFAULT_IO_CONCURRENCY)
for (const sub of subResults) {
results.push(...sub)
}
}
return results
}
@@ -100,15 +168,17 @@ export async function collectExePaths(dir: string, maxDepth: number, currentDept
* - 文件名包含文件夹名 (+6)
* - 文件夹名包含文件名 (+4)
* - 不包含 launcher 关键词 (+2)
* - 包含 shipping, win64, binaries 等关键词 (+4) (针对 UE 等引擎)
* - 文件体积越大分数越高 (每 40MB +1, 上限 +8)
*/
export async function pickRelatedExecutables(gameDir: string, displayName: string): Promise<string[]> {
const exes = await collectExePaths(gameDir, 3)
if (exes.length === 0) return []
if (exes.length === 1) return [exes[0]]
export async function pickRelatedExecutables(
gameDir: string,
displayName: string,
progressCallback?: ScanProgressCallback
): Promise<string[]> {
const target = normalizeDisplayName(displayName).toLowerCase()
const ranked = await Promise.all(exes.map(async (exe) => {
const quickScore = (exe: string) => {
let score = 0
const base = basenameLower(exe).replace(/\.exe$/, '')
const full = exe.toLowerCase()
@@ -118,6 +188,45 @@ export async function pickRelatedExecutables(gameDir: string, displayName: strin
if (target.includes(base)) score += 4
if (!full.includes('launcher')) score += 2
// 针对虚幻引擎等常见的深层目录特征给予加分
if (full.includes('shipping') || full.includes('win64') || full.includes('binaries')) {
score += 4
}
// 常见辅助/上报进程做降权,但不直接丢弃,避免误伤真实联动进程。
if (hasSoftIgnoreKeyword(base)) {
score -= 3
}
return score
}
const allExes: string[] = []
const seen = new Set<string>()
for (const depth of PICK_EXE_DEPTH_STAGES) {
const current = await collectExePaths(gameDir, depth, 1, progressCallback)
for (const exe of current) {
if (seen.has(exe)) continue
seen.add(exe)
allExes.push(exe)
}
if (allExes.length === 0) continue
if (allExes.length >= 8) break
if (allExes.some(exe => quickScore(exe) >= 10)) break
}
if (allExes.length === 0) return []
if (allExes.length === 1) return [allExes[0]]
const quickRanked = allExes
.map(exe => ({ exe, score: quickScore(exe) }))
.sort((a, b) => b.score - a.score)
const candidates = quickRanked
.slice(0, PICK_EXE_STAT_SAMPLE_LIMIT)
.map(v => v.exe)
const ranked = await Promise.all(candidates.map(async (exe) => {
let score = quickScore(exe)
try {
const stat = await fs.stat(exe)
score += Math.min(8, Math.floor((stat.size || 0) / (40 * 1024 * 1024)))
+81
View File
@@ -0,0 +1,81 @@
import path from 'path'
import { LocalGameScanResult, ScanProgressCallback } from './types'
import fs from 'fs-extra'
import { getWindowsDriveRoots, mapWithConcurrency, normalizeDisplayName, normalizeFsPath, pickRelatedExecutables, safeReadDir } from './utils'
const WEGAME_CONTAINER_DIRS = new Set(['rail_apps', 'common_apps', 'apps', 'games'])
const WEGAME_NON_GAME_DIRS = new Set(['rail_apps', 'common_apps', 'app', 'apps', 'cache', 'logs', 'temp', 'patch'])
/**
* 扫描腾讯 WeGame 游戏
*/
export async function scanWeGameGames(progressCallback?: ScanProgressCallback): Promise<LocalGameScanResult[]> {
progressCallback?.('scanning_platform', 'WeGame')
const results: LocalGameScanResult[] = []
const dedup = new Map<string, LocalGameScanResult>()
const drives = await getWindowsDriveRoots()
const candidateRoots = Array.from(new Set(drives.flatMap(root => [
path.join(root, 'WeGameApps'),
path.join(root, 'WeGameApp'),
path.join(root, 'Tencent', 'WeGame', 'apps'),
path.join(root, 'Program Files', 'Tencent', 'WeGame', 'apps'),
path.join(root, 'Program Files (x86)', 'Tencent', 'WeGame', 'apps'),
path.join(root, 'Program Files', 'WeGameApps'),
path.join(root, 'Program Files (x86)', 'WeGameApps')
])))
const gamesFromRoots = await mapWithConcurrency(candidateRoots, async (root) => {
if (!await fs.pathExists(root)) return []
progressCallback?.('scanning_dir', root)
const entries = await safeReadDir(root)
const candidates: string[] = []
for (const entry of entries) {
if (!entry.isDirectory()) continue
const entryDir = path.join(root, entry.name)
const entryLower = entry.name.toLowerCase()
if (WEGAME_CONTAINER_DIRS.has(entryLower)) {
progressCallback?.('scanning_dir', entryDir)
const nested = await safeReadDir(entryDir)
for (const sub of nested) {
if (!sub.isDirectory()) continue
const subLower = sub.name.toLowerCase()
if (WEGAME_NON_GAME_DIRS.has(subLower)) continue
candidates.push(path.join(entryDir, sub.name))
}
continue
}
if (!WEGAME_NON_GAME_DIRS.has(entryLower)) {
candidates.push(entryDir)
}
}
const scanned = await mapWithConcurrency(candidates, async (installDir) => {
const dirName = path.basename(installDir)
if (WEGAME_NON_GAME_DIRS.has(dirName.toLowerCase())) return null
const exes = await pickRelatedExecutables(installDir, dirName, progressCallback)
if (!exes || exes.length === 0) return null
return {
name: normalizeDisplayName(dirName),
processName: exes.map(e => path.basename(e)),
source: 'WeGame' as const,
installDir
} as LocalGameScanResult
}, 6)
return scanned.filter(v => v !== null) as LocalGameScanResult[]
}, 4)
for (const batch of gamesFromRoots) {
for (const game of batch) {
const key = normalizeFsPath(game.installDir)
if (!dedup.has(key)) dedup.set(key, game)
}
}
results.push(...dedup.values())
return results
}
+55 -11
View File
@@ -20,6 +20,7 @@ export class SingBoxService {
private mainWindow: BrowserWindow | null = null
private retryCount = 0
private maxRetries = 3
private retryTimer: NodeJS.Timeout | null = null
private lastLogs: string[] = []
private isStopping = false
private suppressNextStoppedStatus = false
@@ -31,7 +32,9 @@ export class SingBoxService {
constructor(window: BrowserWindow) {
this.mainWindow = window
const logFn = (msg: string, type: 'info' | 'error' = 'info') => this.log(msg, type)
this.installer = new SingBoxInstaller(logFn)
this.installer = new SingBoxInstaller(logFn, (status) => {
this.safeSend('singbox-installer-status', status)
})
this.configManager = new SingBoxConfigManager(logFn)
this.setupIPC()
}
@@ -53,6 +56,17 @@ export class SingBoxService {
await fs.writeFile(configPath, configContent)
await this.start(configPath)
})
ipcMain.handle('singbox-ensure-core', async () => {
await this.installer.checkAndDownloadBinary()
})
ipcMain.handle('singbox-get-install-info', async () => {
const exists = await this.installer.checkBinaryExists()
return {
exists,
installDir: this.installer.getInstallDir(),
binaryPath: this.installer.getBinaryPath()
}
})
}
/**
@@ -61,17 +75,26 @@ export class SingBoxService {
*/
async start(configPath: string) {
if (this.process) {
this.log('Process already running', 'info')
this.log('进程已在运行', 'info')
return
}
this.clearRetryTimer()
this.isStopping = false
const configExists = await fs.pathExists(configPath)
if (!configExists) {
const msg = `sing-box 配置文件不存在:${configPath}`
this.log(msg, 'error')
this.safeSend('singbox-error', msg)
throw new Error(msg)
}
const binPath = await this.installer.checkAndDownloadBinary()
try {
await this.configManager.validateConfig(binPath, configPath)
} catch (e: any) {
this.mainWindow?.webContents.send('singbox-error', e.message)
this.safeSend('singbox-error', e.message)
throw e
}
@@ -114,7 +137,7 @@ export class SingBoxService {
clearTimeout(startupTimer)
const msg = `sing-box 启动失败:${String(err?.message || err)}`
this.log(msg, 'error')
this.mainWindow?.webContents.send('singbox-error', msg)
this.safeSend('singbox-error', msg)
settle(() => reject(new Error(msg)))
})
@@ -125,14 +148,14 @@ export class SingBoxService {
const last = this.lastLogs.at(-1)
const msg = `sing-box 启动后立即退出(code=${String(code)})${last ? `:${last}` : ''}${detail ? `\n\n${detail}` : ''}`
this.log(msg, 'error')
this.mainWindow?.webContents.send('singbox-error', msg)
this.safeSend('singbox-error', msg)
settle(() => reject(new Error(msg)))
}
})
})
this.process.on('close', (code) => {
this.log(`sing-box exited with code ${code}`)
this.log(`sing-box 退出,代码 ${code}`)
this.process = null
if (this.suppressNextStoppedStatus) {
this.suppressNextStoppedStatus = false
@@ -143,14 +166,18 @@ export class SingBoxService {
if (this.isStopping) return
if (code !== 0 && this.retryCount < this.maxRetries) {
this.retryCount++
this.log(`Retrying start (${this.retryCount}/${this.maxRetries})...`)
setTimeout(() => this.start(configPath), 2000)
this.log(`正在重试启动 (${this.retryCount}/${this.maxRetries})...`)
this.retryTimer = setTimeout(() => {
void this.start(configPath).catch((err) => {
this.log(`自动重试启动失败:${String((err as any)?.message || err)}`, 'error')
})
}, 2000)
} else if (code !== 0) {
const detail = this.configManager.buildHelpfulErrorDetail(this.lastLogs)
const last = this.lastLogs.at(-1)
const msg = `sing-box 启动失败(已重试 ${this.maxRetries} 次)${last ? `:${last}` : ''}${detail ? `\n\n${detail}` : ''}`
this.log(msg, 'error')
this.mainWindow?.webContents.send('singbox-error', msg)
this.safeSend('singbox-error', msg)
}
})
@@ -163,6 +190,7 @@ export class SingBoxService {
* 停止 sing-box 内核
*/
stop() {
this.clearRetryTimer()
if (this.process) {
this.isStopping = true
this.retryCount = 0
@@ -183,11 +211,11 @@ export class SingBoxService {
private log(message: string, type: 'info' | 'error' = 'info') {
console.log(`[SingBox] ${message}`)
this.mainWindow?.webContents.send('singbox-log', { message, type, timestamp: Date.now() })
this.safeSend('singbox-log', { message, type, timestamp: Date.now() })
}
private sendStatus(status: 'running' | 'stopped') {
this.mainWindow?.webContents.send('singbox-status', status)
this.safeSend('singbox-status', status)
}
/**
@@ -237,4 +265,20 @@ export class SingBoxService {
})
})
}
private clearRetryTimer() {
if (!this.retryTimer) return
clearTimeout(this.retryTimer)
this.retryTimer = null
}
private safeSend(channel: string, payload: any) {
try {
const win = this.mainWindow
if (!win || win.isDestroyed() || win.webContents.isDestroyed()) return
win.webContents.send(channel, payload)
} catch {
// Ignore renderer IPC errors during app shutdown/restart.
}
}
}
+267 -102
View File
@@ -13,6 +13,30 @@ import { BIN_DIR } from './constants'
* 日志回调函数类型
*/
export type LogFn = (message: string, type?: 'info' | 'error') => void
export type InstallerPhase =
| 'checking'
| 'ready'
| 'missing'
| 'resolving'
| 'downloading'
| 'extracting'
| 'completed'
| 'failed'
export interface InstallerStatus {
phase: InstallerPhase
installDir: string
binaryPath: string
version?: string
downloadUrl?: string
progress?: number
downloadedBytes?: number
totalBytes?: number
error?: string
isTimeout?: boolean
}
export type StatusFn = (status: InstallerStatus) => void
/**
* Sing-box 安装器
@@ -20,9 +44,27 @@ export type LogFn = (message: string, type?: 'info' | 'error') => void
*/
export class SingBoxInstaller {
private log: LogFn
private onStatus?: StatusFn
private installingPromise: Promise<string> | null = null
constructor(logFn: LogFn) {
constructor(logFn: LogFn, statusFn?: StatusFn) {
this.log = logFn
this.onStatus = statusFn
}
getInstallDir() {
return BIN_DIR
}
getBinaryPath() {
const ext = process.platform === 'win32' ? '.exe' : ''
return path.join(BIN_DIR, `sing-box${ext}`)
}
async checkBinaryExists() {
const binPath = this.getBinaryPath()
if (await fs.pathExists(binPath)) return true
return this.tryAdoptManualBinary(binPath)
}
/**
@@ -30,21 +72,32 @@ export class SingBoxInstaller {
* @returns 可执行文件的绝对路径
*/
async checkAndDownloadBinary(): Promise<string> {
if (this.installingPromise) {
this.log('检测到已有 sing-box 安装任务,等待完成...')
return this.installingPromise
}
this.installingPromise = this.checkAndDownloadBinaryInternal()
try {
return await this.installingPromise
} finally {
this.installingPromise = null
}
}
private async checkAndDownloadBinaryInternal(): Promise<string> {
await fs.ensureDir(BIN_DIR)
const platform = process.platform
const arch = process.arch
const ext = platform === 'win32' ? '.exe' : ''
const binPath = path.join(BIN_DIR, `sing-box${ext}`)
const binPath = this.getBinaryPath()
this.emitStatus({ phase: 'checking' })
if (await fs.pathExists(binPath)) {
return binPath
}
const installedFromBundle = await this.tryInstallBundledBinary(platform, arch, binPath)
if (installedFromBundle) {
if (await this.checkBinaryExists()) {
this.emitStatus({ phase: 'ready' })
return binPath
}
this.emitStatus({ phase: 'missing' })
return this.downloadAndInstallBinary(platform, arch, binPath)
}
@@ -53,15 +106,44 @@ export class SingBoxInstaller {
*/
private async downloadAndInstallBinary(platform: NodeJS.Platform, arch: string, binPath: string): Promise<string> {
this.log('未检测到 sing-box,开始自动下载...')
const { version, downloadUrl, archiveExt } = await this.resolveLatestReleaseDownload(platform, arch)
const archivePath = path.join(BIN_DIR, `sing-box-${version}${archiveExt}`)
const extractDir = path.join(BIN_DIR, `tmp-${generateId()}`)
this.emitStatus({ phase: 'resolving' })
let version: string | undefined
let downloadUrl: string | undefined
let archivePath = ''
let extractDir = ''
try {
const resolved = await this.resolveLatestReleaseDownload(platform, arch)
version = resolved.version
downloadUrl = resolved.downloadUrl
const archiveExt = resolved.archiveExt
this.emitStatus({
phase: 'downloading',
version,
downloadUrl,
progress: 0,
downloadedBytes: 0
})
archivePath = path.join(BIN_DIR, `sing-box-${version}${archiveExt}`)
extractDir = path.join(BIN_DIR, `tmp-${generateId()}`)
await fs.ensureDir(extractDir)
await this.downloadToFile(downloadUrl, archivePath)
await this.downloadToFile(downloadUrl, archivePath, (progress, downloadedBytes, totalBytes) => {
this.emitStatus({
phase: 'downloading',
version,
downloadUrl,
progress,
downloadedBytes,
totalBytes
})
})
this.emitStatus({
phase: 'extracting',
version,
downloadUrl,
progress: 100
})
await this.extractAndInstall(archivePath, archiveExt, extractDir, binPath, platform === 'win32')
if (!await fs.pathExists(binPath)) {
@@ -69,14 +151,31 @@ export class SingBoxInstaller {
}
this.log(`sing-box 已安装:${binPath}`)
this.emitStatus({
phase: 'completed',
version,
downloadUrl,
progress: 100
})
return binPath
} catch (e: any) {
const msg = `sing-box 自动下载/安装失败:${String(e?.message || e)}`
this.log(msg, 'error')
this.emitStatus({
phase: 'failed',
version,
downloadUrl,
error: msg,
isTimeout: /timeout|超时/i.test(msg)
})
throw new Error(msg)
} finally {
await fs.remove(extractDir).catch(() => {})
await fs.remove(archivePath).catch(() => {})
if (extractDir) {
await fs.remove(extractDir).catch(() => {})
}
if (archivePath) {
await fs.remove(archivePath).catch(() => {})
}
}
}
@@ -125,45 +224,73 @@ export class SingBoxInstaller {
* 简单的 HTTP GET JSON 请求封装
*/
private fetchJson<T>(url: string): Promise<T> {
return new Promise((resolve, reject) => {
const req = https.request(url, {
method: 'GET',
headers: {
'User-Agent': 'LagZero',
'Accept': 'application/vnd.github+json'
}
}, (res) => {
const status = res.statusCode || 0
const chunks: Buffer[] = []
return this.withRetry(
async () => new Promise<T>((resolve, reject) => {
const req = https.request(url, {
method: 'GET',
headers: {
'User-Agent': 'LagZero',
'Accept': 'application/vnd.github+json'
}
}, (res) => {
const status = res.statusCode || 0
const chunks: Buffer[] = []
res.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)))
res.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8')
if (status >= 300) {
reject(new Error(`请求失败(${status}):${body.slice(0, 200)}`))
return
}
try {
resolve(JSON.parse(body) as T)
} catch (e) {
reject(new Error('解析 JSON 失败'))
}
res.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)))
res.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8')
if (status >= 300) {
reject(new Error(`请求失败(${status}):${body.slice(0, 200)}`))
return
}
try {
resolve(JSON.parse(body) as T)
} catch {
reject(new Error('解析 JSON 失败'))
}
})
})
})
req.on('error', reject)
req.end()
})
req.setTimeout(20000, () => {
req.destroy(new Error('请求超时'))
})
req.on('error', reject)
req.end()
}),
3,
'获取 sing-box release 信息'
)
}
/**
* 下载文件到本地
*/
private async downloadToFile(url: string, destPath: string): Promise<void> {
private async downloadToFile(
url: string,
destPath: string,
onProgress?: (progress: number | undefined, downloadedBytes: number, totalBytes?: number) => void
): Promise<void> {
await fs.ensureDir(path.dirname(destPath))
await this.withRetry(
async (attempt) => {
if (attempt > 1) {
await fs.remove(destPath).catch(() => {})
}
await this.doDownloadRequest(url, destPath, 5, onProgress)
},
3,
'下载 sing-box'
)
}
const doRequest = (u: string, redirectLeft: number): Promise<void> => new Promise((resolve, reject) => {
const req = https.request(u, {
private async doDownloadRequest(
url: string,
destPath: string,
redirectLeft: number,
onProgress?: (progress: number | undefined, downloadedBytes: number, totalBytes?: number) => void
): Promise<void> {
await new Promise<void>((resolve, reject) => {
const req = https.request(url, {
method: 'GET',
headers: { 'User-Agent': 'LagZero' }
}, (res) => {
@@ -171,7 +298,8 @@ export class SingBoxInstaller {
const location = res.headers.location
if ([301, 302, 303, 307, 308].includes(status) && location && redirectLeft > 0) {
res.resume()
resolve(doRequest(location, redirectLeft - 1))
const nextUrl = new URL(location, url).toString()
resolve(this.doDownloadRequest(nextUrl, destPath, redirectLeft - 1, onProgress))
return
}
if (status >= 300) {
@@ -183,30 +311,81 @@ export class SingBoxInstaller {
const total = Number(res.headers['content-length'] || 0)
let received = 0
let lastLoggedBucket = -1
let lastProgress = -1
const fileStream = createWriteStream(destPath)
res.on('data', (chunk) => {
received += Buffer.byteLength(chunk)
if (total > 0) {
const pct = Math.floor((received / total) * 100)
if (pct !== lastProgress) {
lastProgress = pct
onProgress?.(pct, received, total)
}
const bucket = Math.floor(pct / 20) * 20
if (bucket !== lastLoggedBucket && bucket <= 100) {
lastLoggedBucket = bucket
this.log(`正在下载 sing-box:${bucket}%`)
}
} else {
onProgress?.(undefined, received)
}
})
pipeline(res as unknown as Readable, fileStream)
.then(() => resolve())
.catch(reject)
.then(() => {
if (total > 0) {
onProgress?.(100, received, total)
}
resolve()
})
.catch((err) => reject(err))
})
req.setTimeout(30000, () => {
req.destroy(new Error('下载超时'))
})
req.on('error', reject)
req.end()
})
}
await doRequest(url, 5)
private async withRetry<T>(
task: (attempt: number) => Promise<T>,
maxAttempts: number,
taskName: string
): Promise<T> {
let lastError: unknown = null
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await task(attempt)
} catch (error) {
lastError = error
const shouldRetry = this.isRetryableNetworkError(error) && attempt < maxAttempts
if (!shouldRetry) {
throw error
}
const delayMs = attempt * 1000
this.log(`${taskName} 失败,${delayMs}ms 后重试(${attempt}/${maxAttempts - 1}):${String((error as any)?.message || error)}`, 'error')
await new Promise((r) => setTimeout(r, delayMs))
}
}
throw lastError instanceof Error ? lastError : new Error(String(lastError))
}
private isRetryableNetworkError(error: unknown): boolean {
const err = error as NodeJS.ErrnoException | undefined
const code = String(err?.code || '')
if (['ECONNRESET', 'ETIMEDOUT', 'ECONNABORTED', 'EAI_AGAIN', 'ENOTFOUND', 'EPIPE'].includes(code)) {
return true
}
const message = String(err?.message || error || '').toLowerCase()
return (
message.includes('socket hang up') ||
message.includes('timeout') ||
message.includes('network') ||
message.includes('econnreset')
)
}
/**
@@ -253,62 +432,16 @@ export class SingBoxInstaller {
await fs.chmod(binPath, 0o755).catch(() => {})
}
/**
* 尝试从安装包内置资源复制 sing-box
*/
private async tryInstallBundledBinary(
platform: NodeJS.Platform,
arch: string,
binPath: string
): Promise<boolean> {
const platformName = platform === 'win32' ? 'windows'
: platform === 'darwin' ? 'darwin'
: platform === 'linux' ? 'linux'
: null
if (!platformName) return false
const archName = arch === 'x64' ? 'x64'
: arch === 'arm64' ? 'arm64'
: arch === 'ia32' ? 'x86'
: arch
const exeName = platform === 'win32' ? 'sing-box.exe' : 'sing-box'
const sourceDirCandidates = [
path.join(process.resourcesPath, 'singbox', `${platformName}-${archName}`),
path.join(process.resourcesPath, 'singbox', platformName),
path.join(process.resourcesPath, 'singbox'),
]
for (const sourceDir of sourceDirCandidates) {
const sourceExe = path.join(sourceDir, exeName)
if (!await fs.pathExists(sourceExe)) continue
await fs.copyFile(sourceExe, binPath)
if (platform === 'win32') {
await this.copySidecarDlls(sourceDir)
}
await fs.chmod(binPath, 0o755).catch(() => {})
this.log(`已使用内置 sing-box:${sourceExe}`)
return true
}
return false
}
private async copySidecarDlls(sourceDir: string): Promise<void> {
const entries = await fs.readdir(sourceDir, { withFileTypes: true }).catch(() => [])
for (const entry of entries) {
if (!entry.isFile()) continue
if (!entry.name.toLowerCase().endsWith('.dll')) continue
await fs.copyFile(path.join(sourceDir, entry.name), path.join(BIN_DIR, entry.name))
}
}
/**
* 递归查找文件
*/
private async findFileRecursive(rootDir: string, fileName: string): Promise<string | null> {
const entries = await fs.readdir(rootDir, { withFileTypes: true })
let entries: Array<{ name: string, isFile: () => boolean, isDirectory: () => boolean }>
try {
entries = await fs.readdir(rootDir, { withFileTypes: true }) as any
} catch {
return null
}
for (const entry of entries) {
const full = path.join(rootDir, entry.name)
if (entry.isFile() && entry.name === fileName) return full
@@ -320,4 +453,36 @@ export class SingBoxInstaller {
}
return null
}
private async tryAdoptManualBinary(binPath: string): Promise<boolean> {
if (!await fs.pathExists(BIN_DIR)) return false
const exeName = process.platform === 'win32' ? 'sing-box.exe' : 'sing-box'
const manualPath = await this.findFileRecursive(BIN_DIR, exeName)
if (!manualPath) return false
if (path.resolve(manualPath) === path.resolve(binPath)) return true
await fs.copyFile(manualPath, binPath)
await fs.chmod(binPath, 0o755).catch(() => {})
if (process.platform === 'win32') {
const manualDir = path.dirname(manualPath)
const entries = await fs.readdir(manualDir, { withFileTypes: true }).catch(() => [])
for (const entry of entries) {
if (!entry.isFile()) continue
if (!entry.name.toLowerCase().endsWith('.dll')) continue
await fs.copyFile(path.join(manualDir, entry.name), path.join(BIN_DIR, entry.name)).catch(() => {})
}
}
this.log(`检测到手动放置的 sing-box,已采用:${manualPath}`)
return true
}
private emitStatus(payload: Omit<InstallerStatus, 'installDir' | 'binaryPath'>) {
this.onStatus?.({
...payload,
installDir: this.getInstallDir(),
binaryPath: this.getBinaryPath()
})
}
}
+2 -2
View File
@@ -393,7 +393,7 @@ $result | ConvertTo-Json -Compress
const result = await ping(host)
return typeof result?.latency === 'number' ? result.latency : -1
} catch (e: any) {
console.warn('[SystemService] system:ping failed', host, e)
console.warn('[系统服务] Ping 失败:', host, e)
return -1
}
}) // 测试 ICMP 代理连接
@@ -402,7 +402,7 @@ $result | ConvertTo-Json -Compress
const result = await tcpPing(host, port)
return typeof result?.latency === 'number' ? result.latency : -1
} catch (e: any) {
console.warn('[SystemService] system:tcp-ping failed', host, port, e)
console.warn('[系统服务] TCP Ping 失败:', host, port, e)
return -1
}
}) // 测试 TCP 代理连接
+1 -1
View File
@@ -31,7 +31,7 @@ export function runCommand(command: string, args: string[], timeoutMs: number =
clearTimeout(timer)
const output = Buffer.concat(chunks).toString('utf8').trim()
if (timedOut) {
resolve({ code: -1, output: output || 'Command timeout' })
resolve({ code: -1, output: output || '命令执行超时' })
return
}
resolve({ code: typeof code === 'number' ? code : -1, output })
+1 -1
View File
@@ -29,7 +29,7 @@ export async function findAvailablePort(startPort: number, count: number = 1): P
port++
if (port > 65535) {
throw new Error('No available ports found')
throw new Error('未找到可用端口')
}
}
}
+11 -21
View File
@@ -15,21 +15,18 @@
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"prepare:singbox-core": "node electron/scripts/prepare-singbox-core.mjs",
"rebuild:native": "electron-builder install-app-deps",
"rebuild:sqlite": "pnpm rebuild better-sqlite3",
"prepack": "pnpm build",
"pack": "electron-builder --dir",
"predist": "pnpm build",
"dist": "electron-builder",
"predist:win:x64:nsis": "pnpm prepare:singbox-core",
"predist:win:arm64:nsis": "pnpm prepare:singbox-core",
"predist:win:x64:portable": "pnpm prepare:singbox-core",
"predist:win:arm64:portable": "pnpm prepare:singbox-core",
"predist:win:all": "pnpm prepare:singbox-core",
"dist:win:x64:nsis": "electron-builder --win nsis --x64",
"dist:win:arm64:nsis": "electron-builder --win nsis --arm64",
"dist:win:x64:portable": "electron-builder --win portable --x64",
"dist:win:arm64:portable": "electron-builder --win portable --arm64",
"dist:win:all": "electron-builder --win nsis portable --x64 --arm64",
"postdist:win:all": "pnpm run rebuild:native",
"test": "vitest run --coverage"
},
"build": {
@@ -48,15 +45,6 @@
"!**/node_modules/better-sqlite3/build/**/obj/**",
"!**/node_modules/better-sqlite3/build/**/obj.target/**"
],
"extraResources": [
{
"from": "electron/resources/singbox",
"to": "singbox",
"filter": [
"**/*"
]
}
],
"asar": true,
"electronLanguages": [
"en-US",
@@ -64,7 +52,6 @@
],
"win": {
"icon": "public/logo.ico",
"signAndEditExecutable": false,
"target": [
{
"target": "nsis",
@@ -128,10 +115,10 @@
"fs-extra": "^11.3.3",
"kysely": "^0.28.11",
"tar": "^7.5.7",
"uuid": "^13.0.0"
"uuid": "^13.0.0",
"workerpool": "^10.0.1"
},
"devDependencies": {
"@vueuse/core": "^14.2.0",
"@iconify-json/carbon": "^1.2.18",
"@iconify-json/material-symbols": "^1.2.54",
"@types/adm-zip": "^0.5.7",
@@ -140,13 +127,16 @@
"@types/node": "^24.10.1",
"@types/tar": "^6.1.13",
"@types/uuid": "^11.0.0",
"@types/workerpool": "^9.0.0",
"@unocss/reset": "^66.6.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vitest/coverage-v8": "^4.0.18",
"@vue/tsconfig": "^0.8.1",
"@vueuse/core": "^14.2.0",
"echarts": "^6.0.0",
"electron": "^40.2.1",
"electron-builder": "^26.7.0",
"esbuild": "^0.27.3",
"happy-dom": "^20.5.3",
"js-base64": "^3.7.8",
"naive-ui": "^2.43.2",
@@ -165,7 +155,7 @@
"vue": "^3.5.24",
"vue-i18n": "^11.2.8",
"vue-router": "^5.0.2",
"vuedraggable": "^4.1.0",
"vue-tsc": "^3.1.4"
"vue-tsc": "^3.1.4",
"vuedraggable": "^4.1.0"
}
}
}
+22
View File
@@ -29,6 +29,9 @@ importers:
uuid:
specifier: ^13.0.0
version: 13.0.0
workerpool:
specifier: ^10.0.1
version: 10.0.1
devDependencies:
'@iconify-json/carbon':
specifier: ^1.2.18
@@ -54,6 +57,9 @@ importers:
'@types/uuid':
specifier: ^11.0.0
version: 11.0.0
'@types/workerpool':
specifier: ^9.0.0
version: 9.0.0
'@unocss/reset':
specifier: ^66.6.0
version: 66.6.0
@@ -78,6 +84,9 @@ importers:
electron-builder:
specifier: ^26.7.0
version: 26.7.0(electron-builder-squirrel-windows@26.7.0)
esbuild:
specifier: ^0.27.3
version: 0.27.3
happy-dom:
specifier: ^20.5.3
version: 20.5.3
@@ -781,6 +790,10 @@ packages:
'@types/whatwg-mimetype@3.0.2':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
'@types/workerpool@9.0.0':
resolution: {integrity: sha512-gEiZU7P4NzkhqcSwHlSx1CddtzzEjFD/uWegp2tV5DXQABOE4qlH4H8ptCm/S5DDvCViqFj7yMrN3yMBh1pkQg==}
deprecated: This is a stub types definition. workerpool provides its own type definitions, so you do not need this installed.
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
@@ -2768,6 +2781,9 @@ packages:
engines: {node: '>=8'}
hasBin: true
workerpool@10.0.1:
resolution: {integrity: sha512-NAnKwZJxWlj/U1cp6ZkEtPE+GQY1S6KtOS3AlCiPfPFLxV3m64giSp7g2LsNJxzYCocDT7TSl+7T0sgrDp3KoQ==}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -3419,6 +3435,10 @@ snapshots:
'@types/whatwg-mimetype@3.0.2': {}
'@types/workerpool@9.0.0':
dependencies:
workerpool: 10.0.1
'@types/ws@8.18.1':
dependencies:
'@types/node': 24.10.12
@@ -5715,6 +5735,8 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
workerpool@10.0.1: {}
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 39 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 63 KiB

+16 -35
View File
@@ -1,35 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="boltGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4ade80;stop-opacity:1" />
<stop offset="100%" style="stop-color:#22c55e;stop-opacity:1" />
</linearGradient>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="15" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Background Circle (Optional, can be removed for transparent logo) -->
<circle cx="256" cy="256" r="240" fill="#1e293b" stroke="#334155" stroke-width="10" />
<!-- Lightning Bolt Shape -->
<path d="M280 40 L160 260 L260 260 L230 460 L380 220 L270 220 L320 40 Z"
fill="url(#boltGradient)"
stroke="#ffffff"
stroke-width="5"
filter="url(#glow)"
stroke-linejoin="round"
/>
<!-- Speed Lines -->
<path d="M100 150 L60 180 M90 200 L50 230 M100 250 L60 280"
stroke="#4ade80"
stroke-width="8"
stroke-linecap="round"
opacity="0.6"
/>
</svg>
<svg width="512" height="512" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient y2="100%" x2="100%" y1="0%" x1="0%" id="boltGradient">
<stop stop-color="#4ade80" offset="0%"/>
<stop stop-color="#22c55e" offset="100%"/>
</linearGradient>
</defs>
<!-- Background Circle (Optional, can be removed for transparent logo) -->
<!-- Lightning Bolt Shape -->
<!-- Speed Lines -->
<g>
<title>Layer 1</title>
<circle id="svg_1" stroke-width="10" stroke="#334155" fill="#1e293b" r="240" cy="256" cx="256"/>
<path id="svg_2" stroke-linejoin="round" stroke-width="5" stroke="#ffffff" fill="url(#boltGradient)" d="m266,46l-120,220l100,0l-30,200l150,-240l-110,0l50,-180l-40,0z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 707 B

+2
View File
@@ -5,6 +5,7 @@
<n-notification-provider>
<n-modal-provider>
<router-view />
<SingboxInstallerGuard />
</n-modal-provider>
</n-notification-provider>
</n-dialog-provider>
@@ -16,6 +17,7 @@
import { computed } from 'vue'
import { darkTheme, type GlobalThemeOverrides } from 'naive-ui'
import { useTheme, type ThemeColor } from './composables/useTheme'
import SingboxInstallerGuard from '@/components/singbox/SingboxInstallerGuard.vue'
const { isDark, themeColor } = useTheme()
+3
View File
@@ -4,6 +4,9 @@ export const appApi = {
getVersion: (): Promise<string> => window.app.getVersion(),
checkUpdate: () => window.app.checkUpdate(),
openUrl: (url: string) => window.app.openUrl(url),
openDir: (dir: string) => window.app.openDir(dir),
restart: () => window.app.restart(),
reset: () => window.app.reset(),
}
export const logsApi = {
+2
View File
@@ -2,4 +2,6 @@ export const singboxApi = {
start: (config: string) => window.singbox.start(config),
stop: () => window.singbox.stop(),
restart: (config: string) => window.singbox.restart(config),
ensureCoreInstalled: () => window.singbox.ensureCoreInstalled(),
getInstallInfo: () => window.singbox.getInstallInfo(),
}
+12
View File
@@ -52,4 +52,16 @@ export const systemApi = {
}
return fn()
},
onScanProgress: (callback: (data: { status: string, details?: string }) => void) => {
const fn = (window.system as any)?.onScanProgress
if (typeof fn === 'function') {
fn(callback)
}
},
offScanProgress: (callback: (data: { status: string, details?: string }) => void) => {
const fn = (window.system as any)?.offScanProgress
if (typeof fn === 'function') {
fn(callback)
}
}
}
+5 -4
View File
@@ -12,7 +12,6 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
AboutSettings: typeof import('./../components/settings/AboutSettings.vue')['default']
AdvancedConfigEditor: typeof import('./../components/library/AdvancedConfigEditor.vue')['default']
CategoryManager: typeof import('./../components/library/CategoryManager.vue')['default']
GameEditModal: typeof import('./../components/library/GameEditModal.vue')['default']
GeneralSettings: typeof import('./../components/settings/GeneralSettings.vue')['default']
@@ -21,6 +20,7 @@ declare module 'vue' {
LatencyChart: typeof import('./../components/dashboard/LatencyChart.vue')['default']
LogsSettings: typeof import('./../components/settings/LogsSettings.vue')['default']
NButton: typeof import('naive-ui')['NButton']
NCard: typeof import('naive-ui')['NCard']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
@@ -29,6 +29,7 @@ declare module 'vue' {
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NInput: typeof import('naive-ui')['NInput']
NInputGroup: typeof import('naive-ui')['NInputGroup']
NInputNumber: typeof import('naive-ui')['NInputNumber']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal']
@@ -41,10 +42,9 @@ declare module 'vue' {
NodeSelector: typeof import('./../components/dashboard/NodeSelector.vue')['default']
NPopover: typeof import('naive-ui')['NPopover']
NPopselect: typeof import('naive-ui')['NPopselect']
NRadio: typeof import('naive-ui')['NRadio']
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NProgress: typeof import('naive-ui')['NProgress']
NSelect: typeof import('naive-ui')['NSelect']
NSpace: typeof import('naive-ui')['NSpace']
NSpin: typeof import('naive-ui')['NSpin']
NSwitch: typeof import('naive-ui')['NSwitch']
NTag: typeof import('naive-ui')['NTag']
NTime: typeof import('naive-ui')['NTime']
@@ -52,6 +52,7 @@ declare module 'vue' {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SettingsSidebar: typeof import('./../components/settings/SettingsSidebar.vue')['default']
SingboxInstallerGuard: typeof import('./../components/singbox/SingboxInstallerGuard.vue')['default']
SubscriptionManagerModal: typeof import('./../components/node/SubscriptionManagerModal.vue')['default']
ThemeToggle: typeof import('./../components/common/ThemeToggle.vue')['default']
}
+62 -8
View File
@@ -1,12 +1,66 @@
<template>
<div class="flex items-center gap-2 w-full">
<div class="w-10 h-10 bg-surface rounded border border-border overflow-hidden flex items-center justify-center">
<img v-if="modelValue" :src="modelValue" class="w-full h-full object-cover" />
<div v-else class="i-material-symbols-image text-xl text-on-surface-muted"></div>
<div class="flex flex-col sm:flex-row gap-4 w-full">
<!-- Image Preview Area -->
<div
class="group relative w-full sm:w-32 h-32 flex-shrink-0 bg-surface-variant/30 rounded-xl border-2 border-dashed border-border hover:border-primary/50 transition-all duration-300 overflow-hidden flex items-center justify-center cursor-pointer"
@click="handlePickImage"
>
<!-- Image Display -->
<img v-if="modelValue" :src="modelValue" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" />
<!-- Placeholder -->
<div v-else class="flex flex-col items-center justify-center text-on-surface-muted gap-2 p-4 text-center">
<div class="i-material-symbols-add-photo-alternate-outline text-3xl opacity-50 group-hover:opacity-100 group-hover:scale-110 transition-all"></div>
<span class="text-[10px] font-medium opacity-70 group-hover:opacity-100 transition-opacity">点击上传</span>
</div>
<!-- Hover Overlay (Only when has image) -->
<div v-if="modelValue" class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center backdrop-blur-[1px]">
<div class="text-white text-xs font-medium flex items-center gap-1 bg-black/30 px-2 py-1 rounded-full border border-white/20">
<div class="i-material-symbols-edit"></div>
更换
</div>
</div>
</div>
<!-- Controls Area -->
<div class="flex-1 flex flex-col justify-between py-1 min-w-0 gap-3">
<div class="space-y-1">
<div class="flex items-center justify-between">
<label class="text-xs font-medium text-on-surface-muted">图片链接</label>
<n-button v-if="modelValue" type="error" size="small" @click="$emit('update:modelValue', '')" class="text-[10px] opacity-80 hover:opacity-100">
<template #icon><div class="i-material-symbols-delete-outline"></div></template>
移除图片
</n-button>
</div>
<n-input-group>
<n-input
:value="modelValue || ''"
@update:value="$emit('update:modelValue', $event)"
placeholder="https://... 或 file://..."
size="small"
class="flex-1"
>
<template #prefix>
<div class="i-material-symbols-link text-on-surface-muted"></div>
</template>
</n-input>
<n-button ghost size="small" @click="handlePickImage" title="从本地选择">
<template #icon>
<div class="i-material-symbols-folder-open-outline"></div>
</template>
</n-button>
</n-input-group>
</div>
<div class="text-[10px] text-on-surface-muted leading-relaxed bg-surface-variant/20 p-2 rounded-lg border border-border/30">
<div class="flex items-start gap-1.5">
<div class="i-material-symbols-info-outline mt-0.5 text-primary/70 flex-shrink-0"></div>
<span>支持 JPG, PNG, WEBP, GIF 格式。推荐尺寸 16:9 或 1:1,建议使用网络图片地址以获得更好的跨设备体验。</span>
</div>
</div>
</div>
<n-input :value="modelValue || ''" @update:value="$emit('update:modelValue', $event)" placeholder="支持 URL 或 file://..." />
<n-button @click="handlePickImage">选择文件</n-button>
<n-button quaternary @click="$emit('update:modelValue', '')">清空</n-button>
</div>
</template>
@@ -29,4 +83,4 @@ async function handlePickImage() {
emit('update:modelValue', url)
}
}
</script>
</script>
@@ -1,193 +0,0 @@
<template>
<n-modal :show="modelValue" @update:show="$emit('update:modelValue', $event)" preset="card"
:title="isEdit ? $t('common.edit') : $t('games.add_game')" class="w-[600px]" :mask-closable="false">
<n-form ref="formRef" label-placement="top" :model="form">
<div class="grid grid-cols-2 gap-4">
<n-form-item :label="$t('games.name')">
<n-input v-model:value="form.name" placeholder="例如:英雄联盟" />
</n-form-item>
<n-form-item :label="$t('games.category')">
<n-select :value="form.categories" @update:value="updateCategories" multiple
:options="categoryStore.categories.map((c: any) => ({ label: c.name, value: c.id }))" />
</n-form-item>
</div>
<n-form-item :label="$t('games.mode')">
<div class="flex flex-col w-full gap-2">
<n-radio-group v-model:value="form.proxyMode">
<n-space>
<n-radio value="process">{{ $t('games.mode_process') }}</n-radio>
<n-radio value="routing">{{ $t('games.mode_routing') }}</n-radio>
</n-space>
</n-radio-group>
<span class="text-xs text-on-surface-muted">
{{ form.proxyMode === 'process' ? $t('games.mode_process_desc') : $t('games.mode_routing_desc') }}
</span>
</div>
</n-form-item>
<template v-if="form.proxyMode === 'process'">
<n-form-item :label="$t('games.process_name')">
<ProcessSelector v-model="processList" mode="multi" />
</n-form-item>
</template>
<template v-if="form.proxyMode === 'routing'">
<n-form-item :label="$t('rules.ip_rules')">
<div class="flex flex-col w-full gap-1">
<n-input v-model:value="routingRulesText" type="textarea" :rows="5" class="font-mono"
placeholder="1.1.1.1/32&#10;8.8.8.8" />
<span class="text-xs text-on-surface-muted">{{ $t('games.process_name_tip') }} (CIDR/IP)</span>
</div>
</n-form-item>
<n-form-item :label="`${$t('games.process_name')} (${$t('common.optional')})`">
<ProcessSelector v-model="processList" mode="multi" placeholder="例如:Game.exe(用于启动检测)" />
</n-form-item>
</template>
<n-form-item :label="$t('games.icon')">
<IconSelector v-model="form.iconUrl" />
</n-form-item>
</n-form>
<template #footer>
<div class="flex justify-end gap-2">
<n-button @click="close">{{ $t('common.cancel') }}</n-button>
<n-button type="primary" @click="save">
{{ $t('common.save') }}
</n-button>
</div>
</template>
</n-modal>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import type { Game } from '@/types'
import { useCategoryStore } from '@/stores/categories'
import { useMessage } from 'naive-ui'
import IconSelector from '@/components/common/IconSelector.vue'
import ProcessSelector from '@/components/common/ProcessSelector.vue'
const props = defineProps<{
modelValue: boolean
editingGame?: Game | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'save', game: Game): void
}>()
const categoryStore = useCategoryStore()
const message = useMessage()
const isEdit = computed(() => !!props.editingGame)
const form = ref<Partial<Game>>({
name: '',
iconUrl: '',
processName: [],
category: '',
categories: [],
proxyMode: 'process',
routingRules: []
})
const processList = ref<string[]>([''])
const routingRulesText = ref('')
watch(() => props.modelValue, (val) => {
if (val) {
if (props.editingGame) {
form.value = JSON.parse(JSON.stringify(props.editingGame))
if (!Array.isArray(form.value.categories) || form.value.categories.length === 0) {
form.value.categories = form.value.category ? [String(form.value.category)] : []
}
if (Array.isArray(props.editingGame.processName)) {
processList.value = [...props.editingGame.processName]
} else if (props.editingGame.processName) {
processList.value = [props.editingGame.processName]
} else {
processList.value = ['']
}
if (props.editingGame.routingRules) {
routingRulesText.value = props.editingGame.routingRules.join('\n')
} else {
routingRulesText.value = ''
}
} else {
const defaultCategory = categoryStore.categories[0]?.id || ''
form.value = {
name: '',
iconUrl: '',
processName: [],
category: defaultCategory,
categories: defaultCategory ? [defaultCategory] : [],
proxyMode: 'process',
routingRules: []
}
processList.value = ['']
routingRulesText.value = ''
}
}
})
function close() {
emit('update:modelValue', false)
}
function updateCategories(value: string[]) {
const clean = Array.isArray(value) ? value.map(v => String(v).trim()).filter(Boolean) : []
if (clean.length === 0) {
message.warning('至少保留一个标签')
return
}
form.value.categories = clean
form.value.category = clean[0]
}
function save() {
if (!form.value.name) return
const mode = form.value.proxyMode === 'routing' ? 'routing' : 'process'
const cleanProcesses = processList.value.map(p => p.trim()).filter(Boolean)
const cleanRules = routingRulesText.value.split('\n').map(r => r.trim()).filter(Boolean)
const cleanCategories = Array.isArray(form.value.categories)
? form.value.categories.map(c => String(c).trim()).filter(Boolean)
: []
if (mode === 'process' && cleanProcesses.length === 0) {
message.warning('进程模式需要至少填写一个进程名')
return
}
if (mode === 'routing' && cleanRules.length === 0) {
message.warning('路由模式需要至少填写一条 IP/CIDR 规则')
return
}
if (cleanCategories.length === 0) {
message.warning('请至少选择一个标签')
return
}
const gameData: Game = {
...form.value,
category: cleanCategories[0] || '',
categories: cleanCategories,
proxyMode: mode,
iconUrl: form.value.iconUrl ? String(form.value.iconUrl).trim() : undefined,
processName: cleanProcesses,
routingRules: cleanRules
} as Game
if (props.editingGame) {
gameData.id = props.editingGame.id
}
emit('save', gameData)
close()
}
</script>
+166 -60
View File
@@ -1,43 +1,114 @@
<template>
<n-modal :show="modelValue" @update:show="$emit('update:modelValue', $event)" preset="card"
:title="isEdit ? $t('common.edit') : $t('games.add_game')" class="w-[500px]" :mask-closable="false">
<n-form ref="formRef" label-placement="top" :model="form">
<n-form-item :label="$t('games.name')">
<n-input v-model:value="form.name" placeholder="例如:英雄联盟" />
</n-form-item>
:title="isEdit ? $t('common.edit') : $t('games.add_game')" class="w-[95vw] sm:w-[600px] lg:w-[1000px] xl:w-[1100px] max-h-[90vh] flex flex-col"
:content-style="{ overflowY: 'auto' }" :mask-closable="false" :bordered="false" size="huge">
<div class="flex flex-col lg:flex-row gap-6">
<n-form-item :label="$t('games.process_name')">
<ProcessSelector v-model="processNames" mode="single" />
</n-form-item>
<!-- Section 1: Basic Information (Left Column) -->
<div class="flex-1 bg-surface-variant/10 rounded-xl p-5 border border-border/50 flex flex-col gap-4 h-fit">
<div class="flex items-center gap-2 mb-1">
<div class="w-1 h-4 bg-primary rounded-full"></div>
<h3 class="font-bold text-on-surface text-base">{{ $t('games.basic_info') }}</h3>
</div>
<n-form-item :label="$t('games.icon')">
<IconSelector v-model="form.iconUrl" :name="form.name" />
</n-form-item>
<div class="flex flex-col gap-4">
<n-form-item :label="$t('games.name')" path="name">
<n-input v-model:value="form.name" :placeholder="$t('games.name_placeholder')" />
</n-form-item>
<n-form-item :label="$t('games.category')" path="categories">
<n-select :value="form.categories" @update:value="updateCategories" multiple
:options="categoryStore.categories.map((c: any) => ({ label: c.name, value: c.id }))" />
</n-form-item>
</div>
<n-form-item :label="$t('games.category')">
<n-select :value="form.categories" @update:value="updateCategories" multiple
:options="categoryStore.categories.map((c: any) => ({ label: c.name, value: c.id }))" />
</n-form-item>
<n-form-item :label="$t('games.icon')" :show-feedback="false">
<IconSelector v-model="form.iconUrl" />
</n-form-item>
</div>
<n-form-item :label="$t('games.tags')">
<n-dynamic-tags v-model:value="form.tags" />
</n-form-item>
</n-form>
<!-- Section 2: Network Strategy (Right Column) -->
<div
class="flex-1 bg-surface-variant/10 rounded-xl p-5 border border-border/50 flex flex-col gap-4 transition-all duration-300 h-fit">
<div class="flex flex-wrap items-center justify-between gap-3 mb-1">
<div class="flex items-center gap-2 flex-shrink-0">
<div class="w-1 h-4 bg-secondary rounded-full"></div>
<h3 class="font-bold text-on-surface text-base">{{ $t('games.network_strategy') }}</h3>
</div>
<!-- Mode Switcher -->
<div class="bg-surface p-1 rounded-lg border border-border flex shadow-sm w-full sm:w-auto">
<button v-for="mode in ['process', 'routing']" :key="mode"
@click="form.proxyMode = mode as 'process' | 'routing'"
class="flex-1 sm:flex-none px-3 py-1.5 rounded-md text-xs font-bold transition-all duration-200 flex items-center justify-center gap-2 whitespace-nowrap"
:class="form.proxyMode === mode ? 'bg-primary text-on-primary shadow-md' : 'text-on-surface-muted hover:text-on-surface hover:bg-surface-variant/50'">
<div :class="mode === 'process' ? 'i-carbon-application-web' : 'i-carbon-network-overlay'"></div>
{{ mode === 'process' ? $t('games.mode_process') : $t('games.mode_routing') }}
</button>
</div>
</div>
<!-- Mode Description -->
<div
class="bg-surface/50 p-3 rounded-lg border border-border/30 text-xs text-on-surface-muted flex gap-2 items-start">
<div class="i-carbon-information text-primary mt-0.5 flex-shrink-0"></div>
{{ form.proxyMode === 'process' ? $t('games.mode_process_desc') : $t('games.mode_routing_desc') }}
</div>
<!-- Dynamic Configuration Area -->
<div class="mt-2 space-y-4">
<template v-if="form.proxyMode === 'process'">
<n-form-item :label="$t('games.process_name')">
<ProcessSelector v-model="processList" mode="multi" />
</n-form-item>
</template>
<template v-if="form.proxyMode === 'routing'">
<n-form-item :label="$t('rules.ip_rules')">
<div class="flex flex-col w-full gap-2">
<n-input v-model:value="routingRulesText" type="textarea" :rows="5" class="font-mono text-xs"
placeholder="1.1.1.1/32&#10;8.8.8.8" />
<span class="text-xs text-on-surface-muted flex items-center gap-1">
<div class="i-carbon-help"></div>
{{ $t('games.process_name_tip') }} (CIDR/IP)
</span>
</div>
</n-form-item>
<n-form-item :label="`${$t('games.process_name')} (${$t('common.optional')})`">
<ProcessSelector v-model="processList" mode="multi" :placeholder="$t('games.process_optional_placeholder')" />
</n-form-item>
</template>
<!-- Chain Proxy Toggle -->
<div class="flex items-center justify-between p-3 bg-surface rounded-lg border border-border/50">
<div class="flex flex-col mr-4 flex-1">
<span class="text-sm font-bold text-on-surface">{{ $t('games.chain_proxy') }}</span>
<span class="text-xs text-on-surface-muted">{{ $t('games.chain_proxy_desc') }}</span>
</div>
<n-switch v-model:value="form.chainProxy" :disabled="!hasProcessTargets" class="flex-shrink-0">
<template #checked>{{ $t('common.enabled') }}</template>
<template #unchecked>{{ $t('common.disabled') }}</template>
</n-switch>
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<n-button @click="close">{{ $t('common.cancel') }}</n-button>
<n-button type="primary" @click="save" :disabled="!form.name || !processNames">
<div class="flex justify-end gap-3 pt-2">
<n-button @click="close" class="px-6">{{ $t('common.cancel') }}</n-button>
<n-button type="primary" @click="save" class="px-6 shadow-lg shadow-primary/20 font-bold">
{{ $t('common.save') }}
</n-button>
</div>
</template>
</n-modal>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Game } from '@/types'
import { useCategoryStore } from '@/stores/categories'
import { useMessage } from 'naive-ui'
@@ -51,57 +122,69 @@ const props = defineProps<{
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'save', game: Omit<Game, 'id'> | Game): void
(e: 'save', game: Game): void
}>()
const isEdit = computed(() => !!props.editingGame)
const { t } = useI18n()
const categoryStore = useCategoryStore()
const message = useMessage()
const isEdit = computed(() => !!props.editingGame)
const form = ref<Omit<Game, 'id'>>({
const form = ref<Partial<Game>>({
name: '',
iconUrl: '',
processName: '',
category: 'other',
processName: [],
category: '',
categories: [],
tags: [],
lastPlayed: 0
proxyMode: 'process',
routingRules: [],
chainProxy: true
})
const processNames = computed({
get: () => {
if (Array.isArray(form.value.processName)) {
return form.value.processName.join(', ')
}
return form.value.processName || ''
},
set: (val: string) => {
const names = val.split(/[,,]/).map(s => s.trim()).filter(Boolean)
form.value.processName = names.length > 1 ? names : (names[0] || '')
}
})
const processList = ref<string[]>([''])
const routingRulesText = ref('')
const hasProcessTargets = computed(() => processList.value.some(p => String(p || '').trim().length > 0))
watch(hasProcessTargets, (hasTargets) => {
if (!hasTargets) form.value.chainProxy = false
}, { immediate: true })
watch(() => props.modelValue, (val) => {
if (val) {
if (props.editingGame) {
form.value = JSON.parse(JSON.stringify(props.editingGame))
form.value.chainProxy = form.value.chainProxy !== false
if (!Array.isArray(form.value.categories) || form.value.categories.length === 0) {
form.value.categories = form.value.category ? [String(form.value.category)] : []
}
if (!Array.isArray(form.value.tags)) {
form.value.tags = []
if (Array.isArray(props.editingGame.processName)) {
processList.value = [...props.editingGame.processName]
} else if (props.editingGame.processName) {
processList.value = [props.editingGame.processName]
} else {
processList.value = ['']
}
if (props.editingGame.routingRules) {
routingRulesText.value = props.editingGame.routingRules.join('\n')
} else {
routingRulesText.value = ''
}
} else {
const defaultCategory = categoryStore.categories[0]?.id || 'other'
const defaultCategory = categoryStore.categories[0]?.id || ''
form.value = {
name: '',
iconUrl: '',
processName: '',
processName: [],
category: defaultCategory,
categories: defaultCategory ? [defaultCategory] : [],
tags: [],
lastPlayed: 0
proxyMode: 'process',
routingRules: [],
chainProxy: true
}
processList.value = ['']
routingRulesText.value = ''
}
}
})
@@ -113,33 +196,56 @@ function close() {
function updateCategories(value: string[]) {
const clean = Array.isArray(value) ? value.map(v => String(v).trim()).filter(Boolean) : []
if (clean.length === 0) {
message.warning('至少保留一个标签')
message.warning(t('games.at_least_one_tag'))
return
}
form.value.categories = clean
form.value.category = clean[0] || 'other'
form.value.category = clean[0]
}
function save() {
if (!form.value.name) return
const mode = form.value.proxyMode === 'routing' ? 'routing' : 'process'
const cleanProcesses = processList.value.map(p => p.trim()).filter(Boolean)
const cleanRules = routingRulesText.value
.split(/[\r\n,;,;]+/g)
.map(r => r.trim())
.filter(Boolean)
const cleanCategories = Array.isArray(form.value.categories)
? form.value.categories.map((c) => String(c).trim()).filter(Boolean)
? form.value.categories.map(c => String(c).trim()).filter(Boolean)
: []
if (cleanCategories.length === 0) {
message.warning('请至少选择一个标签')
if (mode === 'process' && cleanProcesses.length === 0) {
message.warning(t('games.process_required'))
return
}
const payload = {
...form.value,
category: cleanCategories[0] || 'other',
categories: cleanCategories
if (mode === 'routing' && cleanRules.length === 0) {
message.warning(t('games.routing_rule_required'))
return
}
if (cleanCategories.length === 0) {
message.warning(t('games.tag_required'))
return
}
const gameData: Game = {
...form.value,
category: cleanCategories[0] || '',
categories: cleanCategories,
proxyMode: mode,
chainProxy: cleanProcesses.length > 0 ? form.value.chainProxy !== false : false,
iconUrl: form.value.iconUrl ? String(form.value.iconUrl).trim() : undefined,
processName: cleanProcesses,
routingRules: cleanRules
} as Game
if (props.editingGame) {
emit('save', { ...payload, id: props.editingGame.id })
} else {
emit('save', payload)
gameData.id = props.editingGame.id
}
emit('save', gameData)
close()
}
</script>
+70 -70
View File
@@ -1,80 +1,82 @@
<template>
<div class="flex flex-col items-center justify-center min-h-[400px] text-center animate-fade-in-up">
<div class="relative mb-8 group">
<div
class="absolute inset-0 bg-primary/20 blur-3xl rounded-full scale-150 group-hover:scale-175 transition-transform duration-700">
</div>
<img src="/logo.svg"
class="relative w-32 h-32 drop-shadow-2xl transition-transform duration-500 group-hover:scale-110"
alt="LagZero Logo" />
</div>
<h1
class=" h-14 text-4xl font-black bg-gradient-to-br from-primary via-purple-500 to-secondary bg-clip-text text-transparent mb-2">
{{ pkg.productName }}
</h1>
<p class="text-on-surface-muted mb-8 tracking-wide">{{ $t('settings.tagline') }}</p>
<div class="grid gap-3 w-full max-w-xs">
<div
class="bg-surface-overlay/50 border border-border/50 rounded-xl p-3 flex justify-between items-center mb-4 backdrop-blur-sm">
<span class="text-sm font-medium text-on-surface-muted">{{ $t('settings.current_version') }}</span>
<span class="font-mono font-bold text-primary bg-primary/10 px-2 py-1 rounded">v{{ appVersion
}}</span>
</div>
<n-button type="primary" block secondary size="large" @click="() => checkUpdate()" :loading="checkingUpdate"
class="glass-button-primary">
<template #icon>
<div class="i-material-symbols-update"></div>
</template>
{{ $t('settings.check_update') }}
</n-button>
<div class="grid grid-cols-2 gap-3">
<n-button block secondary @click="openReleasesUrl" class="glass-button">
<template #icon>
<div class="i-material-symbols-history"></div>
</template>
{{ $t('settings.releases') }}
</n-button>
<n-button block secondary @click="openProjectUrl" class="glass-button">
<template #icon>
<div class="i-carbon-logo-github"></div>
</template>
GitHub
</n-button>
</div>
</div>
<div v-if="updateInfo"
class="mt-8 w-full max-w-md text-left bg-surface-panel/80 backdrop-blur-md border border-border p-5 rounded-2xl text-sm space-y-3 animate-scale-in shadow-xl">
<div v-if="updateInfo.error" class="text-error flex items-center gap-2">
<div class="i-material-symbols-error text-xl"></div>
{{ $t('settings.update_error', { msg: updateInfo.error }) }}
</div>
<div v-else-if="updateInfo.available" class="space-y-3">
<div class="text-success font-bold flex items-center gap-2 text-base">
<div class="i-material-symbols-rocket-launch"></div>
{{ $t('settings.update_available', { version: updateInfo.version }) }}
<div class="flex flex-col items-center justify-center h-full text-center animate-fade-in-up">
<div class="flex flex-col items-center justify-center ">
<div class="relative mb-8 group">
<div
class="absolute inset-0 bg-primary/20 blur-3xl rounded-full scale-150 group-hover:scale-175 transition-transform duration-700">
</div>
<div v-if="updateInfo.note"
class="text-on-surface-muted text-xs whitespace-pre-wrap max-h-40 overflow-y-auto bg-surface/50 p-3 rounded-lg border border-border/50 font-mono">
{{ updateInfo.note }}
<img src="/logo.svg"
class="relative w-32 h-32 drop-shadow-2xl transition-transform duration-500 group-hover:scale-110"
alt="LagZero Logo" />
</div>
<h1
class=" h-14 text-4xl font-black bg-gradient-to-br from-primary via-purple-500 to-secondary bg-clip-text text-transparent mb-2">
{{ pkg.productName }}
</h1>
<p class="text-on-surface-muted mb-8 tracking-wide">{{ $t('settings.tagline') }}</p>
<div class="grid gap-3 w-full max-w-xs">
<div
class="bg-surface-overlay/50 border border-border/50 rounded-xl p-3 flex justify-between items-center mb-4 backdrop-blur-sm">
<span class="text-sm font-medium text-on-surface-muted">{{ $t('settings.current_version') }}</span>
<span class="font-mono font-bold text-primary bg-primary/10 px-2 py-1 rounded">v{{ appVersion
}}</span>
</div>
<n-button type="primary" size="small" block class="glass-button-primary mt-2" @click="openReleasesUrl">
<n-button type="primary" block secondary size="large" @click="() => checkUpdate()" :loading="checkingUpdate"
class="glass-button-primary">
<template #icon>
<div class="i-material-symbols-download"></div>
<div class="i-material-symbols-update"></div>
</template>
{{ $t('settings.download_update') }}
{{ $t('settings.check_update') }}
</n-button>
<div class="text-xs text-on-surface-muted text-right">{{ updateInfo.date }}</div>
<div class="grid grid-cols-2 gap-3">
<n-button block secondary @click="openReleasesUrl" class="glass-button">
<template #icon>
<div class="i-material-symbols-history"></div>
</template>
{{ $t('settings.releases') }}
</n-button>
<n-button block secondary @click="openProjectUrl" class="glass-button">
<template #icon>
<div class="i-carbon-logo-github"></div>
</template>
GitHub
</n-button>
</div>
</div>
<div v-else class="text-on-surface-muted flex items-center justify-center gap-2 py-2">
<div class="i-material-symbols-check-circle text-success text-lg"></div>
{{ $t('settings.no_update') }}
<div v-if="updateInfo"
class="mt-8 w-full max-w-md text-left bg-surface-panel/80 backdrop-blur-md border border-border p-5 rounded-2xl text-sm space-y-3 animate-scale-in shadow-xl">
<div v-if="updateInfo.error" class="text-error flex items-center gap-2">
<div class="i-material-symbols-error text-xl"></div>
{{ $t('settings.update_error', { msg: updateInfo.error }) }}
</div>
<div v-else-if="updateInfo.available" class="space-y-3">
<div class="text-success font-bold flex items-center gap-2 text-base">
<div class="i-material-symbols-rocket-launch"></div>
{{ $t('settings.update_available', { version: updateInfo.version }) }}
</div>
<div v-if="updateInfo.note"
class="text-on-surface-muted text-xs whitespace-pre-wrap max-h-40 overflow-y-auto bg-surface/50 p-3 rounded-lg border border-border/50 font-mono">
{{ updateInfo.note }}
</div>
<n-button type="primary" size="small" block class="glass-button-primary mt-2" @click="openReleasesUrl">
<template #icon>
<div class="i-material-symbols-download"></div>
</template>
{{ $t('settings.download_update') }}
</n-button>
<div class="text-xs text-on-surface-muted text-right">{{ updateInfo.date }}</div>
</div>
<div v-else class="text-on-surface-muted flex items-center justify-center gap-2 py-2">
<div class="i-material-symbols-check-circle text-success text-lg"></div>
{{ $t('settings.no_update') }}
</div>
</div>
</div>
</div>
@@ -87,5 +89,3 @@ import { useAppUpdater } from '@/composables/useAppUpdater'
const { appVersion, checkingUpdate, updateInfo, checkUpdate, openProjectUrl, openReleasesUrl } = useAppUpdater()
</script>
+29 -5
View File
@@ -18,6 +18,26 @@
<div class="w-full h-px bg-border/30"></div>
<!-- Theme Mode Section -->
<section>
<h2 class="text-xl font-bold mb-4 flex items-center gap-2">
<div class="i-material-symbols-brightness-6-outline text-primary"></div>
{{ $t('settings.theme_mode') }}
</h2>
<div class="bg-surface/50 border border-border/50 rounded-2xl p-1 flex gap-2 w-fit backdrop-blur-sm">
<button v-for="mode in themeModes" :key="mode.value" @click="setTheme(mode.value)"
class="px-6 py-2.5 rounded-xl transition-all duration-200 font-medium relative overflow-hidden flex items-center gap-2"
:class="theme === mode.value
? 'bg-surface text-primary shadow-sm ring-1 ring-border/50'
: 'text-on-surface-muted hover:text-on-surface hover:bg-surface-overlay/50'">
<div :class="mode.icon" class="text-lg"></div>
{{ mode.label }}
</button>
</div>
</section>
<div class="w-full h-px bg-border/30"></div>
<!-- Theme Section -->
<section>
<h2 class="text-xl font-bold mb-4 flex items-center gap-2">
@@ -48,11 +68,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTheme, type ThemeColor } from '@/composables/useTheme'
import { useTheme, type ThemeColor, type Theme } from '@/composables/useTheme'
import { useSettingsStore } from '@/stores/settings'
const { locale } = useI18n()
const { themeColor, setThemeColor } = useTheme()
const { locale, t } = useI18n()
const { theme, setTheme, themeColor, setThemeColor } = useTheme()
const settingsStore = useSettingsStore()
const currentLocale = computed(() => settingsStore.language)
@@ -61,6 +81,12 @@ const availableLocales = [
{ code: 'en-US', name: 'English' }
]
const themeModes = computed<{ value: Theme, label: string, icon: string }[]>(() => [
{ value: 'light', label: t('settings.theme_light'), icon: 'i-material-symbols-light-mode-outline' },
{ value: 'dark', label: t('settings.theme_dark'), icon: 'i-material-symbols-dark-mode-outline' },
{ value: 'auto', label: t('settings.theme_auto'), icon: 'i-material-symbols-hdr-auto' }
])
const themeColors: { value: ThemeColor, label: string, bg: string }[] = [
{ value: 'green', label: 'Green', bg: 'bg-[#10b981]' },
{ value: 'blue', label: 'Blue', bg: 'bg-[#3b82f6]' },
@@ -74,5 +100,3 @@ function setLanguage(lang: string) {
locale.value = lang
}
</script>
+35 -7
View File
@@ -65,15 +65,23 @@
<div class="space-y-2">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">{{
$t('settings.dns_primary') }}</label>
<n-input v-model:value="settingsStore.dnsPrimary" placeholder="https://dns.google/dns-query"
<n-input v-model:value="settingsStore.dnsPrimary" placeholder="https://cloudflare-dns.com/dns-query"
class="glass-input" />
</div>
<div class="space-y-2">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">{{
$t('settings.dns_secondary') }}</label>
<n-input v-model:value="settingsStore.dnsSecondary" placeholder="https://1.1.1.1/dns-query"
<n-input v-model:value="settingsStore.dnsSecondary" placeholder="https://dns.alidns.com/resolve"
class="glass-input" />
</div>
<div class="space-y-2">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">{{
$t('settings.dns_bootstrap') }}</label>
<n-input v-model:value="settingsStore.dnsBootstrap" placeholder="223.5.5.5" class="glass-input" />
<p class="text-xs text-on-surface-muted">
{{ $t('settings.dns_bootstrap_hint') }}
</p>
</div>
</div>
</div>
</section>
@@ -164,6 +172,17 @@
{{ $t('settings.system_proxy_info') }}
</p>
</div>
<div class="space-y-2">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">
{{ $t('settings.proxy_bypass') }}
</label>
<n-input v-model:value="settingsStore.systemProxyBypass" type="textarea"
:autosize="{ minRows: 3, maxRows: 8 }" :placeholder="$t('settings.proxy_bypass_placeholder')"
class="glass-input" />
<p class="text-xs text-on-surface-muted mt-1">
{{ $t('settings.proxy_bypass_info') }}
</p>
</div>
</div>
</section>
@@ -175,18 +194,23 @@
<div class="i-material-symbols-joystick text-primary"></div>
{{ $t('settings.game_network_tuning') }}
</h2>
<div class="bg-surface-panel/50 border border-border/50 rounded-2xl p-6 backdrop-blur-sm space-y-4">
<div
class="bg-surface-panel/50 border border-border/50 rounded-2xl p-6 backdrop-blur-sm space-y-4 transition-opacity duration-300"
:class="{ 'opacity-60': !isTunNetworkMode }">
<p class="text-xs text-on-surface-muted">
{{ $t('settings.game_network_tuning_hint') }}
</p>
<p v-if="!isTunNetworkMode" class="text-xs text-warning">
{{ $t('settings.game_network_tuning_tun_only') }}
</p>
<div class="flex items-center justify-between">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">
{{ $t('common.enabled') }}
</label>
<n-switch v-model:value="settingsStore.sessionNetworkTuning.enabled" />
<n-switch v-model:value="settingsStore.sessionNetworkTuning.enabled" :disabled="!isTunNetworkMode" />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"
:class="{ 'opacity-50 pointer-events-none': !settingsStore.sessionNetworkTuning.enabled }">
:class="{ 'opacity-50 pointer-events-none': !settingsStore.sessionNetworkTuning.enabled || !isTunNetworkMode }">
<div class="space-y-2 md:col-span-2">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">
{{ $t('settings.network_profile') }}
@@ -248,10 +272,11 @@
<p class="text-xs text-on-surface-muted">
{{ $t('settings.high_loss_hint_only') }}
</p>
<n-switch v-model:value="settingsStore.sessionNetworkTuning.highLossHintOnly" />
<n-switch v-model:value="settingsStore.sessionNetworkTuning.highLossHintOnly" :disabled="!isTunNetworkMode" />
</div>
<div class="pt-1">
<n-button secondary @click="settingsStore.resetSessionNetworkTuning()" class="glass-button">
<n-button secondary @click="settingsStore.resetSessionNetworkTuning()" class="glass-button"
:disabled="!isTunNetworkMode">
{{ $t('settings.reset_session_network_tuning') }}
</n-button>
</div>
@@ -324,6 +349,7 @@ const opOk = ref(true)
const intervalOptions = [
{ label: '1s', value: 1000 },
{ label: '2s', value: 2000 },
{ label: '3s', value: 3000 },
{ label: '5s', value: 5000 },
{ label: '10s', value: 10000 },
{ label: '30s', value: 30000 },
@@ -342,6 +368,7 @@ const vlessEncodingOptions = computed(() => ([
{ label: t('settings.vless_packet_encoding_off'), value: 'off' },
{ label: 'xudp', value: 'xudp' }
]))
const isTunNetworkMode = computed(() => settingsStore.accelNetworkMode === 'tun')
function setCheckMethod(method: string) {
// @ts-ignore
@@ -423,6 +450,7 @@ watch(
async (_next, prev) => {
if (_next === prev) return
if (applyingSessionTuning.value) return
if (!isTunNetworkMode.value) return
const active = gameStore.getAcceleratingGame()
if (!active?.id) return
if (!gameStore.isUsingGlobalSessionNetworkTuning(active.id)) return
+15 -23
View File
@@ -1,41 +1,33 @@
<template>
<aside class="w-64 flex-none flex flex-col gap-6">
<aside class="flex flex-col h-full py-8 px-4 gap-6">
<div class="px-2">
<h1 class="text-3xl font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
<h1 class="text-2xl font-bold tracking-tight">
{{ $t('common.settings') }}
</h1>
<p class="text-xs text-on-surface-muted mt-1 uppercase tracking-widest font-bold opacity-60">
<p class="text-xs text-on-surface-muted mt-0.5">
{{ $t('settings.preferences') }}
</p>
</div>
<nav class="flex flex-col gap-2">
<nav class="flex flex-col gap-1">
<button v-for="tab in tabs" :key="tab.name" @click="$emit('update:modelValue', tab.name)"
class="flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group relative overflow-hidden text-left"
:class="modelValue === tab.name
? 'bg-primary/10 text-primary font-bold shadow-sm'
class="flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 text-left font-medium" :class="modelValue === tab.name
? 'bg-primary/10 text-primary shadow-sm'
: 'hover:bg-surface-overlay text-on-surface-muted hover:text-on-surface'">
<!-- Active Indicator -->
<div v-if="modelValue === tab.name"
class="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-primary rounded-r-full"></div>
<div
:class="[tab.icon, modelValue === tab.name ? 'text-primary' : 'text-on-surface-muted group-hover:text-on-surface']"
class="text-xl transition-colors"></div>
<span>{{ tab.label }}</span>
<div :class="[tab.icon, modelValue === tab.name ? 'text-primary' : 'text-on-surface-muted']"
class="text-lg transition-colors"></div>
<span class="text-sm">{{ tab.label }}</span>
</button>
</nav>
<!-- App Info Small -->
<div class="mt-auto px-4 py-4 rounded-xl bg-surface-panel/30 border border-border/50 backdrop-blur-sm">
<div class="flex items-center gap-3 mb-2">
<div class="w-8 h-8 rounded-lg bg-surface-panel flex items-center justify-center border border-border">
<img src="/logo.svg" class="w-5 h-5" alt="Logo" />
</div>
<div>
<div class="text-sm font-bold">{{ pkg.productName }}</div>
<div class="text-[10px] text-on-surface-muted font-mono">v{{ appVersion }}</div>
<div class="mt-auto px-4 py-4 rounded-xl bg-surface-overlay/30 border border-border/50">
<div class="flex items-center gap-3">
<img src="/logo.svg" class="w-6 h-6 opacity-80" alt="Logo" />
<div class="min-w-0">
<div class="text-[13px] font-bold truncate">{{ pkg.productName }}</div>
<div class="text-[10px] text-on-surface-muted font-mono truncate">v{{ appVersion }}</div>
</div>
</div>
</div>
@@ -0,0 +1,281 @@
<template>
<!-- 正常模式:模态框 -->
<n-modal :show="visible && !isMinimized" preset="card"
class="w-[680px] max-w-[96vw] transition-all duration-300 ease-in-out" :title="t('singbox_installer.title')"
:closable="false" :mask-closable="false" :close-on-esc="false">
<template #header-extra>
<n-button quaternary circle size="small" @click="isMinimized = true">
<template #icon>
<div class="i-material-symbols-remove text-lg"></div>
</template>
</n-button>
</template>
<div class="space-y-4">
<p class="text-sm text-on-surface-muted">
{{ t('singbox_installer.description') }}
</p>
<div class="rounded-xl border border-border bg-surface-overlay/35 p-3 text-sm">
<div class="font-semibold mb-2">{{ t('singbox_installer.download_url') }}</div>
<div class="break-all text-on-surface-muted">{{ effectiveDownloadUrl }}</div>
<div class="mt-3 flex gap-2">
<n-button size="small" @click="openDownloadUrl">
{{ t('singbox_installer.open_download_url') }}
</n-button>
</div>
</div>
<div class="rounded-xl border border-border bg-surface-overlay/35 p-3 text-sm">
<div class="font-semibold mb-2">{{ t('singbox_installer.install_dir') }}</div>
<div class="break-all text-on-surface-muted">{{ installDir }}</div>
<div class="mt-3 flex gap-2">
<n-button size="small" @click="openInstallDir">
{{ t('singbox_installer.open_install_dir') }}
</n-button>
</div>
</div>
<div class="rounded-xl border border-border bg-surface-overlay/35 p-3 text-sm">
<div class="font-semibold mb-2">{{ t('singbox_installer.download_progress') }}</div>
<n-progress v-if="typeof progress === 'number'" type="line" :percentage="progress"
:indicator-placement="'inside'" processing />
<div v-else class="text-on-surface-muted flex items-center gap-2">
<n-spin size="small" />
<span>{{ phaseText }}</span>
</div>
<div class="mt-2 text-xs text-on-surface-muted">
{{ phaseText }}
</div>
</div>
<div v-if="phase === 'failed'" class="rounded-xl border border-error/40 bg-error/8 p-3 text-sm text-on-surface">
<div class="font-semibold text-error mb-2">
{{ failedByTimeout ? t('singbox_installer.download_timeout') : t('singbox_installer.download_failed') }}
</div>
<div class="text-on-surface-muted break-words">
{{ errorMessage || t('singbox_installer.auto_download_failed') }}
</div>
<div class="mt-2 text-on-surface-muted">
{{ t('singbox_installer.manual_install_guide') }}
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<n-button v-if="phase === 'failed'" :loading="ensuring" @click="retryDownload">
{{ t('singbox_installer.retry_download') }}
</n-button>
<n-button :loading="rechecking" @click="checkAgain">
{{ t('singbox_installer.recheck') }}
</n-button>
</div>
</template>
</n-modal>
<!-- 最小化模式:底部悬浮窗 -->
<Teleport to="body">
<div v-if="visible && isMinimized" class="fixed bottom-8 left-1/2 -translate-x-1/2 z-[5000] w-[320px]">
<n-card size="small" class="shadow-xl border-border/50 backdrop-blur-md bg-surface-panel/95"
:title="typeof progress === 'number' ? t('singbox_installer.downloading_percent', { percent: progress }) : phaseText"
:bordered="true">
<template #header-extra>
<n-button quaternary circle size="small" @click="isMinimized = false">
<template #icon>
<div class="i-material-symbols-open-in-full text-lg"></div>
</template>
</n-button>
</template>
<div class="flex flex-col gap-2 py-1">
<n-progress v-if="typeof progress === 'number'" type="line" :percentage="progress"
:indicator-placement="'inside'" processing />
<div v-else-if="phase === 'failed'" class="text-error text-sm flex items-center gap-2">
<div class="i-material-symbols-error"></div>
<span>{{ t('singbox_installer.failed') }}</span>
</div>
<div v-else class="flex items-center gap-2 text-on-surface-muted text-sm">
<n-spin size="small" />
<span>{{ phaseText }}</span>
</div>
</div>
</n-card>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { appApi, singboxApi } from '@/api'
type InstallerPhase =
| 'idle'
| 'checking'
| 'ready'
| 'missing'
| 'resolving'
| 'downloading'
| 'extracting'
| 'completed'
| 'failed'
interface InstallerStatusEvent {
phase: Exclude<InstallerPhase, 'idle'>
installDir: string
binaryPath: string
version?: string
downloadUrl?: string
progress?: number
error?: string
isTimeout?: boolean
}
const DEFAULT_DOWNLOAD_URL = 'https://github.com/SagerNet/sing-box/releases/latest'
const message = useMessage()
const { t } = useI18n()
const visible = ref(false)
const phase = ref<InstallerPhase>('idle')
const progress = ref<number | undefined>(undefined)
const installDir = ref('')
const binaryPath = ref('')
const downloadUrl = ref('')
const errorMessage = ref('')
const failedByTimeout = ref(false)
const ensuring = ref(false)
const rechecking = ref(false)
const coreReadyLock = ref(false)
const isMinimized = ref(false)
const effectiveDownloadUrl = computed(() => downloadUrl.value || DEFAULT_DOWNLOAD_URL)
const phaseText = computed(() => {
if (phase.value === 'checking') return t('singbox_installer.checking')
if (phase.value === 'missing') return t('singbox_installer.missing')
if (phase.value === 'resolving') return t('singbox_installer.resolving')
if (phase.value === 'downloading') {
return typeof progress.value === 'number'
? t('singbox_installer.downloading_percent', { percent: progress.value })
: t('singbox_installer.downloading')
}
if (phase.value === 'extracting') return t('singbox_installer.extracting')
if (phase.value === 'failed') return failedByTimeout.value ? t('singbox_installer.failed_timeout') : t('singbox_installer.failed')
return t('singbox_installer.preparing')
})
const onInstallerStatus = (payload: InstallerStatusEvent) => {
if (coreReadyLock.value) {
visible.value = false
return
}
phase.value = payload.phase
installDir.value = payload.installDir || installDir.value
binaryPath.value = payload.binaryPath || binaryPath.value
downloadUrl.value = payload.downloadUrl || downloadUrl.value
errorMessage.value = payload.error || ''
failedByTimeout.value = Boolean(payload.isTimeout)
progress.value = typeof payload.progress === 'number'
? Math.max(0, Math.min(100, Math.floor(payload.progress)))
: undefined
if (payload.phase === 'ready' || payload.phase === 'completed') {
markCoreReady()
return
}
if (payload.phase === 'missing' || payload.phase === 'resolving' || payload.phase === 'downloading' || payload.phase === 'extracting' || payload.phase === 'failed') {
visible.value = true
}
}
const openInstallDir = () => {
void appApi.openDir(installDir.value).catch((e: any) => {
message.error(t('singbox_installer.open_dir_failed', { error: String(e?.message || e) }))
})
}
onMounted(async () => {
window.electron.on('singbox-installer-status', onInstallerStatus)
try {
const info = await singboxApi.getInstallInfo()
installDir.value = info.installDir
binaryPath.value = info.binaryPath
if (!info.exists) {
phase.value = 'missing'
visible.value = true
void ensureCore()
} else {
markCoreReady()
}
} catch (e: any) {
visible.value = true
phase.value = 'failed'
errorMessage.value = String(e?.message || e)
}
})
onUnmounted(() => {
window.electron.off('singbox-installer-status', onInstallerStatus)
})
async function ensureCore() {
if (ensuring.value) return
ensuring.value = true
try {
await singboxApi.ensureCoreInstalled()
} catch (e: any) {
if (phase.value !== 'failed') {
phase.value = 'failed'
errorMessage.value = String(e?.message || e)
}
visible.value = true
} finally {
ensuring.value = false
}
}
async function checkAgain() {
if (rechecking.value) return
rechecking.value = true
try {
const info = await singboxApi.getInstallInfo()
installDir.value = info.installDir
binaryPath.value = info.binaryPath
if (info.exists) {
markCoreReady()
const restarted = await appApi.restart()
if (!restarted) {
message.warning(t('singbox_installer.restart_hint'))
}
return
}
message.warning(t('singbox_installer.core_not_found'))
} catch (e: any) {
message.error(t('singbox_installer.recheck_failed', { error: String(e?.message || e) }))
} finally {
rechecking.value = false
}
}
function openDownloadUrl() {
void appApi.openUrl(effectiveDownloadUrl.value).catch((e: any) => {
message.error(t('singbox_installer.open_url_failed', { error: String(e?.message || e) }))
})
}
function retryDownload() {
phase.value = 'missing'
errorMessage.value = ''
void ensureCore()
}
function markCoreReady() {
coreReadyLock.value = true
visible.value = false
phase.value = 'ready'
}
</script>
+6 -1
View File
@@ -89,6 +89,10 @@ export function useAppUpdater() {
function openProjectUrl() {
appApi.openUrl('https://github.com/ZenEcho/LagZero')
}
/** 打开核心文件夹 */
function openInstallDir() {
}
return {
appVersion,
@@ -97,6 +101,7 @@ export function useAppUpdater() {
getVersion,
checkUpdate,
openReleasesUrl,
openProjectUrl
openProjectUrl,
openInstallDir
}
}
+28 -4
View File
@@ -5,8 +5,11 @@ import { categoryApi, systemApi } from '@/api'
import { useGameStore } from '@/stores/games'
import { useCategoryStore } from '@/stores/categories'
import type { Category, Game, LocalScanGame } from '@/types'
import { PLATFORMS } from '@/constants'
const PLATFORM_CATEGORY_NAMES = ['Steam', 'Microsoft', 'Epic', 'EA'] as const
// 将 isScanning 和 scanProgressText 提升到模块级别
const isScanning = ref(false)
const scanProgressText = ref('')
/**
* 游戏扫描器 Composable
@@ -14,7 +17,6 @@ const PLATFORM_CATEGORY_NAMES = ['Steam', 'Microsoft', 'Epic', 'EA'] as const
*/
export function useGameScanner() {
const { t } = useI18n()
const isScanning = ref(false)
const message = useMessage()
const gameStore = useGameStore()
const categoryStore = useCategoryStore()
@@ -62,7 +64,7 @@ export function useGameScanner() {
* @param source 游戏来源平台
*/
async function ensurePlatformCategoryId(source: LocalScanGame['source']): Promise<string> {
const platformName = PLATFORM_CATEGORY_NAMES.find((name) => name === source) || source
const platformName = PLATFORMS.find((name) => name === source) || source
return ensureCategoryByName(platformName)
}
@@ -71,8 +73,27 @@ export function useGameScanner() {
* 包括:扫描本地游戏库、自动添加新游戏、扫描运行进程、匹配运行状态
*/
async function scanGames() {
if (isScanning.value) {
message.warning(t('games.scan_in_progress') || '正在扫描中,请稍后再试')
return
}
isScanning.value = true
scanProgressText.value = t('games.scanning_local') || '正在扫描本地游戏,请耐心等待...'
const handleProgress = (data: { status: string, details?: string }) => {
if (data.status === 'scanning_platform') {
scanProgressText.value = `正在扫描游戏平台: ${data.details}`
} else if (data.status === 'scanning_dir' && data.details) {
// 防止路径过长导致 UI 不美观,进行截断
const shortPath = data.details.length > 50 ? '...' + data.details.slice(-50) : data.details
scanProgressText.value = `正在检索: ${shortPath}`
}
// 可以在控制台打印详细日志
// console.log('[Scan Progress]', data.status, data.details)
}
try {
systemApi.onScanProgress(handleProgress)
const localGames = await systemApi.scanLocalGames()
const added = await autoAddGamesFromLibraryScan(localGames)
const processes = await systemApi.scanProcesses()
@@ -89,6 +110,8 @@ export function useGameScanner() {
console.error('Failed to scan games:', error)
message.error(t('games.scan_failed'))
} finally {
systemApi.offScanProgress(handleProgress)
scanProgressText.value = ''
setTimeout(() => { isScanning.value = false }, 500)
}
}
@@ -99,7 +122,7 @@ export function useGameScanner() {
* @returns 新增的游戏数量
*/
async function autoAddGamesFromLibraryScan(localGames: LocalScanGame[]) {
for (const platform of PLATFORM_CATEGORY_NAMES) {
for (const platform of PLATFORMS) {
const hasPlatformGame = localGames.some(g => g.source === platform)
if (!hasPlatformGame) continue
await ensureCategoryByName(platform)
@@ -156,6 +179,7 @@ export function useGameScanner() {
return {
isScanning,
scanProgressText,
scanGames
}
}
+91 -85
View File
@@ -1,107 +1,113 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { effectScope, nextTick } from 'vue'
import { useTheme } from './useTheme'
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] || null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value.toString()
}),
removeItem: vi.fn((key: string) => {
delete store[key]
}),
clear: vi.fn(() => {
store = {}
})
}
})()
Object.defineProperty(window, 'localStorage', {
value: localStorageMock
})
describe('useTheme', () => {
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] || null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value.toString()
}),
clear: () => {
store = {}
}
}
})()
Object.defineProperty(window, 'localStorage', {
value: localStorageMock
})
// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // Deprecated
removeListener: vi.fn(), // Deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
let prefersDark = false
beforeEach(() => {
setActivePinia(createPinia())
prefersDark = false
localStorageMock.clear()
localStorageMock.getItem.mockClear()
localStorageMock.setItem.mockClear()
localStorageMock.removeItem.mockClear()
document.documentElement.className = ''
// Reset internal state if possible, but useTheme uses module-level state.
// So we might need to reset via public methods
const { setTheme } = useTheme()
setTheme('auto')
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
get matches() {
return prefersDark
},
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}))
})
})
it('should initialize with default theme', () => {
const { theme } = useTheme()
expect(theme.value).toBe('auto')
const scope = effectScope()
const api = scope.run(() => useTheme())!
expect(api.theme.value).toBe('auto')
expect(api.themeColor.value).toBe('green')
scope.stop()
})
it('should set theme and persist to localStorage', () => {
const { setTheme, theme } = useTheme()
setTheme('dark')
expect(theme.value).toBe('dark')
expect(localStorageMock.setItem).toHaveBeenCalledWith('theme', 'dark')
it('should set theme and apply dark class', async () => {
const scope = effectScope()
const api = scope.run(() => useTheme())!
api.setTheme('dark')
await nextTick()
expect(api.theme.value).toBe('dark')
expect(document.documentElement.classList.contains('dark')).toBe(true)
expect(localStorageMock.setItem).toHaveBeenCalled()
scope.stop()
})
it('should toggle theme', () => {
const { toggleTheme, resolvedTheme } = useTheme()
// Assume start at auto -> system light (mock default) -> resolved light
// Actually our mock implementation of matchMedia returns false for dark preference by default
// Force set to dark first
const { setTheme } = useTheme()
setTheme('dark')
expect(resolvedTheme.value).toBe('dark')
it('should toggle theme between dark and light', async () => {
const scope = effectScope()
const api = scope.run(() => useTheme())!
toggleTheme()
expect(resolvedTheme.value).toBe('light')
toggleTheme()
expect(resolvedTheme.value).toBe('dark')
api.setTheme('dark')
await nextTick()
expect(api.resolvedTheme.value).toBe('dark')
api.toggleTheme()
await nextTick()
expect(api.theme.value).toBe('light')
expect(api.resolvedTheme.value).toBe('light')
api.toggleTheme()
await nextTick()
expect(api.theme.value).toBe('dark')
expect(api.resolvedTheme.value).toBe('dark')
scope.stop()
})
it('should respond to system preference when auto', () => {
const { setTheme } = useTheme()
setTheme('auto')
// Simulate system dark mode
// Note: Since useTheme creates mediaQuery inside, and we mocked matchMedia globally,
// we can't easily trigger the event listener attached inside the closure without more complex mocking.
// But we can check if it reads the initial state correctly.
// Let's mock matchMedia to return true for dark
window.matchMedia = vi.fn().mockImplementation(query => ({
matches: true,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}))
it('should follow system preference when theme is auto', async () => {
const scope = effectScope()
const api = scope.run(() => useTheme())!
// Re-run apply logic (simulate mount)
// const { onMounted } = useTheme()
// Since we can't easily access lifecycle hooks outside component,
// we might just test the logic if we export applyTheme or similar.
// Or we rely on the fact that setTheme calls applyTheme.
setTheme('auto')
// With matches: true
// expect(document.documentElement.classList.contains('dark')).toBe(true)
// However, the `mediaQuery` const in `useTheme.ts` is initialized ONCE when the module is imported.
// So changing window.matchMedia AFTER import won't affect the `mediaQuery` variable inside `useTheme`.
// This makes testing `auto` mode tricky with module-level state.
// For unit testing purposes, it's better if `useTheme` was a store or we could reset it.
prefersDark = false
api.setTheme('auto')
await nextTick()
expect(document.documentElement.classList.contains('dark')).toBe(false)
prefersDark = true
api.setTheme('light')
await nextTick()
api.setTheme('auto')
await nextTick()
expect(document.documentElement.classList.contains('dark')).toBe(true)
scope.stop()
})
})
+14 -3
View File
@@ -1,3 +1,5 @@
export const SYSTEM_PROXY_PORT = 10808
export const LOCAL_PROXY_PORT = 10860
export const LOCAL_PROXY_HOSTS = ['www.google.com', 'www.apple.com']
export const LOCAL_PROXY_NODE_CHECK_RETRIES = 3
export const LOCAL_PROXY_NODE_CHECK_RETRY_DELAY_MS = 1200
@@ -5,7 +7,16 @@ export const LOCAL_PROXY_NODE_APPLY_WARMUP_MS = 800
export const LOCAL_PROXY_NODE_CHECK_TIMEOUT_MS = 5000
export const LOCAL_PROXY_RECHECK_INTERVAL_MS = 60000
export const GAME_CATEGORIES = ['fps', 'moba', 'rpg', 'other'] as const
export const DEFAULT_DNS_PRIMARY = 'https://cloudflare-dns.com/dns-query'
export const DEFAULT_DNS_SECONDARY = 'https://dns.alidns.com/resolve'
export const BOOTSTRAP_DNS = '223.5.5.5'
export const DEFAULT_DNS_PRIMARY = 'https://dns.google/dns-query'
export const DEFAULT_DNS_SECONDARY = 'https://1.1.1.1/dns-query'
export const GEOIP_CN_RULE_SET_TAG = 'geoip-cn'
export const LOYALSOLDIER_GEOIP_CN_SRS_URL = 'https://cdn.jsdelivr.net/gh/Loyalsoldier/geoip@release/srs/cn.srs'
export const GEOSITE_CN_RULE_SET_TAG = 'geosite-cn'
export const SAGERNET_GEOSITE_CN_SRS_URL = 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-cn.srs'
export const DNS_ADDRESS_STRATEGY = 'prefer_ipv4'
/** 游戏平台 */
export const PLATFORMS = ['Steam', 'Microsoft', 'Epic', 'EA', 'BattleNet', 'WeGame', 'Local', 'GOG'] as const
+1 -1
View File
@@ -39,7 +39,7 @@
<!-- Sidebar -->
<aside
class="bg-surface-panel flex flex-col pt-4 z-40 border-r border-border transition-all duration-300 ease-in-out"
:class="isCollapsed ? 'w-16 items-center' : 'w-64'" @wheel="handleSidebarWheel">
:class="isCollapsed ? 'w-16 items-center' : ' w-48'" @wheel="handleSidebarWheel">
<div class="flex-1 flex flex-col gap-2 px-2 w-full">
<!-- Navigation Items -->
<router-link to="/dashboard" active-class="bg-primary/10 text-primary"
+64 -5
View File
@@ -137,14 +137,14 @@
"subscription_update_failed": "Subscription update failed"
},
"games": {
"library": "Game Library",
"library": "Games",
"no_game_selected": "No game selected. Please select a game to accelerate.",
"go_library": "Go to Library",
"packet_loss": "Packet Loss",
"connection_latency": "Connection Latency",
"duration": "Duration",
"mode": "Mode",
"mode_process": "Process Mode (Recommended)",
"mode_process": "Process Mode",
"mode_process_desc": "Only accelerate game processes.",
"mode_process_subdesc": "Accelerate specific game processes only",
"mode_routing": "Routing Mode",
@@ -153,6 +153,7 @@
"search_placeholder": "Search games...",
"select_game_desc": "Select a game from your library to start accelerating and optimize your network performance.",
"scan_local": "Scan Local",
"scanning_local": "Scanning local games, please be patient...",
"scanning": "Scanning...",
"no_games_found": "No games found",
"selected": "Selected",
@@ -161,7 +162,7 @@
"name": "Name",
"process_name": "Process Name",
"process_name_tip": "Separate multiple process names with commas",
"icon": "Icon Class",
"icon": "Icon",
"category": "Category",
"tags": "Platform/Other Tags",
"delete_confirm": "Are you sure to delete this game?",
@@ -174,11 +175,20 @@
"scan_complete_no_added": "Scan complete: No new games added, {matched} running matched.",
"scan_complete_none": "Scan complete: No new local games found.",
"scan_failed": "Scan failed, please try again.",
"accelerating": "Accelerating",
"accelerating_now": "Accelerating Now",
"current_game": "Current Game",
"switching_warning": "Accelerating \"{name}\", please stop acceleration before switching.",
"stopped_acceleration": "Acceleration stopped",
"stop_failed": "Stop failed"
"stop_failed": "Stop failed",
"basic_info": "Basic Information",
"network_strategy": "Network Strategy",
"name_placeholder": "e.g. League of Legends",
"process_optional_placeholder": "e.g. Game.exe (for startup detection)",
"at_least_one_tag": "Keep at least one tag",
"process_required": "Process mode requires at least one process name",
"routing_rule_required": "Routing mode requires at least one IP/CIDR rule",
"tag_required": "Please select at least one tag"
},
"rules": {
"routing": "Routing",
@@ -196,6 +206,10 @@
},
"settings": {
"language": "Language",
"theme_mode": "Theme Mode",
"theme_dark": "Dark Mode",
"theme_light": "Light Mode",
"theme_auto": "Auto",
"theme_color": "Theme Color",
"coming_soon": "Settings coming soon...",
"toggle_theme": "Toggle Theme",
@@ -222,6 +236,8 @@
"dns_mode_system_hint": "System mode uses OS/ISP DNS and ignores the DoH addresses below; on polluted networks this may cause privacy/certificate warnings.",
"dns_primary": "Primary DNS",
"dns_secondary": "Secondary DNS",
"dns_bootstrap": "Bootstrap DNS",
"dns_bootstrap_hint": "Used to resolve remote DNS domains (e.g. dns.google). Prefer a directly reachable DNS IP such as 223.5.5.5.",
"download_update": "Download Update Now",
"tagline": "Cross-platform Game Accelerator",
"preferences": "Preferences",
@@ -250,8 +266,12 @@
"system_proxy": "System Proxy",
"system_proxy_port": "System Proxy Port",
"system_proxy_info": "System proxy uses HTTP port only (Windows system proxy settings).",
"proxy_bypass": "Proxy Bypass List",
"proxy_bypass_placeholder": "Example: localhost, 127.0.0.1, 192.168.*, 10.0.0.0/8",
"proxy_bypass_info": "Use comma/semicolon/newline separators. Supports domains, IP, CIDR, *.example.com, and 192.168.*. Applied to both system proxy bypass and TUN direct rules.",
"game_network_tuning": "Game Network Tuning",
"game_network_tuning_hint": "Global default network tuning for all games (unless overridden per game). Changes while accelerating will restart acceleration to apply.",
"game_network_tuning_tun_only": "This feature is available in TUN mode only. In System Proxy mode, tuning parameters are ignored.",
"network_profile": "Network Profile",
"network_profile_stable": "Stable First",
"network_profile_aggressive": "Aggressive Low Latency",
@@ -267,6 +287,15 @@
"vless_only_hint": "Current selected node is not VLESS, so this option won't take effect.",
"high_loss_hint_only": "High-loss mode: hint only, no auto fallback",
"reset_session_network_tuning": "Reset Defaults",
"danger_zone": "Danger Zone",
"reset_app": "Reset App",
"reset_app_desc": "Clear all local app data (nodes, games, categories, logs, cache, etc.) and restart automatically.",
"reset_app_confirm_title": "Confirm App Reset",
"reset_app_confirm_desc": "This action cannot be undone. Type {keyword} to confirm.",
"reset_app_confirm_placeholder": "Type {keyword}",
"reset_app_confirm_button": "Confirm Reset",
"reset_app_keyword_mismatch": "Confirmation text mismatch. Please type {keyword}.",
"reset_app_failed": "Reset failed. Please try again.",
"session_network_tuning_restarting": "Applying network tuning and restarting acceleration...",
"session_network_tuning_applied": "Network tuning applied",
"session_network_tuning_apply_failed": "Failed to apply network tuning"
@@ -303,6 +332,7 @@
"mode_switch_success": "Switched to {mode}",
"session_tuning": "Network Tuning",
"current_game_tuning_hint": "This panel applies to current game only",
"session_tuning_tun_only": "Network tuning parameters are supported only in TUN mode and do not apply in System Proxy mode.",
"high_loss_hint": "Packet loss is high in this session. Try Stable profile or switch UDP mode to Auto/Prefer TCP.",
"please_select_node": "Please select a node first",
"start_failed": "Failed to start acceleration",
@@ -326,5 +356,34 @@
"reason_startup": "Startup",
"reason_settings": "Settings",
"reason_recheck": "Auto Recheck"
},
"singbox_installer": {
"title": "Install sing-box Core",
"description": "sing-box core not found. LagZero requires the core to be installed to function properly.",
"download_url": "Core Download URL",
"open_download_url": "Open Download URL",
"install_dir": "Core Installation Folder",
"open_install_dir": "Open Core Folder",
"download_progress": "Download Progress",
"download_timeout": "Download timed out, please install manually",
"download_failed": "Download failed, please install manually",
"auto_download_failed": "Automatic download failed. Please download manually and place it in the installation folder.",
"manual_install_guide": "Manual installation: Download and extract the archive, then place `sing-box.exe` (Windows) or `sing-box` (Linux/macOS) into the directory above.",
"recheck": "I have placed the core, recheck",
"retry_download": "Retry Download",
"checking": "Checking local core...",
"missing": "Core not found, preparing to download...",
"resolving": "Resolving latest version download URL...",
"downloading": "Downloading core...",
"downloading_percent": "Downloading core: {percent}%",
"extracting": "Download complete, installing core...",
"failed_timeout": "Download timed out, waiting for manual installation",
"failed": "Download failed, waiting for manual installation",
"preparing": "Preparing core...",
"open_dir_failed": "Failed to open installation directory: {error}",
"restart_hint": "In-app restart is not supported in dev mode. Please manually close and re-run pnpm dev.",
"core_not_found": "Core file not found. Please verify that the sing-box executable is in the installation directory and try again.",
"recheck_failed": "Recheck failed: {error}",
"open_url_failed": "Failed to open download URL: {error}"
}
}
}
+62 -3
View File
@@ -153,6 +153,7 @@
"search_placeholder": "搜索游戏...",
"select_game_desc": "从游戏库中选择一个游戏以开始加速并优化您的网络性能。",
"scan_local": "扫描游戏",
"scanning_local": "正在扫描本地游戏,请耐心等待...",
"scanning": "扫描中...",
"no_games_found": "未找到游戏",
"selected": "已选择",
@@ -161,7 +162,7 @@
"name": "游戏名称",
"process_name": "进程名称",
"process_name_tip": "多个进程名请用逗号分隔",
"icon": "图标类名",
"icon": "游戏图标",
"category": "分类",
"tags": "平台/其他标签",
"delete_confirm": "确定要删除这个游戏吗?",
@@ -174,11 +175,20 @@
"scan_complete_no_added": "扫描完成:未新增游戏,当前运行匹配 {matched} 个。",
"scan_complete_none": "扫描完成:未发现可新增的本地游戏。",
"scan_failed": "扫描失败,请重试。",
"accelerating": "加速中",
"accelerating_now": "当前正在加速",
"current_game": "当前游戏",
"switching_warning": "正在加速「{name}」,请先停止加速后再切换。",
"stopped_acceleration": "已停止加速",
"stop_failed": "停止失败"
"stop_failed": "停止失败",
"basic_info": "基本信息",
"network_strategy": "加速策略",
"name_placeholder": "例如:英雄联盟",
"process_optional_placeholder": "例如:Game.exe(用于启动检测)",
"at_least_one_tag": "至少保留一个标签",
"process_required": "进程模式需要至少填写一个进程名",
"routing_rule_required": "路由模式需要至少填写一条 IP/CIDR 规则",
"tag_required": "请至少选择一个标签"
},
"rules": {
"routing": "路由模式",
@@ -196,6 +206,10 @@
},
"settings": {
"language": "语言",
"theme_mode": "主题模式",
"theme_dark": "深色模式",
"theme_light": "浅色模式",
"theme_auto": "跟随系统",
"theme_color": "主题颜色",
"coming_soon": "设置功能开发中...",
"toggle_theme": "切换主题",
@@ -222,6 +236,8 @@
"dns_mode_system_hint": "系统默认模式会使用本机/运营商 DNS,下面的 DoH 地址不会生效;在受污染网络下可能出现“您的连接不是私密连接”。",
"dns_primary": "主 DNS",
"dns_secondary": "备用 DNS",
"dns_bootstrap": "Bootstrap DNS",
"dns_bootstrap_hint": "用于解析远端 DNS 域名(例如 dns.google)。推荐填写可直连的 DNS IP,如 223.5.5.5。",
"download_update": "立即下载更新",
"tagline": "跨平台游戏加速器",
"preferences": "偏好设置",
@@ -250,8 +266,12 @@
"system_proxy": "系统代理",
"system_proxy_port": "系统代理端口",
"system_proxy_info": "系统代理仅使用 HTTP 端口(Windows 系统代理设置)。",
"proxy_bypass": "代理排除列表",
"proxy_bypass_placeholder": "示例:localhost, 127.0.0.1, 192.168.*, 10.0.0.0/8",
"proxy_bypass_info": "支持逗号/分号/换行分隔。可填写域名、IP、CIDR、*.example.com 或 192.168.*。该列表同时用于系统代理绕过和 TUN 直连规则。",
"game_network_tuning": "游戏网络优化",
"game_network_tuning_hint": "作为全局默认网络优化参数,作用于所有游戏(未单独覆盖时)。若加速中修改,将自动重启加速生效。",
"game_network_tuning_tun_only": "该功能仅在 TUN 模式下可用。当前为系统代理模式,网络优化参数不会生效。",
"network_profile": "网络档位",
"network_profile_stable": "稳定优先",
"network_profile_aggressive": "激进低延迟",
@@ -267,6 +287,15 @@
"vless_only_hint": "当前所选节点不是 VLESS,该项不会生效。",
"high_loss_hint_only": "高丢包时仅提示,不自动回退配置",
"reset_session_network_tuning": "恢复默认",
"danger_zone": "危险操作",
"reset_app": "重置软件",
"reset_app_desc": "清空软件的所有本地数据(节点、游戏、分类、日志、缓存等),并自动重启。",
"reset_app_confirm_title": "确认重置软件",
"reset_app_confirm_desc": "此操作不可撤销。请输入 {keyword} 以确认。",
"reset_app_confirm_placeholder": "请输入 {keyword}",
"reset_app_confirm_button": "确认重置",
"reset_app_keyword_mismatch": "确认文本不正确,请输入 {keyword}",
"reset_app_failed": "重置失败,请稍后重试",
"session_network_tuning_restarting": "正在应用网络优化参数并重启加速...",
"session_network_tuning_applied": "网络优化参数已生效",
"session_network_tuning_apply_failed": "应用网络优化参数失败"
@@ -303,6 +332,7 @@
"mode_switch_success": "已切换为 {mode}",
"session_tuning": "网络优化",
"current_game_tuning_hint": "此处仅作用于当前游戏",
"session_tuning_tun_only": "仅 TUN 模式支持网络优化参数,系统代理模式下不生效。",
"high_loss_hint": "当前会话丢包偏高,建议切到“稳定优先”或将 UDP 改为“自动/优先 TCP”。",
"please_select_node": "请先选择一个节点",
"start_failed": "启动加速失败",
@@ -326,5 +356,34 @@
"reason_startup": "启动",
"reason_settings": "设置项目",
"reason_recheck": "自动重检"
},
"singbox_installer": {
"title": "安装 sing-box 核心",
"description": "检测到本机没有 sing-box 核心,LagZero 需要先安装核心后才能正常工作。",
"download_url": "核心下载地址",
"open_download_url": "打开下载地址",
"install_dir": "核心安装文件夹",
"open_install_dir": "打开核心文件夹",
"download_progress": "下载进度",
"download_timeout": "下载超时,请手动安装核心",
"download_failed": "下载失败,请手动安装核心",
"auto_download_failed": "自动下载失败,请手动下载后放到安装文件夹。",
"manual_install_guide": "手动安装方式:下载压缩包并解压,把 `sing-box.exe`(Windows)或 `sing-box`(Linux/macOS)放到上述目录。",
"recheck": "我已放置核心,重新检测",
"retry_download": "重试自动下载",
"checking": "正在检测本地核心...",
"missing": "未检测到核心,准备下载...",
"resolving": "正在解析最新版本下载地址...",
"downloading": "正在下载核心...",
"downloading_percent": "正在下载核心:{percent}%",
"extracting": "下载完成,正在安装核心...",
"failed_timeout": "下载超时,等待手动安装",
"failed": "下载失败,等待手动安装",
"preparing": "正在准备核心...",
"open_dir_failed": "打开安装目录失败:{error}",
"restart_hint": "开发模式下不支持应用内自动重启,请手动关闭并重新执行 pnpm dev。",
"core_not_found": "未检测到核心文件,请确认已将 sing-box 可执行文件放到安装目录后再试。",
"recheck_failed": "重新检测失败:{error}",
"open_url_failed": "打开下载地址失败:{error}"
}
}
}
+43 -5
View File
@@ -39,6 +39,30 @@ function normalizeProxyModeForPreset(game: Game): Game {
return { ...game, proxyMode: 'routing' }
}
function buildSystemProxyBypassValue(raw: string): string {
const parts = String(raw || '')
.split(/[\r\n,;]+/g)
.map(v => String(v).trim())
.filter(Boolean)
if (!parts.some(v => v.toLowerCase() === '<local>')) {
parts.unshift('<local>')
}
return parts.join(';')
}
function sanitizeSessionNetworkTuningForMode(
tuning: SessionNetworkTuningOptions,
accelNetworkMode: 'tun' | 'system_proxy'
): SessionNetworkTuningOptions {
if (accelNetworkMode === 'tun') return tuning
return {
...tuning,
enabled: false
}
}
/**
* 游戏仓库:
* - 管理游戏库与当前选中项
@@ -378,13 +402,19 @@ export const useGameStore = defineStore('games', () => {
const rawGame = toRaw(game) as Game
const rawNode = toRaw(node) as any
const effectiveSessionTuning = sanitizeSessionNetworkTuningForMode(
{ ...getEffectiveSessionNetworkTuning(rawGame.id) },
settingsStore.accelNetworkMode
)
const config = String(generateSingboxConfig(rawGame, rawNode, {
mode: settingsStore.dnsMode,
primary: settingsStore.dnsPrimary,
secondary: settingsStore.dnsSecondary,
bootstrap: settingsStore.dnsBootstrap,
tunInterfaceName: settingsStore.tunInterfaceName,
proxyBypassList: settingsStore.systemProxyBypass,
accelNetworkMode: settingsStore.accelNetworkMode,
sessionTuning: { ...getEffectiveSessionNetworkTuning(rawGame.id) },
sessionTuning: effectiveSessionTuning,
localProxyNode,
localProxy: {
enabled: settingsStore.localProxyEnabled,
@@ -398,7 +428,10 @@ export const useGameStore = defineStore('games', () => {
await singboxApi.restart(config)
if (useSystemProxy) {
const proxyResult = await systemApi.setSystemProxy(systemProxyPortToUse, '<local>')
const proxyResult = await systemApi.setSystemProxy(
systemProxyPortToUse,
buildSystemProxyBypassValue(settingsStore.systemProxyBypass)
)
if (!proxyResult?.ok) {
await singboxApi.stop()
const rollbackResult = await systemApi.clearSystemProxy(proxyResult?.snapshot || previousSystemProxySnapshot || undefined)
@@ -417,8 +450,11 @@ export const useGameStore = defineStore('games', () => {
systemProxySnapshot.value = null
}
const procs = Array.isArray(rawGame.processName) ? rawGame.processName.map(p => String(p)) : [String(rawGame.processName)]
const shouldEnableChainProxy = rawGame.proxyMode === 'process' && rawGame.chainProxy !== false
const procs = (Array.isArray(rawGame.processName) ? rawGame.processName : [rawGame.processName])
.map(p => String(p).trim())
.filter(Boolean)
// Chain proxy is available for both process/routing modes, but requires process names.
const shouldEnableChainProxy = rawGame.chainProxy !== false && procs.length > 0
if (shouldEnableChainProxy) await proxyMonitorApi.start(String(rawGame.id), procs)
else await proxyMonitorApi.stop()
@@ -487,6 +523,8 @@ export const useGameStore = defineStore('games', () => {
const active = getAcceleratingGame()
if (!active?.id) return false
if (operationState.value !== 'idle') return false
const settingsStore = useSettingsStore()
if (settingsStore.accelNetworkMode !== 'tun') return false
await stopGame(active.id)
await startGame(active.id)
@@ -560,6 +598,6 @@ function toIpcGame(game: Game): Game {
nodeId: raw.nodeId != null ? String(raw.nodeId) : undefined,
proxyMode: raw.proxyMode === 'routing' ? 'routing' : 'process',
routingRules,
chainProxy: !!raw.chainProxy
chainProxy: raw.chainProxy !== false
}
}
+12 -5
View File
@@ -2,7 +2,7 @@
import { defineStore } from 'pinia'
import { reactive } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import { DEFAULT_DNS_PRIMARY, DEFAULT_DNS_SECONDARY } from '@/constants'
import { SYSTEM_PROXY_PORT, LOCAL_PROXY_PORT, DEFAULT_DNS_PRIMARY, DEFAULT_DNS_SECONDARY, BOOTSTRAP_DNS } from '@/constants'
import type { CheckMethod, DnsMode, Theme, ThemeColor, SessionNetworkTuningOptions, NetworkProfile } from '@/types'
function createDefaultSessionNetworkTuning(): SessionNetworkTuningOptions {
@@ -49,19 +49,24 @@ export const useSettingsStore = defineStore('settings', () => {
const themeColor = useLocalStorage<ThemeColor>('settings-theme-color', 'green') // 主题色 默认green
// Network & Proxy
const checkInterval = useLocalStorage('settings-check-interval', 5000) // 检测间隔 默认5000ms
const checkInterval = useLocalStorage('settings-check-interval', 3000) // 检测间隔 默认5000ms
const checkMethod = useLocalStorage<CheckMethod>('settings-check-method', 'ping') // 检测模式 默认ping
// 检测URL
const checkUrl = useLocalStorage('settings-check-url', 'http://www.gstatic.com/generate_204')
const checkUrl = useLocalStorage('settings-check-url', 'http://www.gstatic.com/generate_204')
const dnsMode = useLocalStorage<DnsMode>('settings-dns-mode', 'secure') // DNS模式 默认secure
const dnsPrimary = useLocalStorage('settings-dns-primary', DEFAULT_DNS_PRIMARY) // DNS主服务器 默认
const dnsSecondary = useLocalStorage('settings-dns-secondary', DEFAULT_DNS_SECONDARY) // DNS次服务器 默认
const dnsBootstrap = useLocalStorage('settings-dns-bootstrap', BOOTSTRAP_DNS) // Bootstrap DNS 默认223.5.5.5
const localProxyEnabled = useLocalStorage('settings-local-proxy-enabled', true) // 本地代理是否启用 默认true
const localProxyPort = useLocalStorage('settings-local-proxy-port', 10860) // 本地代理端口 默认10860
const localProxyPort = useLocalStorage('settings-local-proxy-port', LOCAL_PROXY_PORT) // 本地代理端口 默认10860
const localProxyNodeRecursiveTest = useLocalStorage('settings-local-proxy-node-recursive-test', true) // 本地代理节点递归测试 默认true
const localProxyFixedNodeIndex = useLocalStorage('settings-local-proxy-fixed-node-index', 1) // 本地代理固定节点索引 默认1
const accelNetworkMode = useLocalStorage<'tun' | 'system_proxy'>('settings-accel-network-mode', 'tun') // 加速网络模式 默认tun
const systemProxyPort = useLocalStorage('settings-system-proxy-port', 10808) // 系统代理端口 默认10808
const systemProxyPort = useLocalStorage('settings-system-proxy-port', SYSTEM_PROXY_PORT) // 系统代理端口 默认10808
const systemProxyBypass = useLocalStorage(
'settings-system-proxy-bypass',
'<local>\nlocalhost\n127.0.0.1\n::1\n192.168.*\n10.*\n*.local'
) // 系统代理绕过列表
const tunInterfaceName = useLocalStorage('settings-tun-interface-name', 'LagZero') // TUN接口名称 默认LagZero
if (tunInterfaceName.value === 'singbox-tun') {
tunInterfaceName.value = 'LagZero'
@@ -91,12 +96,14 @@ export const useSettingsStore = defineStore('settings', () => {
dnsMode,
dnsPrimary,
dnsSecondary,
dnsBootstrap,
localProxyEnabled,
localProxyPort,
localProxyNodeRecursiveTest,
localProxyFixedNodeIndex,
accelNetworkMode,
systemProxyPort,
systemProxyBypass,
tunInterfaceName,
sessionNetworkTuning,
resetSessionNetworkTuning,
+11 -2
View File
@@ -1,4 +1,4 @@
import type { Game, Category, NodeConfig } from './index'
import type { Game, Category, NodeConfig, Platform } from './index'
export interface ElectronAPI {
minimize: () => Promise<void>
@@ -15,6 +15,12 @@ export interface SingboxAPI {
start: (config: string) => Promise<void>
stop: () => Promise<void>
restart: (config: string) => Promise<void>
ensureCoreInstalled: () => Promise<void>
getInstallInfo: () => Promise<{
exists: boolean
installDir: string
binaryPath: string
}>
}
export interface SystemAPI {
@@ -22,7 +28,7 @@ export interface SystemAPI {
scanLocalGames: () => Promise<Array<{
name: string
processName: string[]
source: 'Steam' | 'Microsoft' | 'Epic' | 'EA'
source: Platform
installDir: string
}>>
getProcessTree: () => Promise<any>
@@ -77,6 +83,9 @@ export interface AppAPI {
error?: string
}>
openUrl: (url: string) => Promise<void>
openDir: (dir: string) => Promise<void>
restart: () => Promise<boolean>
reset: () => Promise<boolean>
}
export interface LogEntry {
+7 -1
View File
@@ -1,3 +1,7 @@
import { PLATFORMS } from '@/constants'
export type Platform = typeof PLATFORMS[number]
export interface Game {
id?: string
name: string
@@ -91,7 +95,9 @@ export interface DnsConfigOptions {
mode?: 'secure' | 'system' // DNS 模式:安全模式 (DoH/DoT) 或 系统解析
primary?: string // 主 DNS 服务器地址
secondary?: string // 备用 DNS 服务器地址
bootstrap?: string // Bootstrap DNS(用于解析 DNS 服务器域名)
tunInterfaceName?: string
proxyBypassList?: string
accelNetworkMode?: AccelNetworkMode
disableTun?: boolean
localProxyNode?: NodeConfig
@@ -152,7 +158,7 @@ export interface SingboxConfig {
export type LocalScanGame = {
name: string
processName: string[]
source: 'Steam' | 'Microsoft' | 'Epic' | 'EA'
source: Platform
installDir: string
}
+124
View File
@@ -34,12 +34,17 @@ describe('generateSingboxConfig session tuning', () => {
const cfg = buildConfig({ mode: 'secure' })
const tun = cfg.inbounds.find((i: any) => i.tag === 'tun-in')
expect(tun.mtu).toBe(1280)
expect(tun.inet6_address).toBeUndefined()
expect(tun.strict_route).toBe(false)
expect(tun.stack).toBe('system')
const remotePrimary = cfg.dns.servers.find((s: any) => s.tag === 'remote-primary')
expect(remotePrimary?.strategy).toBe('ipv4_only')
expect(cfg.dns.final).toBe('remote-primary')
expect(cfg.route.rules.some((r: any) => r.protocol === 'udp')).toBe(false)
const nodeOut = cfg.outbounds.find((o: any) => o.tag === 'node-out')
expect(nodeOut.packet_encoding).toBe('packetaddr')
expect(nodeOut.domain_strategy).toBeUndefined()
})
it('applies stable/aggressive tun mappings and vless xudp override', () => {
@@ -99,4 +104,123 @@ describe('generateSingboxConfig session tuning', () => {
const udpDirectRule = preferTcp.route.rules.find((r: any) => r.protocol === 'udp')
expect(udpDirectRule?.outbound).toBe('direct')
})
it('parses wildcard and localhost bypass entries into direct rules', () => {
const cfg = buildConfig({
mode: 'secure',
proxyBypassList: 'localhost;192.168.*'
})
const ipBypassRule = cfg.route.rules.find((r: any) =>
Array.isArray(r?.ip_cidr) && r.ip_cidr.includes('192.168.0.0/16') && r.outbound === 'direct'
)
expect(ipBypassRule).toBeDefined()
const localhostRule = cfg.route.rules.find((r: any) =>
Array.isArray(r?.domain) && r.domain.includes('localhost') && r.outbound === 'direct'
)
expect(localhostRule).toBeDefined()
})
it('ignores session tuning in system proxy mode', () => {
const cfg = buildConfig({
accelNetworkMode: 'system_proxy',
systemProxy: {
enabled: true,
port: 10808
},
sessionTuning: {
enabled: true,
profile: 'aggressive',
udpMode: 'prefer_udp',
tunMtu: 1360,
tunStack: 'mixed',
strictRoute: true,
vlessPacketEncodingOverride: 'xudp',
highLossHintOnly: true
}
})
const tunInbound = cfg.inbounds.find((i: any) => i.tag === 'tun-in')
expect(tunInbound).toBeUndefined()
expect(cfg.route.rules.some((r: any) => r.protocol === 'udp')).toBe(false)
const nodeOut = cfg.outbounds.find((o: any) => o.tag === 'node-out')
expect(nodeOut.packet_encoding).toBe('packetaddr')
expect(nodeOut.domain_strategy).toBeUndefined()
})
it('keeps process filtering in system proxy process mode', () => {
const cfg = buildConfig({
accelNetworkMode: 'system_proxy',
systemProxy: {
enabled: true,
port: 10808
}
})
const forcedInboundRule = cfg.route.rules.find((r: any) =>
Array.isArray(r?.inbound) && r.inbound.includes('system-http-in') && r.outbound === 'proxy'
)
expect(forcedInboundRule).toBeDefined()
expect(Array.isArray(forcedInboundRule.process_name)).toBe(true)
expect(forcedInboundRule.process_name).toContain('game.exe')
})
it('adds bootstrap DNS server when configured', () => {
const cfg = buildConfig({
mode: 'secure',
primary: 'https://cloudflare-dns.com/dns-query',
secondary: 'https://cloudflare-dns.com/dns-query',
bootstrap: '223.5.5.5'
})
const bootstrapServer = cfg.dns.servers.find((s: any) => s.tag === 'bootstrap')
expect(bootstrapServer).toBeDefined()
expect(bootstrapServer.address).toBe('223.5.5.5')
expect(bootstrapServer.strategy).toBe('ipv4_only')
})
it('keeps process scope for routing ip rules parsed from comma-separated entries', () => {
const cfg = JSON.parse(generateSingboxConfig(
{
...baseGame,
proxyMode: 'routing',
processName: ['chrome.exe'],
routingRules: ['1.1.1.1, 8.8.8.8/32']
},
baseNode,
{ mode: 'secure' }
))
expect(cfg.route.final).toBe('direct')
expect(cfg.dns.final).toBe('remote-primary')
const cidrRule = cfg.route.rules.find((r: any) =>
Array.isArray(r?.ip_cidr) && r.ip_cidr.includes('1.1.1.1/32') && r.ip_cidr.includes('8.8.8.8/32')
)
expect(cidrRule).toBeDefined()
expect(cidrRule.outbound).toBe('proxy')
expect(Array.isArray(cidrRule.process_name)).toBe(true)
expect(cidrRule.process_name).toContain('chrome.exe')
})
it('scopes routing global to specified processes', () => {
const cfg = JSON.parse(generateSingboxConfig(
{
...baseGame,
proxyMode: 'routing',
routingRules: ['global'],
processName: ['msedge.exe']
},
baseNode,
{ mode: 'secure' }
))
// With process scope, only msedge goes through proxy; everything else is direct.
expect(cfg.route.final).toBe('direct')
expect(cfg.dns.final).toBe('remote-primary')
expect(cfg.route.rules.some((r: any) =>
Array.isArray(r?.process_name) && r.process_name.includes('msedge.exe') && r.outbound === 'proxy'
)).toBe(true)
})
})
+409 -66
View File
@@ -1,46 +1,114 @@
/**
* Singbox 配置生成工具
* 用于将游戏规则和节点配置转换为 Sing-box 可识别的 JSON 格式
*
* 核心功能:
* 1. 路由模式与进程模式的配置生成
* 2. 多协议节点转换 (Shadowsocks, VMess, VLESS, Trojan 等)
* 3. DNS 策略管理 (防污染、分流)
* 4. 虚拟网卡 (TUN) 与系统代理配置
* 5. 网络调优参数处理
*/
import type { Game, DnsConfigOptions, SingboxConfig, SessionNetworkTuningOptions, VlessPacketEncodingOverride } from '@/types'
import type { NodeConfig } from '@/utils/protocol'
import pkg from '../../package.json'
const GEOIP_CN_RULE_SET_TAG = 'geoip-cn'
const LOYALSOLDIER_GEOIP_CN_SRS_URL = 'https://cdn.jsdelivr.net/gh/Loyalsoldier/geoip@release/srs/cn.srs'
const GEOSITE_CN_RULE_SET_TAG = 'geosite-cn'
const SAGERNET_GEOSITE_CN_SRS_URL = 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-cn.srs'
import {
DEFAULT_DNS_PRIMARY,
DEFAULT_DNS_SECONDARY,
GEOIP_CN_RULE_SET_TAG,
LOYALSOLDIER_GEOIP_CN_SRS_URL,
GEOSITE_CN_RULE_SET_TAG,
SAGERNET_GEOSITE_CN_SRS_URL,
DNS_ADDRESS_STRATEGY,
} from '@/constants'
/**
* 生成 Sing-box 配置文件
* 这是整个配置生成的核心入口函数。
*
* @param game 游戏配置对象,包含代理模式和进程信息
* @param node 节点配置对象,包含服务器地址、协议等
* @param dnsOptions DNS 配置选项
* @returns 格式化后的 JSON 字符串
* @param game 游戏配置对象,包含代理模式(路由/进程)和需要代理的进程列表
* @param node 节点配置对象,包含服务器地址、端口、协议类型及认证信息
* @param dnsOptions DNS 及高级网络配置选项,包括 DNS 服务器、TUN 配置、本地代理等
* @returns 格式化后的 JSON 字符串,可直接写入 config.json 供 Sing-box 使用
*/
export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?: DnsConfigOptions): string {
// ---------------------------------------------------------------------------
// 1. 配置上下文解析与预处理
// ---------------------------------------------------------------------------
// 判断是否为路由模式 (Routing Mode)
// 路由模式:基于规则分流,通常配合 TUN 模式使用
// 进程模式:仅代理指定进程的流量
const isRoutingMode = game.proxyMode === 'routing'
// 标准化进程名称:去除路径、转小写、添加 .exe 后缀等
const processes = normalizeProcessNames(Array.isArray(game.processName) ? game.processName : [game.processName])
// 加速网络模式:默认为 TUN 模式,也可选系统代理模式
const accelNetworkMode = dnsOptions?.accelNetworkMode || 'tun'
const routingRules = Array.isArray(game.routingRules) ? game.routingRules.map(r => String(r).trim()).filter(Boolean) : []
const hasBypassCn = routingRules.some(r => r.toLowerCase() === 'bypass_cn')
const hasGlobal = routingRules.some(r => r.toLowerCase() === 'global')
const routingIpCidrs = normalizeRoutingIpCidrs(routingRules)
// 解析路由规则,判断是否包含特殊规则 (bypass_cn, global)
const routingRules = tokenizeRoutingRules(game.routingRules) // 解析路由规则字符串为数组
const hasBypassCn = routingRules.some(r => r.toLowerCase() === 'bypass_cn') // 是否包含 bypass_cn 规则
const hasGlobal = routingRules.some(r => r.toLowerCase() === 'global') // 是否包含全局路由规则
const routingIpCidrs = normalizeRoutingIpCidrs(routingRules) // 标准化路由规则中的 IP CIDR 格式
// 判断是否为"类全局"路由:包含 bypass_cn、global 或没有指定具体 CIDR 规则
const isGlobalLikeRouting = hasBypassCn || hasGlobal || routingIpCidrs.length === 0
// 是否指定了进程
const hasProcessScope = processes.length > 0
// 路由模式下的进程限定逻辑:
// 如果在路由模式下指定了进程,则所有路由规则(包括 global/bypass_cn)仅对这些进程生效
// 其他进程将直接直连,不走代理
const hasRoutingProcessScope = isRoutingMode && hasProcessScope
// DNS 配置解析
const dnsMode = dnsOptions?.mode || 'secure'
const dnsPrimary = String(dnsOptions?.primary || 'https://dns.google/dns-query').trim()
const dnsSecondary = String(dnsOptions?.secondary || 'https://1.1.1.1/dns-query').trim()
const dnsPrimary = String(dnsOptions?.primary || DEFAULT_DNS_PRIMARY).trim()
const dnsSecondary = String(dnsOptions?.secondary || DEFAULT_DNS_SECONDARY).trim()
const dnsBootstrap = String(dnsOptions?.bootstrap || '').trim()
const proxyBypassList = String(dnsOptions?.proxyBypassList || '').trim()
const tunInterfaceName = String(dnsOptions?.tunInterfaceName || pkg.productName).trim() || pkg.productName
const useSecureDns = dnsMode === 'secure'
// 判断是否禁用 TUN:
// 1. 用户显式禁用
// 2. 加速模式选择了 system_proxy
const disableTun = !!dnsOptions?.disableTun || accelNetworkMode === 'system_proxy'
// DNS 地址策略:
// Windows TUN + direct IPv6 经常会导致 "An invalid argument was supplied" 错误
// 因此在 TUN 模式下,强制 DNS 返回 IPv4 地址以提高连接稳定性
const dnsAddressStrategy = disableTun ? DNS_ADDRESS_STRATEGY : 'ipv4_only'
// 决定是否默认使用远端 DNS:
// 在以下场景下,为了防止 DNS 污染,必须将远端加密 DNS 设为默认:
// 1. 路由模式无进程限定(全局代理):所有流量都走代理,DNS 也应走代理
// 2. TUN 模式 + 有进程限制:
// Windows DNS 查询通常由 svchost.exe 发出,单纯的 process_name 规则无法匹配到游戏进程
// 因此必须让所有 DNS 默认走远端,否则游戏域名的解析可能会被污染
const useRemoteDnsAsDefault = useSecureDns && (
(isRoutingMode && !hasRoutingProcessScope) ||
(hasProcessScope && !disableTun)
)
// 本地代理配置(用于让其他设备连接)
const localProxyNode = dnsOptions?.localProxyNode
const localProxyStrictNode = !!dnsOptions?.localProxyStrictNode
const sessionTuning = resolveSessionTuning(dnsOptions?.sessionTuning)
// 网络微调参数解析
const sessionTuning = resolveSessionTuning(
disableTun
? { ...dnsOptions?.sessionTuning, enabled: false } as SessionNetworkTuningOptions
: dnsOptions?.sessionTuning
)
// 基础配置结构
// ---------------------------------------------------------------------------
// 2. 构建基础配置结构 (Log, DNS, Inbounds, Outbounds, Route)
// ---------------------------------------------------------------------------
const config: SingboxConfig = {
log: {
level: 'info',
@@ -48,49 +116,48 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
},
dns: {
servers: [
// 远端加密 DNS 服务器配置
...(useSecureDns ? [
// 在安全模式下,通过代理使用加密 DNS 以减少 DNS 污染风险
{ tag: 'remote-primary', address: dnsPrimary, detour: 'proxy', strategy: 'ipv4_only' },
{ tag: 'remote-secondary', address: dnsSecondary, detour: 'proxy', strategy: 'ipv4_only' },
{ tag: 'remote-primary', address: dnsPrimary, detour: 'proxy', strategy: dnsAddressStrategy },
{ tag: 'remote-secondary', address: dnsSecondary, detour: 'proxy', strategy: dnsAddressStrategy },
// Bootstrap DNS:用于解析远端 DNS 服务器本身的域名(如果它是域名格式)
...(dnsBootstrap ? [{ tag: 'bootstrap', address: dnsBootstrap, detour: 'direct', strategy: dnsAddressStrategy }] : []),
] : []),
{ tag: 'local', address: 'local', detour: 'direct', strategy: 'ipv4_only' },
// 本地 DNS 服务器(通常是系统默认 DNS)
{ tag: 'local', address: 'local', detour: 'direct', strategy: dnsAddressStrategy },
// 阻断 DNS(用于屏蔽广告等,目前未使用)
{ tag: 'block', address: 'rcode://success' }
],
// DNS 路由规则
rules: useSecureDns ? [] : [
// 非安全模式下,所有 DNS 查询直接走本地
{ outbound: 'any', server: 'local' }
],
final: useSecureDns ? 'remote-primary' : 'local'
// 默认 DNS 服务器策略
final: useRemoteDnsAsDefault ? 'remote-primary' : 'local'
},
inbounds: [],
/*
{
type: 'tun', // 虚拟网卡类型
tag: 'tun-in', // 虚拟网卡标签
interface_name: tunInterfaceName, // 虚拟网卡名称
inet4_address: '172.19.0.1/30', // 虚拟网卡 IP 地址
mtu: 1280, // 1280 是 Sing-box 推荐的 MTU 值
auto_route: true, // 自动路由
strict_route: false, // 设置为 true 可以绝对防止泄漏,但 false 兼容性更好
stack: 'system', // 可选 'gvisor', 'system' 或 'mixed'
sniff: true
}
],
*/
inbounds: [], // 后续动态添加
outbounds: [
// 代理出口选择器
{
type: 'selector',
tag: 'proxy',
// 如果开启了 strict 模式,则不允许直连回退
outbounds: localProxyStrictNode ? ['node-out'] : ['node-out', 'direct'],
default: 'node-out'
},
// 直连出口
{
type: 'direct',
tag: 'direct'
},
// 阻断出口
{
type: 'block',
tag: 'block'
},
// DNS 出口(用于劫持 DNS 流量)
{
type: 'dns',
tag: 'dns-out'
@@ -98,39 +165,48 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
],
route: {
rules: [
// 所有的 DNS 流量都转发给 DNS 模块处理
{ protocol: 'dns', outbound: 'dns-out' }
],
auto_detect_interface: true,
// 最终兜底规则:
// 路由模式下,如果是全局类路由且没有进程限制,则默认走代理;否则默认直连
final: isRoutingMode
? ((hasBypassCn || hasGlobal || routingIpCidrs.length === 0) && !hasRoutingProcessScope ? 'proxy' : 'direct')
? (isGlobalLikeRouting && !hasRoutingProcessScope ? 'proxy' : 'direct')
: 'direct'
}
}
// ---------------------------------------------------------------------------
// 3. 配置 Inbounds (入站)
// ---------------------------------------------------------------------------
// 3.1 TUN Inbound 配置
if (!disableTun) {
config.inbounds.push({
type: 'tun',
tag: 'tun-in',
interface_name: tunInterfaceName,
inet4_address: '172.19.0.1/30',
// 关键说明:不配置 inet6_address
// Windows TUN + direct IPv6 会报 "An invalid argument was supplied",
// 导致所有走 direct 出站的 IPv6 连接全部失败。
// 去掉 IPv6 后 TUN 只捕获 IPv4 流量,IPv6 走系统原生网络栈,避免断网。
mtu: sessionTuning.tunMtu,
auto_route: true,
strict_route: sessionTuning.strictRoute,
stack: sessionTuning.tunStack,
sniff: true
sniff: true // 开启流量嗅探,用于域名解析
})
}
const udpRule = buildUdpPreferenceRule(sessionTuning, !disableTun)
if (udpRule) {
config.route.rules.push(udpRule)
}
// 3.2 本地代理 (Local Proxy) 配置
// 允许用户开启一个本地 HTTP/Socks5 端口,供其他程序或局域网设备使用
if (dnsOptions?.localProxy?.enabled) {
const httpPort = dnsOptions.localProxy.port
const socksPort = httpPort + 1
// HTTP Proxy
// HTTP Proxy Inbound
config.inbounds.push({
type: 'http',
tag: 'http-in',
@@ -140,7 +216,7 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
set_system_proxy: false
})
// SOCKS5 Proxy
// SOCKS5 Proxy Inbound
config.inbounds.push({
type: 'socks',
tag: 'socks-in',
@@ -149,7 +225,10 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
sniff: true
})
// 配置本地代理的流量出口
const localProxyOutboundTag = localProxyNode ? 'local-proxy' : 'proxy'
// 如果指定了独立的本地代理节点
if (localProxyNode) {
config.outbounds.push({
type: 'selector',
@@ -160,13 +239,15 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
config.outbounds.push(convertNodeToOutbound(localProxyNode, 'local-node-out'))
}
// Local mixed proxy inbounds should always tunnel through selected local proxy outbound.
// 路由规则:本地代理入站的流量 -> 指定出口
config.route.rules.push({
inbound: ['http-in', 'socks-in'],
outbound: localProxyOutboundTag
})
}
// 3.3 系统代理 (System Proxy) 配置
// 当不使用 TUN 模式时,使用 HTTP 代理作为系统代理
if (dnsOptions?.systemProxy?.enabled) {
const httpPort = dnsOptions.systemProxy.port
@@ -176,34 +257,60 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
listen: '127.0.0.1',
listen_port: httpPort,
sniff: true,
set_system_proxy: false
set_system_proxy: false // 注意:这里仅开启端口,系统代理的设置由外部程序控制
})
// In routing mode, keep geo/ip rule matching behavior. Do not force inbound->proxy.
// 路由逻辑处理:
// 非路由模式(即进程模式)下,系统代理入站的流量也需要遵循进程规则
// 只有匹配了 process_name 的流量才走代理
if (!isRoutingMode) {
config.route.rules.push({
inbound: ['system-http-in'],
process_name: processes,
outbound: 'proxy'
})
}
}
// ---------------------------------------------------------------------------
// 4. 配置路由规则 (Routing Rules)
// ---------------------------------------------------------------------------
// 4.1 自定义直连/分流规则
const customBypassRules = buildCustomBypassDirectRules(proxyBypassList)
if (customBypassRules.length > 0) {
config.route.rules.push(...customBypassRules)
}
// 4.2 UDP 偏好规则
const udpRule = buildUdpPreferenceRule(sessionTuning, !disableTun)
if (udpRule) {
config.route.rules.push(udpRule)
}
// 4.3 DNS Bootstrap 规则
if (useSecureDns) {
const bootstrapDomains = collectBootstrapDomains(node, localProxyNode)
const bootstrapDomains = collectBootstrapDomains(
node,
localProxyNode,
extractDnsAddressHostname(dnsPrimary),
extractDnsAddressHostname(dnsSecondary)
)
if (bootstrapDomains.length > 0) {
// Avoid DNS bootstrap loop: node server domains must be resolved directly first.
// 防止 DNS 循环依赖:节点服务器域名必须通过本地/Bootstrap DNS 解析
config.dns.rules.unshift({
domain: bootstrapDomains,
server: 'local'
server: dnsBootstrap ? 'bootstrap' : 'local'
})
}
}
// 1. 将节点配置转换为 Outbound 格式并添加到配置中
// 4.4 添加主节点 Outbound
const nodeOutbound = convertNodeToOutbound(node, 'node-out', sessionTuning.vlessPacketEncodingOverride)
config.outbounds.push(nodeOutbound)
// 2. 配置 DNS 规则:如果开启了安全 DNS 且定义了进程,则强制该进程使用远端 DNS
// 4.5 进程级 DNS 规则
// 如果开启了安全 DNS 且定义了进程,则强制该进程的 DNS 查询使用远端 DNS
if (hasProcessScope && useSecureDns && (!isRoutingMode || hasRoutingProcessScope)) {
config.dns.rules.unshift({
process_name: processes,
@@ -211,22 +318,33 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
})
}
// 3. 配置路由规则
// 4.6 核心路由逻辑
if (isRoutingMode) {
// === 路由模式 ===
if (hasBypassCn) {
// -- 绕过大陆模式 (Bypass CN) --
// 私有 IP 直连
const privateRule: Record<string, any> = {
ip_is_private: true,
outbound: 'direct'
}
if (hasRoutingProcessScope) privateRule.process_name = processes
if (hasRoutingProcessScope) {
privateRule.process_name = processes
}
config.route.rules.push(privateRule)
// 下载并配置 CN IP 规则集
ensureRemoteRuleSet(config, {
tag: GEOIP_CN_RULE_SET_TAG,
type: 'remote',
format: 'binary',
url: LOYALSOLDIER_GEOIP_CN_SRS_URL,
download_detour: 'proxy'
download_detour: 'proxy' // 规则集下载走代理
})
// 下载并配置 CN 域名规则集
ensureRemoteRuleSet(config, {
tag: GEOSITE_CN_RULE_SET_TAG,
type: 'remote',
@@ -234,32 +352,52 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
url: SAGERNET_GEOSITE_CN_SRS_URL,
download_detour: 'proxy'
})
// CN IP 直连规则
const geoipRule: Record<string, any> = {
rule_set: [GEOIP_CN_RULE_SET_TAG],
outbound: 'direct'
}
if (hasRoutingProcessScope) geoipRule.process_name = processes
if (hasRoutingProcessScope) {
geoipRule.process_name = processes
}
config.route.rules.push(geoipRule)
// CN 域名直连规则
const geositeRule: Record<string, any> = {
rule_set: [GEOSITE_CN_RULE_SET_TAG],
outbound: 'direct'
}
if (hasRoutingProcessScope) geositeRule.process_name = processes
if (hasRoutingProcessScope) {
geositeRule.process_name = processes
}
config.route.rules.push(geositeRule)
// 如果有进程限制,则剩下的流量中,只有该进程走代理
// (全局 final 默认为 direct)
if (hasRoutingProcessScope) {
config.route.rules.push({
process_name: processes,
outbound: 'proxy'
})
}
// 如果没有进程限制,final 默认为 proxy (在 2. 构建基础配置结构 中设置)
} else if (hasGlobal) {
// -- 全局模式 (Global) --
if (hasRoutingProcessScope) {
// 仅指定进程走代理
config.route.rules.push({
process_name: processes,
outbound: 'proxy'
})
}
// 如果没有进程限制,final 默认为 proxy
} else if (routingIpCidrs.length > 0) {
// -- 指定 IP CIDR 代理模式 --
const rule: Record<string, any> = {
ip_cidr: routingIpCidrs,
outbound: 'proxy'
@@ -270,7 +408,8 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
config.route.rules.push(rule)
}
} else {
// 进程模式:仅指定的游戏进程走代理,其余默认直连
// === 进程模式 ===
// 仅指定的游戏进程走代理,其余默认直连
if (hasProcessScope) {
config.route.rules.push({
process_name: processes,
@@ -284,9 +423,12 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
/**
* 将通用的 NodeConfig 转换为 Sing-box 的 Outbound 结构
* 支持 Shadowsocks, VMess, VLESS, Trojan, Socks, HTTP 等协议
*
* @param node 原始节点配置
* @returns Sing-box outbound 对象
* @param node 原始节点配置对象
* @param tag Outbound 的标签,默认为 'node-out'
* @param vlessPacketEncodingOverride VLESS 协议的包编码覆盖设置 (xudp/off)
* @returns Sing-box outbound 配置对象
*/
function convertNodeToOutbound(
node: NodeConfig,
@@ -328,6 +470,7 @@ function convertNodeToOutbound(
type: 'vless',
uuid: node.uuid,
flow: node.flow,
// VLESS XUDP 支持
packet_encoding: vlessPacketEncodingOverride === 'xudp' ? 'xudp' : node.packet_encoding,
transport,
tls
@@ -338,6 +481,7 @@ function convertNodeToOutbound(
type: 'trojan',
password: node.password,
transport,
// Trojan 必须开启 TLS
tls: tls || {
enabled: true,
server_name: resolveTlsServerName(node),
@@ -369,10 +513,15 @@ function convertNodeToOutbound(
}
/**
* 构建传输层配置(WebSocket, gRPC 等)
* 构建传输层配置 (Transport)
* 处理 WebSocket, gRPC, HTTP 等传输方式
*
* @param node 节点配置
* @returns Sing-box transport 配置对象或 undefined
*/
function buildTransport(node: NodeConfig): any {
const network = String(node.network || 'tcp').trim().toLowerCase()
// TCP 是默认传输方式,无需特殊配置
if (!network || network === 'tcp') return undefined
if (network === 'ws') {
@@ -390,6 +539,7 @@ function buildTransport(node: NodeConfig): any {
}
}
// 其他传输方式直接透传
return {
type: network,
path: node.path,
@@ -399,15 +549,21 @@ function buildTransport(node: NodeConfig): any {
/**
* 构建 TLS 配置
* 处理标准 TLS、Reality、uTLS 指纹等
*
* @param node 节点配置
* @returns Sing-box tls 配置对象或 undefined
*/
function buildTls(node: NodeConfig): any {
if (!node.tls?.enabled) return undefined
// ALPN (Application-Layer Protocol Negotiation) 配置
const alpn = (node.alpn || '')
.split(',')
.map(v => v.trim())
.filter(Boolean)
// uTLS 指纹配置
const fingerprint = node.fingerprint || node.tls.utls?.fingerprint
const tls: any = {
@@ -421,7 +577,7 @@ function buildTls(node: NodeConfig): any {
} : undefined
}
// 处理 Reality 配置
// 处理 Reality 配置 (新一代抗封锁协议)
if (node.tls.reality?.enabled) {
tls.reality = {
enabled: true,
@@ -433,6 +589,11 @@ function buildTls(node: NodeConfig): any {
return tls
}
/**
* 解析 TLS Server Name (SNI)
* 优先顺序:node.tls.server_name > node.host > node.server
* 且必须不是 IP 地址
*/
function resolveTlsServerName(node: NodeConfig): string | undefined {
const candidates = [
node.tls?.server_name,
@@ -448,6 +609,9 @@ function resolveTlsServerName(node: NodeConfig): string | undefined {
return undefined
}
/**
* 判断字符串是否为 IP 地址 (IPv4 或 IPv6)
*/
function isIpLiteral(value: string): boolean {
const v = String(value ?? '').trim()
if (!v) return false
@@ -461,19 +625,45 @@ function isIpLiteral(value: string): boolean {
})
}
function collectBootstrapDomains(...nodes: Array<NodeConfig | undefined>): string[] {
/**
* 收集需要 Bootstrap 解析的域名
* 包括节点服务器域名、本地代理节点域名等
* 这些域名需要通过本地 DNS 解析,以避免循环依赖
*/
function collectBootstrapDomains(...targets: Array<NodeConfig | string | undefined>): string[] {
const set = new Set<string>()
for (const node of nodes) {
const domain = String(node?.server || '').trim().toLowerCase()
for (const target of targets) {
const domain = typeof target === 'string'
? String(target || '').trim().toLowerCase()
: String(target?.server || '').trim().toLowerCase()
if (!domain || isIpLiteral(domain)) continue
set.add(domain)
}
return Array.from(set)
}
/**
* 从 DNS 地址中提取主机名
* 例如:https://dns.google/dns-query -> dns.google
*/
function extractDnsAddressHostname(address: string): string {
const raw = String(address || '').trim()
if (!raw) return ''
try {
const url = new URL(raw)
const host = String(url.hostname || '').trim().toLowerCase()
return host && !isIpLiteral(host) ? host : ''
} catch {
const hostOnly = raw.replace(/^\[|\]$/g, '').trim().toLowerCase()
return hostOnly && !isIpLiteral(hostOnly) ? hostOnly : ''
}
}
/**
* 标准化进程名称列表
* 去除路径只保留文件名,并添加小写版本以提高兼容性
* 例如: ["C:\\Program Files\\Game\\game.exe"] -> ["game.exe", "game"]
*/
function normalizeProcessNames(processes: string[]): string[] {
const items = processes
@@ -503,6 +693,10 @@ function toProcessBaseName(processName: string): string {
return base.trim()
}
/**
* 扩展进程名别名
* 例如输入 "chrome",自动扩展为 ["chrome", "chrome.exe"]
*/
function expandProcessAliases(processName: string): string[] {
const value = String(processName || '').trim()
if (!value) return []
@@ -516,6 +710,10 @@ function expandProcessAliases(processName: string): string[] {
return Array.from(aliases)
}
/**
* 确保配置中包含指定的远程规则集 (Rule Set)
* 避免重复添加
*/
function ensureRemoteRuleSet(config: SingboxConfig, ruleSet: Record<string, any>) {
const route = config.route as any
if (!Array.isArray(route.rule_set)) {
@@ -527,6 +725,10 @@ function ensureRemoteRuleSet(config: SingboxConfig, ruleSet: Record<string, any>
route.rule_set.push(ruleSet)
}
/**
* 标准化路由 IP CIDR 列表
* 过滤掉特殊关键字,确保格式正确
*/
function normalizeRoutingIpCidrs(rules: string[]): string[] {
const set = new Set<string>()
for (const raw of rules) {
@@ -546,6 +748,135 @@ function normalizeRoutingIpCidrs(rules: string[]): string[] {
return Array.from(set)
}
/**
* 将路由规则字符串分割为数组
* 支持换行、逗号、分号分隔
*/
function tokenizeRoutingRules(rules: unknown): string[] {
if (!Array.isArray(rules)) return []
return rules
.flatMap((item) => String(item ?? '').split(/[\r\n,;,;]+/g))
.map(v => String(v || '').trim())
.filter(Boolean)
}
/**
* 构建自定义分流/直连规则
* 解析用户输入的 Bypass 列表,生成 Sing-box 路由规则对象
*/
function buildCustomBypassDirectRules(raw: string): Array<Record<string, any>> {
const entries = parseBypassEntries(raw)
if (entries.length === 0) return []
const ipCidrs = new Set<string>()
const domains = new Set<string>()
const domainSuffixes = new Set<string>()
let hasPrivateRange = false
for (const token of entries) {
const lower = token.toLowerCase()
if (lower === '<local>') continue
if (lower === 'localhost') {
domains.add('localhost')
continue
}
if (lower === 'local') {
domainSuffixes.add('local')
continue
}
if (lower === 'lan') {
domainSuffixes.add('lan')
continue
}
const wildcardCidr = wildcardIpv4ToCidr(token)
if (wildcardCidr) {
ipCidrs.add(wildcardCidr)
continue
}
if (isIpLiteral(token)) {
ipCidrs.add(token.includes(':') ? `${token}/128` : `${token}/32`)
continue
}
if (isValidCidr(token)) {
ipCidrs.add(token)
continue
}
if (lower === 'private' || lower === 'private-ip' || lower === 'private_ip') {
hasPrivateRange = true
continue
}
// *.example.com -> domain_suffix
if (/^\*\.[a-z0-9.-]+$/i.test(token)) {
domainSuffixes.add(token.slice(2).toLowerCase())
continue
}
// example.com -> domain
if (/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(token)) {
domains.add(token.toLowerCase())
continue
}
}
const rules: Array<Record<string, any>> = []
if (hasPrivateRange) {
rules.push({ ip_is_private: true, outbound: 'direct' })
}
if (ipCidrs.size > 0) {
rules.push({ ip_cidr: Array.from(ipCidrs), outbound: 'direct' })
}
if (domains.size > 0) {
rules.push({ domain: Array.from(domains), outbound: 'direct' })
}
if (domainSuffixes.size > 0) {
rules.push({ domain_suffix: Array.from(domainSuffixes), outbound: 'direct' })
}
return rules
}
/**
* 解析 Bypass 条目字符串
*/
function parseBypassEntries(raw: string): string[] {
return String(raw || '')
.split(/[\r\n,;]+/g)
.map(v => String(v).trim())
.filter(Boolean)
}
/**
* 将通配符 IP (如 192.168.*.*) 转换为 CIDR 格式
*/
function wildcardIpv4ToCidr(raw: string): string | null {
const token = String(raw || '').trim()
if (!token.includes('*')) return null
const parts = token.split('.')
if (parts.length < 2 || parts.length > 4) return null
let wildcardFrom = -1
const normalized = [0, 0, 0, 0]
const fullParts = [...parts]
while (fullParts.length < 4) fullParts.push('*')
for (let i = 0; i < 4; i += 1) {
const p = String(fullParts[i] || '').trim()
if (p === '*') {
if (wildcardFrom === -1) wildcardFrom = i
normalized[i] = 0
continue
}
if (wildcardFrom !== -1) return null
if (!/^\d{1,3}$/.test(p)) return null
const n = Number(p)
if (n < 0 || n > 255) return null
normalized[i] = n
}
if (wildcardFrom < 0) return null
const prefix = wildcardFrom * 8
return `${normalized.join('.')}/${prefix}`
}
/**
* 验证 CIDR 格式是否有效 (e.g. 192.168.1.0/24)
*/
function isValidCidr(value: string): boolean {
const s = String(value || '').trim()
if (!s.includes('/')) return false
@@ -561,6 +892,10 @@ function isValidCidr(value: string): boolean {
return bits >= 0 && bits <= 32
}
/**
* 解析和规范化网络调优参数 (SessionNetworkTuning)
* 处理 MTU、栈类型、UDP 模式等
*/
function resolveSessionTuning(tuning?: SessionNetworkTuningOptions): SessionNetworkTuningOptions {
if (!tuning?.enabled) {
return {
@@ -588,6 +923,9 @@ function resolveSessionTuning(tuning?: SessionNetworkTuningOptions): SessionNetw
}
}
/**
* 规范化 MTU 值,确保在合理范围内 (1200-1500)
*/
function normalizeMtu(value: number): number {
const fallback = 1280
const n = Number(value)
@@ -595,6 +933,10 @@ function normalizeMtu(value: number): number {
return Math.min(1500, Math.max(1200, Math.floor(n)))
}
/**
* 构建 UDP 流量偏好规则
* 根据 tuning.udpMode 决定 UDP 流量是优先走代理还是直连
*/
function buildUdpPreferenceRule(
tuning: SessionNetworkTuningOptions,
hasTunInbound: boolean
@@ -605,6 +947,7 @@ function buildUdpPreferenceRule(
protocol: 'udp',
outbound: tuning.udpMode === 'prefer_udp' ? 'proxy' : 'direct'
}
// 如果开启了 TUN,则限定该规则只对 TUN 入站流量生效
if (hasTunInbound) {
rule.inbound = ['tun-in']
}
+51 -16
View File
@@ -50,7 +50,7 @@
<h1 class="text-lg font-bold leading-tight text-on-surface ">{{ game.name }}</h1>
<div class="flex items-center gap-2 text-xs text-on-surface-muted">
<span
class="bg-surface-overlay px-1.5 py-0.5 rounded border border-border data-[active=true]:text-primary data-[active=true]:border-primary/20"
class="bg-surface-overlay px-1.5 py-0.5 rounded border border-border data-[active=true]:text-primary data-[active=true]:border-primary/20 max-w-[400px] inline-block truncate"
:data-active="isRunning">
{{ categoryLabel }}
</span>
@@ -87,7 +87,8 @@
</button>
</div>
<button @click="showSessionTuningPanel = !showSessionTuningPanel"
class="px-3 py-1 text-[11px] font-bold rounded-lg border border-border/60 bg-surface-overlay/60 text-on-surface-muted hover:text-on-surface transition">
:disabled="!isTunNetworkMode"
class="px-3 py-1 text-[11px] font-bold rounded-lg border border-border/60 bg-surface-overlay/60 text-on-surface-muted hover:text-on-surface transition disabled:opacity-50 disabled:cursor-not-allowed">
{{ $t('dashboard.session_tuning') }}
</button>
</div>
@@ -106,12 +107,13 @@
</button>
</div>
<p class="text-xs text-on-surface-muted mb-3">{{ $t('dashboard.current_game_tuning_hint') }}</p>
<p v-if="!isTunNetworkMode" class="text-xs text-warning mb-3">{{ $t('dashboard.session_tuning_tun_only') }}</p>
<div class="flex items-center justify-between mb-3 rounded-lg border border-border/50 px-3 py-2">
<span class="text-xs text-on-surface-muted">{{ $t('common.enabled') }}</span>
<n-switch v-model:value="currentGameSessionTuning.enabled" size="small" />
<n-switch v-model:value="currentGameSessionTuning.enabled" size="small" :disabled="!isTunNetworkMode" />
</div>
<div class="flex bg-surface-overlay/50 p-1 rounded-xl border border-border/30 mb-3"
:class="{ 'opacity-50 pointer-events-none': !currentGameSessionTuning.enabled }">
:class="{ 'opacity-50 pointer-events-none': !currentGameSessionTuning.enabled || !isTunNetworkMode }">
<button @click="applyProfilePreset('stable')"
class="flex-1 py-2 text-xs rounded-lg transition-all font-bold" :class="currentGameSessionTuning.profile === 'stable'
? 'bg-surface shadow-sm text-primary'
@@ -126,7 +128,7 @@
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3"
:class="{ 'opacity-50 pointer-events-none': !currentGameSessionTuning.enabled }">
:class="{ 'opacity-50 pointer-events-none': !currentGameSessionTuning.enabled || !isTunNetworkMode }">
<n-select v-model:value="currentGameSessionTuning.udpMode" :options="udpModeOptions" size="small" />
<n-input-number v-model:value="currentGameSessionTuning.tunMtu" :min="1200" :max="1500" size="small" />
<n-select v-model:value="currentGameSessionTuning.tunStack" :options="tunStackOptions" size="small" />
@@ -194,19 +196,20 @@
<!-- Outer Glow Ring -->
<div
class="absolute inset-0 rounded-full bg-gradient-to-t from-primary/0 via-primary/30 to-primary/0 md:scale-150 animate-spin-slow opacity-0 group-hover:opacity-100 transition-opacity duration-700 pointer-events-none"
:class="isRunning ? 'via-error/30' : 'via-primary/30'">
:class="isRunning ? 'via-error/30' : (!isCoreInstalled ? 'via-on-surface-muted/10' : 'via-primary/30')">
</div>
<!-- Pulse Effect -->
<div v-if="!isRunning"
<div v-if="!isRunning && isCoreInstalled"
class="absolute inset-0 rounded-full bg-primary/20 animate-ping opacity-20 duration-[3s]">
</div>
<button @click="toggleAccelerator" :disabled="isActionPending"
<button @click="toggleAccelerator"
:disabled="isActionPending || (!isRunning && !isCoreInstalled)"
class="relative w-40 h-40 md:w-48 md:h-48 rounded-full flex items-center justify-center group outline-none transition-all duration-500 ease-out hover:scale-105 active:scale-95 z-10"
:class="[
isRunning ? 'shadow-[0_0_60px_-10px_rgba(var(--rgb-error),0.3)]' : 'shadow-[0_0_60px_-10px_rgba(var(--rgb-primary),0.3)]',
isActionPending ? 'opacity-80 cursor-not-allowed pointer-events-none' : ''
isRunning ? 'shadow-[0_0_60px_-10px_rgba(var(--rgb-error),0.3)]' : (!isCoreInstalled ? 'shadow-[0_0_30px_-10px_rgba(0,0,0,0.1)]' : 'shadow-[0_0_60px_-10px_rgba(var(--rgb-primary),0.3)]'),
(isActionPending || (!isRunning && !isCoreInstalled)) ? 'opacity-80 cursor-not-allowed pointer-events-none' : ''
]">
<!-- Button Background -->
@@ -214,7 +217,9 @@
class="absolute inset-2 rounded-full border-4 backdrop-blur-md transition-all duration-500 overflow-hidden"
:class="isRunning
? 'bg-error/5 border-error/20 group-hover:bg-error/10 group-hover:border-error/40'
: 'bg-primary/5 border-primary/20 group-hover:bg-primary/10 group-hover:border-primary/40'">
: (!isCoreInstalled
? 'bg-on-surface-muted/5 border-on-surface-muted/20'
: 'bg-primary/5 border-primary/20 group-hover:bg-primary/10 group-hover:border-primary/40')">
<!-- Shiny reflection -->
<div
class="absolute inset-0 bg-gradient-to-br from-white/40 to-transparent opacity-0 group-hover:opacity-30 transition-opacity duration-500">
@@ -227,11 +232,12 @@
class="text-5xl md:text-6xl transition-all duration-300 transform group-hover:scale-110"
:class="isActionPending
? 'i-carbon-circle-dash animate-spin text-primary'
: (isRunning ? 'i-carbon-stop-filled text-error drop-shadow-[0_2px_10px_rgba(var(--rgb-error),0.4)]' : 'i-carbon-play-filled-alt text-primary drop-shadow-[0_2px_10px_rgba(var(--rgb-primary),0.4)]')">
: (isRunning ? 'i-carbon-stop-filled text-error drop-shadow-[0_2px_10px_rgba(var(--rgb-error),0.4)]' : (!isCoreInstalled ? 'i-carbon-cloud-download text-on-surface-muted' : 'i-carbon-play-filled-alt text-primary drop-shadow-[0_2px_10px_rgba(var(--rgb-primary),0.4)]'))">
</div>
<span class=" text-xs font-black uppercase tracking-[0.25em] transition-colors"
:class="isRunning ? 'text-error' : 'text-primary'">
{{ actionLabel }}
:class="isRunning ? 'text-error' : (!isCoreInstalled ? 'text-on-surface-muted' : 'text-primary')">
{{ (!isRunning && !isCoreInstalled) ? ($t('singbox_installer.preparing') || '环境准备中') :
actionLabel }}
</span>
</div>
</button>
@@ -241,7 +247,8 @@
<div class="mt-8 flex flex-col items-center gap-3">
<p class="text-sm font-medium max-w-xs text-center leading-relaxed transition-colors"
:class="isRunning ? 'text-success' : 'text-on-surface-muted'">
{{ isRunning ? $t('dashboard.active_protection') : $t('dashboard.ready_to_boost') }}
{{ isRunning ? $t('dashboard.active_protection') : (!isCoreInstalled ?
$t('singbox_installer.preparing') : $t('dashboard.ready_to_boost')) }}
</p>
<!-- Duration Badge -->
@@ -337,7 +344,7 @@ import { storeToRefs } from 'pinia'
import { useMessage, NInputNumber, NSelect, NSwitch } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import i18n from '@/i18n'
import { electronApi } from '@/api'
import { electronApi, singboxApi } from '@/api'
// Stores
const gameStore = useGameStore()
@@ -351,6 +358,7 @@ const { t } = useI18n()
// State
const game = computed(() => gameStore.currentGame)
const isRunning = computed(() => game.value?.status === 'accelerating')
const isCoreInstalled = ref(false)
const isSwitchingNode = ref(false)
const isModeSwitching = ref(false)
const isActionPending = computed(() => isSwitchingNode.value || isModeSwitching.value || gameStore.operationState !== 'idle')
@@ -384,8 +392,10 @@ const vlessEncodingOptions = computed(() => ([
{ label: String(t('settings.vless_packet_encoding_off')), value: 'off' },
{ label: 'xudp', value: 'xudp' }
]))
const isTunNetworkMode = computed(() => settingsStore.accelNetworkMode === 'tun')
function applyProfilePreset(profile: 'stable' | 'aggressive') {
if (!isTunNetworkMode.value) return
const gameId = String(game.value?.id || '').trim()
if (!gameId) return
gameStore.applyGameSessionNetworkProfilePreset(gameId, profile, {
@@ -420,6 +430,7 @@ const currentNodeIsVless = computed(() => {
})
function resetCurrentGameSessionTuning() {
if (!isTunNetworkMode.value) return
const gameId = String(game.value?.id || '').trim()
if (!gameId) return
gameStore.resetGameSessionNetworkTuning(gameId)
@@ -656,6 +667,14 @@ function onSingboxEvent(name: string, data: any) {
}
}
function onInstallerStatusChange(d: any) {
if (d && (d.phase === 'ready' || d.phase === 'completed')) {
isCoreInstalled.value = true
} else if (d && (d.phase === 'missing' || d.phase === 'resolving' || d.phase === 'downloading' || d.phase === 'extracting' || d.phase === 'failed')) {
isCoreInstalled.value = false
}
}
onMounted(() => {
nodeStore.loadNodes()
if (isRunning.value) {
@@ -667,6 +686,12 @@ onMounted(() => {
electronApi.on('singbox-log', (d: any) => onSingboxEvent('singbox-log', d))
electronApi.on('singbox-error', (d: any) => onSingboxEvent('singbox-error', d))
electronApi.on('singbox-status', (d: any) => onSingboxEvent('singbox-status', d))
electronApi.on('singbox-installer-status', onInstallerStatusChange)
singboxApi.getInstallInfo().then((info) => {
isCoreInstalled.value = info.exists
}).catch(() => {
isCoreInstalled.value = false
})
} catch (e) {
console.warn('Electron API events not available', e)
}
@@ -699,6 +724,15 @@ watch(isRunning, (running) => {
}
}, { immediate: true })
watch(
() => settingsStore.accelNetworkMode,
(mode) => {
if (mode !== 'tun') {
showSessionTuningPanel.value = false
}
}
)
onUnmounted(() => {
pauseHintRotation()
pauseSampling()
@@ -718,6 +752,7 @@ watch(
async (next, prev) => {
if (next === prev) return
if (applyingSessionTuning.value) return
if (!isTunNetworkMode.value) return
if (!isRunning.value) return
if (gameStore.operationState !== 'idle') return
+15 -7
View File
@@ -4,7 +4,10 @@
<div class="flex flex-col gap-4 mb-6">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div class="flex items-center gap-4">
<h1 class="text-xl md:text-2xl font-bold text-on-surface ">{{ $t('games.library') }}</h1>
<h1 class="text-xl md:text-2xl font-bold text-on-surface flex items-center gap-3">
{{ $t('games.library') }}
<span class="text-sm font-normal text-on-surface-muted bg-surface-overlay px-2 py-0.5 rounded-full border border-border/50 font-mono">{{ gameStore.gameLibrary.length }}</span>
</h1>
<div class="bg-surface rounded-lg p-1 flex border border-border">
<button @click="viewMode = 'grid'" class="p-1.5 md:p-2 rounded transition"
:class="viewMode === 'grid' ? 'bg-primary text-on-primary' : 'text-on-surface-muted hover:text-on-surface'">
@@ -24,6 +27,11 @@
<div class="i-carbon-search absolute left-3 top-1/2 -translate-y-1/2 text-on-surface-muted text-sm"></div>
</div>
<div v-if="isScanning" class="text-xs text-on-surface-muted italic truncate absolute top-0 max-w-sm"
:title="scanProgressText">
{{ scanProgressText }}
</div>
<n-button round @click="scanGames" :loading="isScanning" class="shadow-sm">
<template #icon>
<div class="i-carbon-radar"></div>
@@ -264,8 +272,7 @@
</div>
</div>
<AdvancedConfigEditor v-model="showEditModal" :editing-game="editingGame" @save="handleSaveGame" />
<GameEditModal v-model="showEditModal" :editing-game="editingGame" @save="handleSaveGame" />
<CategoryManager v-model="showCategoryManager" />
</div>
</template>
@@ -278,7 +285,8 @@ import { useCategoryStore } from '@/stores/categories'
import { useI18n } from 'vue-i18n'
import { useMessage, useDialog } from 'naive-ui'
import type { LocalScanGame } from '@/types'
import AdvancedConfigEditor from '@/components/library/AdvancedConfigEditor.vue'
import { PLATFORMS } from '@/constants'
import GameEditModal from '@/components/library/GameEditModal.vue'
import CategoryManager from '@/components/library/CategoryManager.vue'
import { useGameScanner } from '@/composables/useGameScanner'
@@ -322,7 +330,7 @@ const categoryStore = useCategoryStore()
const viewMode = ref<'grid' | 'list'>('grid')
const searchQuery = ref('')
const activeCategory = ref('all')
const { isScanning, scanGames } = useGameScanner()
const { isScanning, scanProgressText, scanGames } = useGameScanner()
const runningGames = computed(() => gameStore.runningGames)
const acceleratingGame = computed(() => gameStore.getAcceleratingGame())
const isActionPending = computed(() => gameStore.operationState !== 'idle')
@@ -330,7 +338,7 @@ const isActionPending = computed(() => gameStore.operationState !== 'idle')
const showEditModal = ref(false)
const showCategoryManager = ref(false)
const editingGame = ref<Game | null>(null)
const platformTags = new Set<LocalScanGame['source']>(['Steam', 'Microsoft', 'Epic', 'EA'])
const platformTags = new Set<LocalScanGame['source']>(PLATFORMS)
const maxTagDisplayWidth = 200
const tagMaxWidth = 120
const tagTextFont = '500 10px sans-serif'
@@ -401,7 +409,7 @@ function getVisibleTags(game: Game): string[] {
return visible
}
/** */
function getHiddenTagCount(game: Game): number {
return Math.max(getDisplayTags(game).length - getVisibleTags(game).length, 0)
}
+74
View File
@@ -23,15 +23,52 @@
<!-- About Tab -->
<AboutSettings v-else-if="activeTab === 'about'" />
</Transition>
<section v-if="activeTab === 'general'"
class="mt-8 rounded-2xl border border-error/40 bg-error/8 p-5 backdrop-blur-sm">
<h3 class="text-lg font-bold flex items-center gap-2 text-error">
<div class="i-material-symbols-warning-outline-rounded"></div>
{{ $t('settings.danger_zone') }}
</h3>
<p class="mt-2 text-sm text-on-surface-muted">
{{ $t('settings.reset_app_desc') }}
</p>
<div class="mt-4">
<n-button type="error" strong @click="openResetDialog">
{{ $t('settings.reset_app') }}
</n-button>
</div>
</section>
</div>
</main>
</div>
<n-modal :show="showResetDialog" @update:show="onResetDialogUpdate" preset="card" class="w-[520px] max-w-[95vw]"
:title="$t('settings.reset_app_confirm_title')" :mask-closable="false">
<div class="space-y-3">
<p class="text-sm text-on-surface-muted">
{{ $t('settings.reset_app_confirm_desc', { keyword: resetKeyword }) }}
</p>
<n-input v-model:value="resetConfirmText"
:placeholder="$t('settings.reset_app_confirm_placeholder', { keyword: resetKeyword })" />
</div>
<template #footer>
<div class="flex justify-end gap-2">
<n-button @click="closeResetDialog">{{ $t('common.cancel') }}</n-button>
<n-button type="error" :loading="resetting" @click="confirmReset">
{{ $t('settings.reset_app_confirm_button') }}
</n-button>
</div>
</template>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage } from 'naive-ui'
import { useAppUpdater } from '@/composables/useAppUpdater'
import { appApi } from '@/api'
import SettingsSidebar from '@/components/settings/SettingsSidebar.vue'
import GeneralSettings from '@/components/settings/GeneralSettings.vue'
import NetworkSettings from '@/components/settings/NetworkSettings.vue'
@@ -40,8 +77,45 @@ import AboutSettings from '@/components/settings/AboutSettings.vue'
const activeTab = ref('general')
const { getVersion } = useAppUpdater()
const { t } = useI18n()
const message = useMessage()
const resetKeyword = 'RESET'
const showResetDialog = ref(false)
const resetConfirmText = ref('')
const resetting = ref(false)
onMounted(async () => {
await getVersion()
})
function openResetDialog() {
resetConfirmText.value = ''
showResetDialog.value = true
}
function closeResetDialog() {
if (resetting.value) return
showResetDialog.value = false
}
function onResetDialogUpdate(v: boolean) {
if (!v && resetting.value) return
showResetDialog.value = v
}
async function confirmReset() {
if (resetConfirmText.value.trim().toUpperCase() !== resetKeyword) {
message.error(t('settings.reset_app_keyword_mismatch', { keyword: resetKeyword }))
return
}
resetting.value = true
try {
await appApi.reset()
} catch (e) {
message.error(t('settings.reset_app_failed'))
} finally {
resetting.value = false
}
}
</script>
+3 -3
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { CategoryManager, type Category } from '../../electron/categories/manager'
import { CategoryService, type Category } from '../../electron/services/category'
vi.mock('electron', () => ({
ipcMain: {
@@ -8,7 +8,7 @@ vi.mock('electron', () => ({
}))
describe('CategoryManager', () => {
let manager: CategoryManager
let manager: CategoryService
let categories: Category[]
let db: any
@@ -35,7 +35,7 @@ describe('CategoryManager', () => {
})
}
manager = new CategoryManager(db)
manager = new CategoryService(db)
await manager.getAll()
})
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { JsonStore } from '../../electron/config/store';
import { JsonStore } from '../../electron/common/store';
import fs from 'fs-extra';
import path from 'path';
import os from 'os';
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it, vi } from 'vitest'
import {
parsePowerShellProcessJson,
parseWmicProcessCsv,
type ProcessNode
} from '../../electron/services/process'
vi.mock('electron', () => ({
ipcMain: {
handle: vi.fn()
}
}))
function findNodeByPid(nodes: ProcessNode[], pid: number): ProcessNode | null {
const stack = [...nodes]
while (stack.length > 0) {
const node = stack.pop()
if (!node) continue
if (node.pid === pid) return node
if (node.children?.length) {
stack.push(...node.children)
}
}
return null
}
describe('process tree parser', () => {
it('parses PowerShell/CIM json array output', () => {
const json = JSON.stringify([
{
ProcessId: 100,
ParentProcessId: 4,
Name: 'launcher.exe',
ExecutablePath: 'C:\\Games\\Launcher\\launcher.exe'
},
{
ProcessId: 200,
ParentProcessId: 100,
Name: 'game.exe',
ExecutablePath: 'C:\\Games\\Launcher\\game.exe'
},
{
ProcessId: 300,
ParentProcessId: 0,
Name: 'other.exe',
ExecutablePath: 'C:\\Tools\\other.exe'
}
])
const tree = parsePowerShellProcessJson(json)
const rootPids = tree.map((node) => node.pid).sort((a, b) => a - b)
expect(rootPids).toEqual([100, 300])
const launcher = findNodeByPid(tree, 100)
expect(launcher).not.toBeNull()
expect(launcher!.children.map((child) => child.pid)).toEqual([200])
})
it('parses PowerShell/CIM json single-object output', () => {
const json = JSON.stringify({
ProcessId: 501,
ParentProcessId: 1,
Name: 'single.exe',
ExecutablePath: 'C:\\single.exe'
})
const tree = parsePowerShellProcessJson(json)
expect(tree.length).toBe(1)
expect(tree[0]?.pid).toBe(501)
expect(tree[0]?.name).toBe('single.exe')
})
it('parses WMIC csv output with comma in executable path', () => {
const csv = [
'Node,ExecutablePath,Name,ParentProcessId,ProcessId',
'DESKTOP,C:\\Games\\A,B\\launcher.exe,launcher.exe,4,100',
'DESKTOP,C:\\Games\\A,B\\game.exe,game.exe,100,200'
].join('\r\n')
const tree = parseWmicProcessCsv(csv)
const launcher = findNodeByPid(tree, 100)
const game = findNodeByPid(tree, 200)
expect(launcher).not.toBeNull()
expect(game).not.toBeNull()
expect(launcher?.path).toBe('C:\\Games\\A,B\\launcher.exe')
expect(game?.path).toBe('C:\\Games\\A,B\\game.exe')
expect(launcher?.children.some((child) => child.pid === 200)).toBe(true)
})
})
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from 'vitest'
import { findChainProxyChildren, ProxyMonitorService } from '../../electron/services/proxy-monitor'
import type { ProcessNode } from '../../electron/services/process'
vi.mock('electron', () => ({
ipcMain: {
handle: vi.fn()
}
}))
function makeProcessTree(): ProcessNode[] {
return [
{
path: 'C:\\Games\\launcher.exe',
name: 'launcher.exe',
ppid: 4,
pid: 100,
children: [
{
path: 'C:\\Games\\game.exe',
name: 'game.exe',
ppid: 100,
pid: 200,
children: [
{
path: 'C:\\Games\\anticheat.exe',
name: 'anticheat.exe',
ppid: 200,
pid: 201,
children: []
}
]
}
]
},
{
path: 'C:\\Tools\\unrelated.exe',
name: 'unrelated.exe',
ppid: 4,
pid: 300,
children: []
}
]
}
describe('findChainProxyChildren', () => {
it('collects descendants of monitored process recursively', () => {
const monitored = new Set<string>(['launcher.exe'])
const names = findChainProxyChildren(makeProcessTree(), monitored)
expect(names).toEqual(expect.arrayContaining(['game.exe', 'anticheat.exe']))
expect(names).not.toContain('unrelated.exe')
})
})
describe('ProxyMonitorService chain proxy behavior', () => {
it('updates singbox process names when child processes appear', async () => {
const send = vi.fn()
const win = {
webContents: {
send
}
} as any
const processService = {
getProcessTree: vi.fn(async () => makeProcessTree())
} as any
const singboxService = {
updateProcessNames: vi.fn(async () => undefined)
} as any
const service = new ProxyMonitorService(win, processService, singboxService)
service.startMonitoring('game-1', ['launcher.exe'])
await (service as any).checkChainProxy()
expect(singboxService.updateProcessNames).toHaveBeenCalledTimes(2)
const latestArgs = singboxService.updateProcessNames.mock.calls.at(-1)?.[0] as string[]
expect(latestArgs).toEqual(expect.arrayContaining(['launcher.exe', 'game.exe', 'anticheat.exe']))
expect(send).toHaveBeenCalledWith('proxy-monitor:detected', expect.arrayContaining(['game.exe', 'anticheat.exe']))
service.stopMonitoring()
})
})
+86 -2
View File
@@ -85,7 +85,8 @@ describe('generateSingboxConfig', () => {
// Process mode: default final should be 'direct' (implied by not being 'proxy' or explicit final)
// Actually in my impl: final: isRoutingMode ? 'proxy' : 'direct'
expect(config.route.final).toBe('direct')
expect(config.dns.final).toBe('remote-primary')
// Should have rule for game process -> proxy
const gameRule = config.route.rules.find((r: any) => r.process_name && r.process_name.includes('game.exe'))
expect(gameRule).toBeDefined()
@@ -95,7 +96,7 @@ describe('generateSingboxConfig', () => {
it('should configure routing mode correctly', () => {
const routingGame = { ...mockGame, proxyMode: 'routing' as const }
const config = JSON.parse(generateSingboxConfig(routingGame, mockNode))
expect(config.route.final).toBe('direct')
expect(config.dns.final).toBe('remote-primary')
@@ -103,6 +104,15 @@ describe('generateSingboxConfig', () => {
const tunInbound = config.inbounds.find((i: any) => i.tag === 'tun-in')
expect(tunInbound).toBeDefined()
expect(tunInbound.inet4_address).toBeDefined()
expect(tunInbound.inet6_address).toBeUndefined()
})
it('should prefer ipv4-only DNS strategy in tun secure mode', () => {
const config = JSON.parse(generateSingboxConfig(mockGame, mockNode, { mode: 'secure' }))
const remotePrimary = config.dns.servers.find((s: any) => s.tag === 'remote-primary')
const localDns = config.dns.servers.find((s: any) => s.tag === 'local')
expect(remotePrimary?.strategy).toBe('ipv4_only')
expect(localDns?.strategy).toBe('ipv4_only')
})
it('should only proxy configured CIDR rules in routing mode', () => {
@@ -139,6 +149,7 @@ describe('generateSingboxConfig', () => {
expect(Array.isArray(cidrRule.process_name)).toBe(true)
expect(cidrRule.process_name).toContain('chrome.exe')
expect(cidrRule.process_name).toContain('msedge.exe')
expect(config.dns.final).toBe('remote-primary')
})
it('should include exe alias for bare process names in routing mode', () => {
@@ -158,6 +169,25 @@ describe('generateSingboxConfig', () => {
expect(cidrRule.process_name).toContain('chrome.exe')
})
it('should parse comma-separated routing ip rules and keep process scope', () => {
const routingGame = {
...mockGame,
proxyMode: 'routing' as const,
processName: ['chrome.exe'],
routingRules: ['1.1.1.1,8.8.8.8/32']
}
const config = JSON.parse(generateSingboxConfig(routingGame, mockNode))
expect(config.route.final).toBe('direct')
const cidrRule = config.route.rules.find((r: any) =>
Array.isArray(r?.ip_cidr) && r.ip_cidr.includes('1.1.1.1/32') && r.ip_cidr.includes('8.8.8.8/32')
)
expect(cidrRule).toBeDefined()
expect(cidrRule.outbound).toBe('proxy')
expect(Array.isArray(cidrRule.process_name)).toBe(true)
expect(cidrRule.process_name).toContain('chrome.exe')
})
it('should use remote geoip-cn and geosite-cn rulesets for bypass_cn mode', () => {
const routingGame = {
...mockGame,
@@ -239,6 +269,7 @@ describe('generateSingboxConfig', () => {
}
const config = JSON.parse(generateSingboxConfig(routingGame, mockNode))
expect(config.route.final).toBe('proxy')
expect(config.dns.final).toBe('remote-primary')
})
it('should include node outbound', () => {
@@ -307,4 +338,57 @@ describe('generateSingboxConfig', () => {
expect(bootstrapRule).toBeDefined()
expect(bootstrapRule.server).toBe('local')
})
it('should use configured bootstrap DNS server for domain bootstrap rules', () => {
const domainNode: NodeConfig = {
...mockNode,
server: 'm.cnmjin.net',
tls: {
enabled: false
}
}
const config = JSON.parse(generateSingboxConfig(mockGame, domainNode, {
mode: 'secure',
primary: 'https://cloudflare-dns.com/dns-query',
secondary: 'https://cloudflare-dns.com/dns-query',
bootstrap: '223.5.5.5'
}))
const bootstrapServer = config.dns.servers.find((s: any) => s.tag === 'bootstrap')
expect(bootstrapServer).toBeDefined()
expect(bootstrapServer.address).toBe('223.5.5.5')
expect(bootstrapServer.detour).toBe('direct')
const bootstrapRule = config.dns.rules.find((r: any) =>
Array.isArray(r?.domain) && r.domain.includes('cloudflare-dns.com')
)
expect(bootstrapRule).toBeDefined()
expect(bootstrapRule.server).toBe('bootstrap')
expect(bootstrapRule.domain).toContain('cloudflare-dns.com')
expect(bootstrapRule.domain).toContain('m.cnmjin.net')
})
it('should add custom bypass direct rules from proxy bypass list', () => {
const config = JSON.parse(generateSingboxConfig(mockGame, mockNode, {
proxyBypassList: 'localhost,192.168.*,10.0.0.0/8,*.lan'
}))
const ipBypassRule = config.route.rules.find((r: any) =>
Array.isArray(r?.ip_cidr)
&& r.ip_cidr.includes('192.168.0.0/16')
&& r.ip_cidr.includes('10.0.0.0/8')
&& r.outbound === 'direct'
)
expect(ipBypassRule).toBeDefined()
const localhostRule = config.route.rules.find((r: any) =>
Array.isArray(r?.domain) && r.domain.includes('localhost') && r.outbound === 'direct'
)
expect(localhostRule).toBeDefined()
const lanSuffixRule = config.route.rules.find((r: any) =>
Array.isArray(r?.domain_suffix) && r.domain_suffix.includes('lan') && r.outbound === 'direct'
)
expect(lanSuffixRule).toBeDefined()
})
})