feat: 新增游戏平台扫描、主题模式与重置功能
- 新增 BattleNet、WeGame、GOG 等游戏平台扫描支持 - 添加系统代理绕过列表和 Bootstrap DNS 配置 - 实现应用重置功能,支持清理数据并重启 - 增加主题模式选择(亮色/暗色/自动) - 优化本地游戏扫描性能,支持并行扫描 - 改进代理监控,支持链式代理子进程检测 - 修复日志发送时的窗口销毁检查 - 更新图标和侧边栏布局 - 移除 prepare-singbox-core 脚本,简化构建流程
This commit is contained in:
1 parent
4ed9f1441f
commit
489f3d4f1f
67 files changed
+3661
-1060
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,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 +0,0 @@
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
])
|
||||
|
||||
// 去重 (相同的安装目录只保留一个)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 代理连接
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+22
@@ -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
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 63 KiB |
+16
-35
@@ -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 |
@@ -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()
|
||||
|
||||
|
||||
@@ -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,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(),
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+5
-4
@@ -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']
|
||||
}
|
||||
|
||||
@@ -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 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>
|
||||
@@ -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 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Vendored
+11
-2
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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']
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user