feat: 重构游戏扫描模块并新增系统代理支持

- 重构游戏扫描模块,按平台拆分扫描逻辑(Steam、Microsoft Store、Epic/EA)
- 新增系统代理模式支持,可在系统代理和TUN模式间切换
- 新增语言切换功能,支持中英文界面切换
- 改进主题系统,将主题配置统一管理至store
- 新增系统托盘功能,支持窗口最小化到托盘
- 优化网络连接性能,为TCP ping添加noDelay选项
- 新增游戏分类匹配功能,扫描时自动匹配游戏分类
- 改进日志系统,自动捕获前端提示层日志
- 新增会话网络调优选项,支持MTU、UDP模式等高级配置
- 修复多处UI样式问题,统一颜色变量命名
- 更新项目文档,反映新的Electron架构设计
This commit is contained in:
ZenEcho committed 2026-02-20 16:50:03 +08:00
1 parent c2bb7e6ed8
commit a20e070059
66 files changed
+4668 -1713

No files matched your search

+40 -16
View File
@@ -13,21 +13,35 @@ electron/
│ └── schema.ts # SQLite 数据库表结构定义 (Kysely Schema)
│
├── main/ # [入口层] 主进程核心引导
│ ├── index.ts # **应用入口**:负责 App 生命周期、窗口创建、IPC 注册、服务组装
│ └── logger.ts # 日志系统:负责日志文件写入、轮转及向渲染进程广播日志
│ ├── bootstrap.ts # **启动引导**:环境检查、管理员权限校验、图标加载
│ ├── index.ts # **应用入口**:负责 App 生命周期、服务组装
│ ├── logger.ts # 日志系统:负责日志文件写入、轮转及向渲染进程广播日志
│ ├── tray.ts # 托盘管理:系统托盘图标与菜单逻辑
│ └── window.ts # 窗口管理:主窗口创建、配置与生命周期控制
│
├── preload/ # [桥接层] 预加载脚本
│ └── index.ts # 暴露给渲染进程的 API 定义 (ContextBridge)
│
├── services/ # [业务层] 核心业务逻辑服务 (Service)
│ ├── scanners/ # [扫描器] 游戏扫描具体实现
│ │ ├── flat.ts # 通用目录扫描 (Epic/EA)
│ │ ├── microsoft.ts # Xbox/Microsoft Store 游戏扫描 (Registry/Manifest)
│ │ ├── steam.ts # Steam 库扫描 (libraryfolders.vdf)
│ │ ├── types.ts # 扫描结果类型定义
│ │ └── utils.ts # 扫描工具函数
│ ├── singbox/ # [核心] sing-box 内核管理
│ │ ├── config.ts # 配置管理:校验配置、更新路由规则
│ │ ├── constants.ts # 常量定义
│ │ ├── index.ts # **SingBoxService**:服务入口,协调各模块
│ │ ├── installer.ts # 安装器:负责内核下载与安装
│ │ └── utils.ts # 工具函数
│ ├── category.ts # 游戏分类服务:管理侧边栏分类数据
│ ├── database.ts # 数据库服务:封装 SQLite 连接与基础 CRUD 操作
│ ├── game-scanner.ts # 游戏扫描服务:自动识别 Steam/Epic/Xbox/EA 本地已安装游戏
│ ├── game-scanner.ts # 游戏扫描服务:统一入口,聚合 scanners 目录下的扫描逻辑
│ ├── game.ts # 游戏管理服务:管理“我的游戏”列表、配置与状态
│ ├── node.ts # 节点管理服务:管理代理节点/订阅信息
│ ├── process.ts # 进程管理服务:提供进程树查找、父子进程分析能力
│ ├── proxy-monitor.ts # 监控服务:实时监控游戏进程启动,自动触发加速路由
│ ├── singbox.ts # **核心加速服务**:管理 sing-box 内核进程、生成配置、处理路由规则
│ ├── system.ts # 系统服务:提供 DNS 刷新、虚拟网卡重置、网络测试等底层能力
│ └── updater.ts # 更新服务:检查 GitHub Releases 新版本
│
@@ -43,20 +57,30 @@ electron/
## 模块详细说明
### 1. 入口层 (`main/`)
- **`index.ts`**:
- 是整个 Electron 应用的起点。
- 负责初始化所有 Service 实例(依赖注入)。
- 处理窗口管理(最小化、最大化、关闭)。
- 处理系统托盘 (Tray) 逻辑。
- **`logger.ts`**:
- 实现了双向日志系统:既记录后端日志,也接收前端日志。
- 包含日志文件自动清理策略(限制文件大小和总占用空间)。
经过重构,入口层职责更加单一:
- **`index.ts`**: 核心入口,负责组装各个模块,初始化服务。
- **`bootstrap.ts`**: 处理应用启动前的环境准备,如强制管理员权限、处理 SQLite 原生模块错误。
- **`window.ts`**: 封装了 `BrowserWindow` 的创建与配置,管理窗口 IPC 事件(最小化/关闭)。
- **`tray.ts`**: 独立管理系统托盘逻辑。
### 2. 业务层 (`services/`)
这是代码最集中的地方,每个文件对应一个具体的业务领域:
- **`singbox.ts`**: 最核心的文件。负责下载 sing-box 内核、生成 `config.json`、启动/停止内核、动态更新路由规则。
- **`proxy-monitor.ts`**: 实现了智能进程捕获。它会轮询系统进程树,当发现已配置的游戏进程及其子进程启动时,自动通知 `singbox.ts` 添加分流规则。
- **`game-scanner.ts`**: 包含复杂的跨平台(目前主要是 Windows)扫描逻辑,读取注册表、Steam 库文件 (`libraryfolders.vdf`) 等信息来定位游戏。
这是代码最集中的地方,每个文件对应一个具体的业务领域。
#### 核心服务:`singbox/`
将原有的 `singbox.ts` 拆分为独立模块:
- **`index.ts`**: 服务外观 (Facade),对外提供 `start`, `stop`, `restart` 接口。
- **`installer.ts`**: 专注于二进制文件的下载、解压和安装逻辑。
- **`config.ts`**: 专注于配置文件的生成、校验和动态规则更新。
#### 扫描服务:`game-scanner.ts` & `scanners/`
- **`game-scanner.ts`**: 作为统一入口,对外暴露 `scanLocalGamesFromPlatforms`。
- **`scanners/`**: 包含各平台的具体扫描实现:
- **`steam.ts`**: 解析 Steam 库文件。
- **`microsoft.ts`**: 结合注册表和 AppxManifest 解析 Xbox 游戏。
- **`flat.ts`**: 简单的目录遍历扫描 (Epic/EA)。
#### 监控服务:`proxy-monitor.ts`
- 实现了智能进程捕获。它会轮询系统进程树,当发现已配置的游戏进程及其子进程启动时,自动通知 `singbox` 服务添加分流规则。
### 3. 工具层 (`utils/`)
提炼了纯函数工具,不依赖具体业务逻辑,易于测试和复用。
+183
View File
@@ -0,0 +1,183 @@
import { app, dialog, nativeImage, NativeImage } from 'electron'
import { spawnSync } from 'node:child_process'
import path from 'node:path'
import fs from 'fs-extra'
import pkg from '../../package.json'
const ELEVATION_FLAG = '--lagzero-elevated'
/**
* 格式化 PowerShell 参数字符串
* @param input 输入字符串
* @returns 转义后的字符串
*/
function psQuote(input: string): string {
return `'${String(input || '').replace(/'/g, "''")}'`
}
/**
* 检查当前进程是否拥有 Windows 管理员权限
* @returns 是否为管理员
*/
function isWindowsAdmin(): boolean {
if (process.platform !== 'win32') return true
const script = [
'$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())',
'$isAdmin = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)',
'Write-Output $isAdmin'
].join('; ')
const result = spawnSync('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], {
windowsHide: true,
encoding: 'utf8'
})
if (result.status !== 0) return false
return String(result.stdout || '').trim().toLowerCase() === 'true'
}
/**
* 尝试以管理员权限重新启动应用
* @returns 是否成功触发重启
*/
function relaunchAsAdmin(): boolean {
if (process.platform !== 'win32') return false
const args = process.argv.slice(1).filter((a) => a !== ELEVATION_FLAG)
args.push(ELEVATION_FLAG)
const argsLiteral = args.map(psQuote).join(', ')
const script = [
`$exe = ${psQuote(process.execPath)}`,
`$args = @(${argsLiteral})`,
'Start-Process -FilePath $exe -ArgumentList $args -Verb RunAs'
].join('; ')
const result = spawnSync('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], {
windowsHide: true,
encoding: 'utf8'
})
return result.status === 0
}
/**
* 确保应用在启动时拥有管理员权限 (仅 Windows)
* 如果当前没有权限,会尝试申请提权并重启
*/
export function ensureAdminAtStartup() {
if (process.platform !== 'win32') return
if (isWindowsAdmin()) return
const relaunched = relaunchAsAdmin()
if (relaunched) {
app.exit(0)
process.exit(0)
}
console.warn('[Main] Failed to relaunch as administrator. Continuing without elevation.')
}
/**
* 加载应用图标
* 依次尝试加载 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.svg'),
]
const tryFromPath = (p: string): NativeImage | null => {
if (!p || !fs.existsSync(p)) return null
const img = nativeImage.createFromPath(p)
if (!img.isEmpty()) return img
return null
}
for (const p of candidates) {
const image = tryFromPath(p)
if (image) return image
}
const svgPath = path.join(baseDir, 'logo.svg')
if (!fs.existsSync(svgPath)) {
console.warn('[Main] Icon file not found:', svgPath)
return null
}
const svg = fs.readFileSync(svgPath, 'utf-8')
const toImage = (content: string) => {
const svgBase64 = Buffer.from(content, 'utf-8').toString('base64')
return nativeImage.createFromDataURL(`data:image/svg+xml;base64,${svgBase64}`)
}
const sanitized = svg
.replace(/<filter[\s\S]*?<\/filter>/gi, '')
.replace(/\sfilter="url\(#.*?\)"/gi, '')
const image = toImage(sanitized)
if (!image.isEmpty()) return image
const fallbackSvg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<rect width="256" height="256" rx="48" fill="#0f172a"/>
<path d="M145 20 L78 131 L136 131 L118 236 L202 111 L141 111 L168 20 Z"
fill="#22c55e" stroke="#ffffff" stroke-width="6" stroke-linejoin="round" />
</svg>`
const fallbackImage = toImage(fallbackSvg)
if (!fallbackImage.isEmpty()) {
console.warn('[Main] logo.svg decode failed, using built-in fallback icon')
return fallbackImage
}
return null
}
/**
* 格式化启动错误信息
* 特别处理 better-sqlite3 常见的架构/版本不匹配错误,提供修复建议
* @param error 错误对象
* @returns 格式化后的错误信息
*/
export function formatStartupError(error: unknown) {
const raw = String((error as any)?.stack || (error as any)?.message || error || 'Unknown error')
const lower = raw.toLowerCase()
if (lower.includes('better_sqlite3.node') && lower.includes('not a valid win32 application')) {
return [
'Failed to load better-sqlite3 native module due to architecture mismatch.',
'',
`Current runtime arch: ${process.arch}`,
'Fix command: pnpm rebuild better-sqlite3',
'',
'Details:',
raw
].join('\n')
}
if (lower.includes('better_sqlite3.node') && lower.includes('compiled against a different node.js version')) {
return [
'Failed to load better-sqlite3 due to native ABI mismatch.',
'',
`Current runtime: electron ${process.versions.electron}, modules ${process.versions.modules}, arch ${process.arch}`,
'Fix command: pnpm run rebuild:native',
'Fallback command: pnpm run rebuild:sqlite',
'',
'Details:',
raw
].join('\n')
}
return raw
}
/**
* 处理应用启动失败
* 弹出错误框并退出应用
* @param error 错误对象
*/
export function handleStartupError(error: unknown) {
const message = formatStartupError(error)
console.error('[Main] App startup failed:', message)
dialog.showErrorBox('LagZero startup failed', message)
app.quit()
}
+91 -210
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow, ipcMain, dialog, Tray, Menu, nativeImage, NativeImage, shell } from 'electron'
import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron'
import { fileURLToPath, pathToFileURL } from 'node:url'
import path from 'node:path'
import fs from 'fs-extra'
@@ -15,6 +15,9 @@ import { GameScannerService } from '../services/game-scanner'
import { UpdaterService } from '../services/updater'
import { SystemService } from '../services/system'
import { setupLogger } from './logger'
import { ensureAdminAtStartup, loadAppIcon, handleStartupError } from './bootstrap'
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)
@@ -28,10 +31,6 @@ global.__dirname = __dirname
process.env.APP_ROOT = path.join(__dirname, '../')
const APP_ID = 'com.' + pkg.name + '.client'
export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL']
export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron')
export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist')
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, 'public') : RENDERER_DIST
if (VITE_DEV_SERVER_URL) {
@@ -41,8 +40,11 @@ if (process.platform === 'win32') {
app.setAppUserModelId(APP_ID)
}
let win: BrowserWindow | null = null
let tray: Tray | null = null
// 确保以管理员权限启动
ensureAdminAtStartup()
const windowManager = new WindowManager()
const trayManager = new TrayManager(null as any) // 稍后更新 win 引用
// Services
let dbService: DatabaseService | null = null
@@ -55,123 +57,17 @@ let nodeService: NodeService | null = null
let gameScannerService: GameScannerService | null = null
let updaterService: UpdaterService | null = null
let systemService: SystemService | null = null
let quitCleanupStarted = false
// Setup Logger
setupLogger(() => win)
// 初始化日志系统
setupLogger(() => windowManager.get())
function loadAppIcon(): NativeImage | null {
const baseDir = process.env.VITE_PUBLIC || ''
const candidates = [
path.join(baseDir, 'logo.png'),
path.join(baseDir, 'logo.ico'),
path.join(baseDir, 'logo.svg'),
]
const tryFromPath = (p: string): NativeImage | null => {
if (!p || !fs.existsSync(p)) return null
const img = nativeImage.createFromPath(p)
if (!img.isEmpty()) return img
return null
}
for (const p of candidates) {
const image = tryFromPath(p)
if (image) return image
}
const svgPath = path.join(baseDir, 'logo.svg')
if (!fs.existsSync(svgPath)) {
console.warn('[Main] Icon file not found:', svgPath)
return null
}
const svg = fs.readFileSync(svgPath, 'utf-8')
const toImage = (content: string) => {
const svgBase64 = Buffer.from(content, 'utf-8').toString('base64')
return nativeImage.createFromDataURL(`data:image/svg+xml;base64,${svgBase64}`)
}
const sanitized = svg
.replace(/<filter[\s\S]*?<\/filter>/gi, '')
.replace(/\sfilter="url\(#.*?\)"/gi, '')
const image = toImage(sanitized)
if (!image.isEmpty()) return image
const fallbackSvg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<rect width="256" height="256" rx="48" fill="#0f172a"/>
<path d="M145 20 L78 131 L136 131 L118 236 L202 111 L141 111 L168 20 Z"
fill="#22c55e" stroke="#ffffff" stroke-width="6" stroke-linejoin="round" />
</svg>`
const fallbackImage = toImage(fallbackSvg)
if (!fallbackImage.isEmpty()) {
console.warn('[Main] logo.svg decode failed, using built-in fallback icon')
return fallbackImage
}
return null
}
function createTray(icon: NativeImage | null) {
if (tray || !icon) return
const trayIcon = icon.resize({ width: 20, height: 20, quality: 'best' })
tray = new Tray(trayIcon)
tray.setToolTip(pkg.productName)
tray.setContextMenu(Menu.buildFromTemplate([
{
label: 'Show Window',
click: () => {
if (!win) return
win.show()
win.focus()
}
},
{
label: 'Quit',
click: () => app.quit()
}
]))
tray.on('double-click', () => {
if (!win) return
if (win.isVisible()) {
win.focus()
} else {
win.show()
win.focus()
}
})
}
function createWindow() {
const appIcon = loadAppIcon()
const preloadCandidates = [
path.join(__dirname, '../preload/index.mjs'),
path.join(__dirname, 'preload.mjs'),
path.join(__dirname, 'index.mjs'),
]
const preloadPath = preloadCandidates.find(p => fs.existsSync(p)) || preloadCandidates[0]
win = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 860,
minHeight: 620,
frame: false,
webPreferences: {
// Compatible with both nested and flat dist-electron outputs.
preload: preloadPath,
nodeIntegration: false,
contextIsolation: true,
webSecurity: false,
},
backgroundColor: '#1e1e1e',
...(appIcon ? { icon: appIcon } : {}),
})
// Initialize Services
/**
* 初始化所有业务服务并注册 IPC 监听
* @param win 主窗口实例
*/
function initServices(win: BrowserWindow) {
// 实例化服务
dbService = new DatabaseService()
singboxService = new SingBoxService(win)
processService = new ProcessService()
@@ -183,11 +79,11 @@ function createWindow() {
updaterService = new UpdaterService()
systemService = new SystemService()
// Register generic DB IPCs
// 注册通用数据库 IPC
ipcMain.handle('db:export', () => dbService?.exportData())
ipcMain.handle('db:import', (_, data) => dbService?.importData(data))
// Register Scanner IPCs
// 注册扫描服务 IPC
ipcMain.handle('system:scan-local-games', async () => {
try {
return await gameScannerService?.scanLocalGamesFromPlatforms() || []
@@ -197,6 +93,7 @@ function createWindow() {
}
})
ipcMain.handle('dialog:pick-process-folder', async (_, maxDepth: number = 1) => {
const win = windowManager.get()
if (!win) return null
const result = await dialog.showOpenDialog(win, {
properties: ['openDirectory']
@@ -206,122 +103,106 @@ function createWindow() {
return gameScannerService?.scanDir(dir, maxDepth)
})
// Register Updater IPCs
// 注册更新服务 IPC
ipcMain.handle('app:get-version', () => app.getVersion())
ipcMain.handle('app:check-update', () => updaterService?.checkUpdate())
ipcMain.handle('app:open-url', (_, url: string) => {
shell.openExternal(url)
})
if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL)
win.webContents.openDevTools()
} else {
win.loadFile(path.join(RENDERER_DIST, 'index.html'))
}
// 注册系统对话框 IPC
ipcMain.handle('dialog:pick-image', async () => {
const win = windowManager.get()
if (!win) return null
const result = await dialog.showOpenDialog(win, {
properties: ['openFile'],
filters: [
{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'webp', 'ico', 'svg'] }
]
})
if (result.canceled || result.filePaths.length === 0) return null
return pathToFileURL(result.filePaths[0]!).toString()
})
createTray(appIcon)
ipcMain.handle('dialog:pick-process', async () => {
const win = windowManager.get()
if (!win) return null
const result = await dialog.showOpenDialog(win, {
properties: ['openFile', 'multiSelections'],
filters: [
{ name: 'Executables', extensions: ['exe'] },
{ name: 'All Files', extensions: ['*'] }
]
})
if (result.canceled || result.filePaths.length === 0) return null
return result.filePaths.map(p => path.basename(p))
})
}
/**
* 启动应用程序
* 创建窗口、托盘图标并初始化服务
*/
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)
initServices(win)
}
// 监听所有窗口关闭事件
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
// 监听应用激活事件
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
} else if (win) {
win.show()
win.focus()
startApp()
} else {
windowManager.show()
}
})
function formatStartupError(error: unknown) {
const raw = String((error as any)?.stack || (error as any)?.message || error || 'Unknown error')
const lower = raw.toLowerCase()
if (lower.includes('better_sqlite3.node') && lower.includes('not a valid win32 application')) {
return [
'Failed to load better-sqlite3 native module due to architecture mismatch.',
'',
`Current runtime arch: ${process.arch}`,
'Fix command: pnpm rebuild better-sqlite3',
'',
'Details:',
raw
].join('\n')
}
if (lower.includes('better_sqlite3.node') && lower.includes('compiled against a different node.js version')) {
return [
'Failed to load better-sqlite3 due to native ABI mismatch.',
'',
`Current runtime: electron ${process.versions.electron}, modules ${process.versions.modules}, arch ${process.arch}`,
'Fix command: pnpm run rebuild:native',
'Fallback command: pnpm run rebuild:sqlite',
'',
'Details:',
raw
].join('\n')
}
return raw
}
// 应用准备就绪
app.whenReady()
.then(() => {
createWindow()
startApp()
})
.catch((error) => {
const message = formatStartupError(error)
console.error('[Main] App startup failed:', message)
dialog.showErrorBox('LagZero startup failed', message)
app.quit()
handleStartupError(error)
})
// 全局未捕获异常处理
process.on('unhandledRejection', (reason) => {
const message = formatStartupError(reason)
const message = String(reason)
console.error('[Main] Unhandled rejection:', message)
})
app.on('before-quit', () => {
tray?.destroy()
tray = null
})
// 应用退出前清理
app.on('before-quit', (event) => {
trayManager.destroy()
ipcMain.handle('window-minimize', () => win?.minimize())
ipcMain.handle('window-maximize', () => {
if (win && win.isMaximized()) {
win.unmaximize()
} else if (win) {
win.maximize()
}
})
ipcMain.handle('window-close', () => win?.close())
if (quitCleanupStarted) return
quitCleanupStarted = true
event.preventDefault()
ipcMain.handle('dialog:pick-image', async () => {
if (!win) return null
const result = await dialog.showOpenDialog(win, {
properties: ['openFile'],
filters: [
{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'webp', 'ico', 'svg'] }
]
})
if (result.canceled || result.filePaths.length === 0) return null
return pathToFileURL(result.filePaths[0]!).toString()
})
ipcMain.handle('dialog:pick-process', async () => {
if (!win) return null
const result = await dialog.showOpenDialog(win, {
properties: ['openFile', 'multiSelections'],
filters: [
{ name: 'Executables', extensions: ['exe'] },
{ name: 'All Files', extensions: ['*'] }
]
})
if (result.canceled || result.filePaths.length === 0) return null
return result.filePaths.map(p => path.basename(p))
// 尝试清理系统代理设置
Promise.resolve(systemService?.clearSystemProxy())
.catch((e) => {
console.warn('[Main] Failed to clear system proxy on app quit:', e)
})
.finally(() => {
app.exit(0)
})
})
+40 -20
View File
@@ -75,29 +75,49 @@ function queueLogWrite(task: () => Promise<void>) {
})
}
function sanitizeLogText(text?: string) {
if (!text) return ''
return String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n')
}
/**
* 净化日志文本
*
* 移除换行符等特殊字符,防止日志格式错乱。
*/
function sanitizeLogText(text?: string) {
if (!text) return ''
return String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n')
}
function formatLogLine(row: AppLogEntry) {
const iso = new Date(row.timestamp).toISOString()
const base = `[${iso}] [${row.level.toUpperCase()}] [${row.category}] [${row.source}] ${sanitizeLogText(row.message)}`
if (!row.detail) return `${base}\n`
return `${base}\n${sanitizeLogText(row.detail)}\n`
}
/**
* 格式化单行日志
*
* 格式: [ISO时间] [LEVEL] [CATEGORY] [SOURCE] MESSAGE
*/
function formatLogLine(row: AppLogEntry) {
const iso = new Date(row.timestamp).toISOString()
const base = `[${iso}] [${row.level.toUpperCase()}] [${row.category}] [${row.source}] ${sanitizeLogText(row.message)}`
if (!row.detail) return `${base}\n`
return `${base}\n${sanitizeLogText(row.detail)}\n`
}
async function ensureSingleFileLimit(logFilePath: string, incomingBytes: number) {
const exists = await fs.pathExists(logFilePath)
if (!exists) return
const stat = await fs.stat(logFilePath)
const nextSize = stat.size + incomingBytes
if (nextSize <= MAX_LOG_FILE_BYTES) return
await fs.truncate(logFilePath, 0)
}
/**
* 确保单个日志文件大小不超过限制
*
* 如果超过限制 (MAX_LOG_FILE_BYTES),则清空文件内容(简单轮转策略)。
*/
async function ensureSingleFileLimit(logFilePath: string, incomingBytes: number) {
const exists = await fs.pathExists(logFilePath)
if (!exists) return
const stat = await fs.stat(logFilePath)
const nextSize = stat.size + incomingBytes
if (nextSize <= MAX_LOG_FILE_BYTES) return
await fs.truncate(logFilePath, 0)
}
async function pruneLogDirIfNeeded(logDir: string) {
const exists = await fs.pathExists(logDir)
/**
* 清理过期的日志文件
*
* 如果日志目录总大小超过限制 (MAX_LOG_DIR_BYTES),则删除最旧的文件。
*/
async function pruneLogDirIfNeeded(logDir: string) {
const exists = await fs.pathExists(logDir)
if (!exists) return
const dirents = await fs.readdir(logDir, { withFileTypes: true })
+59
View File
@@ -0,0 +1,59 @@
import { Tray, Menu, NativeImage, app, BrowserWindow } from 'electron'
import pkg from '../../package.json'
/**
* 系统托盘管理器
* 负责创建和管理系统托盘图标及菜单
*/
export class TrayManager {
private tray: Tray | null = null
private win: BrowserWindow | null = null
constructor(win: BrowserWindow) {
this.win = win
}
/**
* 创建系统托盘
* @param icon 应用图标
*/
create(icon: NativeImage | null) {
if (this.tray || !icon) return
const trayIcon = icon.resize({ width: 20, height: 20, quality: 'best' })
this.tray = new Tray(trayIcon)
this.tray.setToolTip(pkg.productName)
this.tray.setContextMenu(Menu.buildFromTemplate([
{
label: 'Show Window',
click: () => {
if (!this.win) return
this.win.show()
this.win.focus()
}
},
{
label: 'Quit',
click: () => app.quit()
}
]))
this.tray.on('double-click', () => {
if (!this.win) return
if (this.win.isVisible()) {
this.win.focus()
} else {
this.win.show()
this.win.focus()
}
})
}
/**
* 销毁系统托盘
*/
destroy() {
this.tray?.destroy()
this.tray = null
}
}
+92
View File
@@ -0,0 +1,92 @@
import { BrowserWindow, NativeImage, ipcMain } from 'electron'
import path from 'path'
import fs from 'fs-extra'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const APP_ROOT = process.env.APP_ROOT || path.join(__dirname, '../')
export const MAIN_DIST = path.join(APP_ROOT, 'dist-electron')
export const RENDERER_DIST = path.join(APP_ROOT, 'dist')
export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL']
/**
* 窗口管理器
* 负责主窗口的创建、配置和生命周期管理
*/
export class WindowManager {
private win: BrowserWindow | null = null
constructor() {
this.setupIPC()
}
/**
* 创建主窗口
* @param icon 窗口图标
* @returns BrowserWindow 实例
*/
create(icon: NativeImage | null): BrowserWindow {
const preloadCandidates = [
path.join(__dirname, 'index.mjs'),
path.join(__dirname, '../preload/index.mjs'),
path.join(__dirname, 'preload.mjs'),
]
const preloadPath = preloadCandidates.find(p => fs.existsSync(p)) || preloadCandidates[0]
console.info('[Main] preload selected:', preloadPath)
this.win = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 960,
minHeight: 720,
frame: false,
webPreferences: {
preload: preloadPath,
nodeIntegration: false,
contextIsolation: true,
webSecurity: false,
},
backgroundColor: '#1e1e1e',
...(icon ? { icon } : {}),
})
if (VITE_DEV_SERVER_URL) {
this.win.loadURL(VITE_DEV_SERVER_URL)
this.win.webContents.openDevTools()
} else {
this.win.loadFile(path.join(RENDERER_DIST, 'index.html'))
}
return this.win
}
/**
* 获取当前窗口实例
*/
get() {
return this.win
}
/**
* 显示并聚焦窗口
*/
show() {
this.win?.show()
this.win?.focus()
}
/**
* 注册窗口控制相关的 IPC 事件
*/
private setupIPC() {
ipcMain.handle('window-minimize', () => this.win?.minimize())
ipcMain.handle('window-maximize', () => {
if (this.win && this.win.isMaximized()) {
this.win.unmaximize()
} else if (this.win) {
this.win.maximize()
}
})
ipcMain.handle('window-close', () => this.win?.close())
}
}
+52
View File
@@ -3,19 +3,36 @@
*
* 使用 ContextBridge 将主进程 API 安全地暴露给渲染进程。
* 定义了 electron, singbox, system, proxyMonitor, nodes, games, categories, app, logs 等全局对象。
*
* 注意:
* 1. 所有暴露的 API 必须通过 ipcRenderer.invoke 或 ipcRenderer.on/send 通信
* 2. 避免直接暴露 ipcRenderer 对象,防止安全风险
*/
import { contextBridge, ipcRenderer } from 'electron'
const ipcListenerMap = new Map<string, Map<(...args: any[]) => void, (...args: any[]) => void>>()
const appLogListenerMap = new Map<(entry: any) => void, (...args: any[]) => void>()
// 基础 Electron 功能
contextBridge.exposeInMainWorld('electron', {
/** 最小化窗口 */
minimize: () => ipcRenderer.invoke('window-minimize'),
/** 最大化/还原窗口 */
maximize: () => ipcRenderer.invoke('window-maximize'),
/** 关闭窗口 */
close: () => ipcRenderer.invoke('window-close'),
/** 选择图片文件 */
pickImage: () => ipcRenderer.invoke('dialog:pick-image'),
/** 选择可执行文件 (.exe) */
pickProcess: () => ipcRenderer.invoke('dialog:pick-process'),
/** 选择并扫描文件夹中的可执行文件 */
pickProcessFolder: (maxDepth?: number) => ipcRenderer.invoke('dialog:pick-process-folder', maxDepth),
/**
* 注册 IPC 事件监听器
* @param channel 频道名称
* @param callback 回调函数
*/
on: (channel: string, callback: (...args: any[]) => void) => {
const wrapped = (_event: any, ...args: any[]) => callback(...args)
const channelMap = ipcListenerMap.get(channel) || new Map()
@@ -23,6 +40,12 @@ contextBridge.exposeInMainWorld('electron', {
ipcListenerMap.set(channel, channelMap)
ipcRenderer.on(channel, wrapped)
},
/**
* 移除 IPC 事件监听器
* @param channel 频道名称
* @param callback 原回调函数引用
*/
off: (channel: string, callback: (...args: any[]) => void) => {
const channelMap = ipcListenerMap.get(channel)
const wrapped = channelMap?.get(callback)
@@ -32,30 +55,54 @@ contextBridge.exposeInMainWorld('electron', {
}
})
// Sing-box 内核控制
contextBridge.exposeInMainWorld('singbox', {
/** 启动内核 */
start: (config: string) => ipcRenderer.invoke('singbox-start', config),
/** 停止内核 */
stop: () => ipcRenderer.invoke('singbox-stop'),
/** 重启内核 */
restart: (config: string) => ipcRenderer.invoke('singbox-restart', config),
})
// 系统底层功能
contextBridge.exposeInMainWorld('system', {
/** 扫描系统进程 */
scanProcesses: () => ipcRenderer.invoke('process-scan'),
/** 扫描本地已安装的游戏 (Steam/Xbox/Epic/EA) */
scanLocalGames: () => ipcRenderer.invoke('system:scan-local-games'),
/** 获取进程树 */
getProcessTree: () => ipcRenderer.invoke('process-tree'),
/** ICMP Ping 测试 */
ping: (host: string) => ipcRenderer.invoke('system:ping', host),
/** TCP Ping 测试 */
tcpPing: (host: string, port: number) => ipcRenderer.invoke('system:tcp-ping', host, port),
/** 刷新 DNS 缓存 */
flushDnsCache: () => ipcRenderer.invoke('system:flush-dns-cache'),
/** 重置 TUN 虚拟网卡 */
reinstallTunAdapter: (interfaceName?: string) => ipcRenderer.invoke('system:tun-reinstall', interfaceName),
/** 查找可用端口 */
findAvailablePort: (port: number, count?: number) => ipcRenderer.invoke('system:find-available-port', port, count),
/** 测试 HTTP 代理连通性 */
testHttpProxyConnect: (proxyPort: number, targetHost: string, targetPort?: number, timeoutMs?: number) =>
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'),
})
// 进程代理监控
contextBridge.exposeInMainWorld('proxyMonitor', {
/** 开始监控指定游戏进程启动 */
start: (gameId: string, processNames: string[]) => ipcRenderer.invoke('proxy-monitor:start', gameId, processNames),
/** 停止监控 */
stop: () => ipcRenderer.invoke('proxy-monitor:stop'),
})
// 节点管理
contextBridge.exposeInMainWorld('nodes', {
getAll: () => ipcRenderer.invoke('nodes:get-all'),
save: (node: any) => ipcRenderer.invoke('nodes:save', node),
@@ -63,24 +110,29 @@ contextBridge.exposeInMainWorld('nodes', {
import: (nodes: any[]) => ipcRenderer.invoke('nodes:import', nodes),
})
// 游戏管理
contextBridge.exposeInMainWorld('games', {
getAll: () => ipcRenderer.invoke('games:get-all'),
save: (game: any) => ipcRenderer.invoke('games:save', game),
delete: (id: string) => ipcRenderer.invoke('games:delete', id),
})
// 分类管理
contextBridge.exposeInMainWorld('categories', {
getAll: () => ipcRenderer.invoke('categories:get-all'),
save: (category: any) => ipcRenderer.invoke('categories:save', category),
delete: (id: string) => ipcRenderer.invoke('categories:delete', id),
match: (name: string, processes: string | string[]) => ipcRenderer.invoke('categories:match', name, processes),
})
// 应用元数据与更新
contextBridge.exposeInMainWorld('app', {
getVersion: () => ipcRenderer.invoke('app:get-version'),
checkUpdate: () => ipcRenderer.invoke('app:check-update'),
openUrl: (url: string) => ipcRenderer.invoke('app:open-url', url),
})
// 日志系统
contextBridge.exposeInMainWorld('logs', {
getAll: () => ipcRenderer.invoke('logs:get-all'),
clear: () => ipcRenderer.invoke('logs:clear'),
+28
View File
@@ -428,11 +428,19 @@ export class DatabaseService {
return this.getAllGames()
}
/**
* 删除游戏
* @param id - 游戏 ID
*/
async deleteGame(id: string) {
await this.db.deleteFrom('games').where('id', '=', id).execute()
return this.getAllGames()
}
/**
* 获取所有分类
* @returns Promise<Category[]>
*/
async getAllCategories() {
const rows = await this.db.selectFrom('categories').selectAll().orderBy('order_index').execute()
return rows.map(row => ({
@@ -445,6 +453,10 @@ export class DatabaseService {
}))
}
/**
* 保存分类(新增或更新)
* @param category - 分类对象
*/
async saveCategory(category: any) {
const data = {
id: category.id || generateId(),
@@ -464,11 +476,19 @@ export class DatabaseService {
return this.getAllCategories()
}
/**
* 删除分类
* @param id - 分类 ID
*/
async deleteCategory(id: string) {
await this.db.deleteFrom('categories').where('id', '=', id).execute()
return this.getAllCategories()
}
/**
* 获取所有配置文件
* @returns Promise<Profile[]>
*/
async getAllProfiles() {
const rows = await this.db.selectFrom('profiles').selectAll().execute()
return rows.map(row => ({
@@ -480,6 +500,10 @@ export class DatabaseService {
}))
}
/**
* 保存配置文件(新增或更新)
* @param profile - 配置文件对象
*/
async saveProfile(profile: any) {
const data = {
id: profile.id || generateId(),
@@ -498,6 +522,10 @@ export class DatabaseService {
return this.getAllProfiles()
}
/**
* 删除配置文件
* @param id - 配置文件 ID
*/
async deleteProfile(id: string) {
await this.db.deleteFrom('profiles').where('id', '=', id).execute()
return this.getAllProfiles()
+21 -339
View File
@@ -1,348 +1,22 @@
import fs from 'fs-extra'
import path from 'path'
import { runCommand } from '../utils/command'
import { LocalGameScanResult } from './scanners/types'
import { getWindowsDriveRoots } from './scanners/utils'
import { scanSteamGames } from './scanners/steam'
import { scanMicrosoftGames } from './scanners/microsoft'
import { scanFlatPlatformFolder } from './scanners/flat'
/**
* 本地游戏扫描结果接口
* 游戏扫描服务
*
* 负责扫描本地已安装的游戏,目前支持 Windows 平台。
* 聚合了 Steam, Microsoft Store (Xbox), Epic Games, EA App 等多个平台的扫描逻辑。
*/
export type LocalGameScanResult = {
/** 游戏显示名称 */
name: string
/** 游戏主进程名 (如 dota2.exe) */
processName: string
/** 来源平台 */
source: 'steam' | 'microsoft' | 'epic' | 'ea'
/** 安装目录绝对路径 */
installDir: string
}
const GAME_SCAN_IGNORE_DIR_NAMES = new Set([
'_commonredist',
'redist',
'redistributable',
'installer',
'installers',
'directx',
'vcredist',
'prereq',
'prerequisites',
'support',
'tools',
'launcher'
])
const GAME_SCAN_IGNORE_EXE_KEYWORDS = [
'setup',
'unins',
'uninstall',
'installer',
'crashreport',
'updater',
'helper',
'bootstrap'
]
const GAME_SCAN_EXE_HARD_EXCLUDE = new Set([
'unitycrashhandler64.exe',
'unitycrashhandler32.exe'
])
function normalizeDisplayName(name: string) {
return name
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function normalizeFsPath(p: string) {
return path.normalize(p).replace(/[\\\/]+$/, '').toLowerCase()
}
async function safeReadDir(dir: string) {
try {
return await fs.readdir(dir, { withFileTypes: true })
} catch {
return []
}
}
async function getWindowsDriveRoots(): Promise<string[]> {
if (process.platform !== 'win32') return []
const roots: string[] = []
for (let i = 67; i <= 90; i += 1) {
const letter = String.fromCharCode(i)
const root = `${letter}:\\`
if (await fs.pathExists(root)) roots.push(root)
}
return roots
}
function basenameLower(p: string) {
return path.basename(p).toLowerCase()
}
function shouldSkipExeByName(exeName: string) {
const lower = exeName.toLowerCase()
if (!lower.endsWith('.exe')) return true
if (GAME_SCAN_EXE_HARD_EXCLUDE.has(lower)) return true
return GAME_SCAN_IGNORE_EXE_KEYWORDS.some(k => lower.includes(k))
}
async function collectExePaths(dir: string, maxDepth: number, currentDepth: number = 1): Promise<string[]> {
const results: string[] = []
const dirents = await safeReadDir(dir)
for (const dirent of dirents) {
const full = path.join(dir, dirent.name)
if (dirent.isDirectory()) {
const folderName = dirent.name.toLowerCase()
if (GAME_SCAN_IGNORE_DIR_NAMES.has(folderName)) continue
if (maxDepth === -1 || currentDepth < maxDepth) {
const sub = await collectExePaths(full, maxDepth, currentDepth + 1)
results.push(...sub)
}
continue
}
if (!dirent.isFile() || !dirent.name.toLowerCase().endsWith('.exe')) continue
if (shouldSkipExeByName(dirent.name)) continue
results.push(full)
}
return results
}
async function pickBestExecutable(gameDir: string, displayName: string): Promise<string | null> {
const exes = await collectExePaths(gameDir, 3)
if (exes.length === 0) return null
if (exes.length === 1) return exes[0] || null
const target = normalizeDisplayName(displayName).toLowerCase()
const ranked = await Promise.all(exes.map(async (exe) => {
let score = 0
const base = basenameLower(exe).replace(/\.exe$/, '')
const full = exe.toLowerCase()
if (base === target) score += 10
if (base.includes(target)) score += 6
if (target.includes(base)) score += 4
if (!full.includes('launcher')) score += 2
try {
const stat = await fs.stat(exe)
score += Math.min(8, Math.floor((stat.size || 0) / (40 * 1024 * 1024)))
} catch {
// ignore stat errors and keep heuristic score
}
return { exe, score }
}))
ranked.sort((a, b) => b.score - a.score)
return ranked[0]?.exe || null
}
async function readSteamLibraryRoots(): Promise<string[]> {
const drives = await getWindowsDriveRoots()
const candidateSteamRoots = Array.from(new Set(
drives.flatMap(root => [
path.join(root, 'Program Files (x86)', 'Steam'),
path.join(root, 'Program Files', 'Steam'),
path.join(root, 'Steam')
])
))
const libs = new Set<string>()
for (const steamRoot of candidateSteamRoots) {
const libraryVdf = path.join(steamRoot, 'steamapps', 'libraryfolders.vdf')
if (!await fs.pathExists(libraryVdf)) continue
libs.add(path.normalize(steamRoot))
try {
const content = await fs.readFile(libraryVdf, 'utf8')
const lines = content.split(/\r?\n/)
for (const line of lines) {
const m = line.match(/"path"\s+"([^"]+)"/i)
if (!m?.[1]) continue
const p = m[1].replace(/\\\\/g, '\\').trim()
if (p) libs.add(path.normalize(p))
}
} catch (e) {
console.warn('[GameScan] Failed to parse Steam libraryfolders.vdf:', e)
}
}
return Array.from(libs)
}
async function scanSteamGames(): Promise<LocalGameScanResult[]> {
const roots = await readSteamLibraryRoots()
const results: LocalGameScanResult[] = []
for (const libRoot of roots) {
const commonDir = path.join(libRoot, 'steamapps', 'common')
if (!await fs.pathExists(commonDir)) continue
const games = await safeReadDir(commonDir)
for (const entry of games) {
if (!entry.isDirectory()) continue
const installDir = path.join(commonDir, entry.name)
const exe = await pickBestExecutable(installDir, entry.name)
if (!exe) continue
results.push({
name: normalizeDisplayName(entry.name),
processName: path.basename(exe),
source: 'steam',
installDir
})
}
}
return results
}
async function scanFlatPlatformFolder(source: 'epic' | 'ea' | 'microsoft', roots: string[]): Promise<LocalGameScanResult[]> {
const results: LocalGameScanResult[] = []
for (const root of roots) {
if (!await fs.pathExists(root)) continue
const entries = await safeReadDir(root)
for (const entry of entries) {
if (!entry.isDirectory()) continue
const installDir = path.join(root, entry.name)
const exe = await pickBestExecutable(installDir, entry.name)
if (!exe) continue
results.push({
name: normalizeDisplayName(entry.name),
processName: path.basename(exe),
source,
installDir
})
}
}
return results
}
function pickBestNameFromHints(installDir: string, hints: Map<string, string>) {
const dirKey = normalizeFsPath(installDir)
let bestName = ''
let bestLen = -1
for (const [hintPath, hintName] of hints.entries()) {
if (!hintName) continue
if (dirKey === hintPath || dirKey.startsWith(`${hintPath}\\`) || hintPath.startsWith(`${dirKey}\\`)) {
if (hintPath.length > bestLen) {
bestLen = hintPath.length
bestName = hintName
}
}
}
return bestName
}
function parsePowershellJson<T>(raw: string): T[] {
const text = raw.trim()
if (!text) return []
try {
const parsed = JSON.parse(text)
if (Array.isArray(parsed)) return parsed as T[]
if (parsed && typeof parsed === 'object') return [parsed as T]
return []
} catch {
return []
}
}
async function getRegistryDisplayNameHints(): Promise<Map<string, string>> {
const map = new Map<string, string>()
if (process.platform !== 'win32') return map
const script = [
"$roots = @(",
" 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',",
" 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',",
" 'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'",
")",
"$items = foreach ($r in $roots) {",
" Get-ItemProperty -Path $r -ErrorAction SilentlyContinue |",
" Where-Object { $_.DisplayName -and $_.InstallLocation } |",
" Select-Object DisplayName, InstallLocation",
"}",
"$items | ConvertTo-Json -Compress"
].join('; ')
const { code, output } = await runCommand('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], 15000)
if (code !== 0 || !output) return map
const rows = parsePowershellJson<{ DisplayName?: string, InstallLocation?: string }>(output)
for (const row of rows) {
const name = String(row.DisplayName || '').trim()
const location = String(row.InstallLocation || '').trim()
if (!name || !location) continue
map.set(normalizeFsPath(location), name)
}
return map
}
function parseDisplayNameFromManifest(content: string): string {
const displayName = content.match(/<DisplayName>([^<]+)<\/DisplayName>/i)?.[1]?.trim() || ''
const identityName = content.match(/<Identity[^>]*\sName="([^"]+)"/i)?.[1]?.trim() || ''
if (displayName && !/^ms-resource:/i.test(displayName)) return displayName
return identityName
}
async function getManifestDisplayNameHints(xboxRoots: string[]): Promise<Map<string, string>> {
const map = new Map<string, string>()
for (const root of xboxRoots) {
if (!await fs.pathExists(root)) continue
const entries = await safeReadDir(root)
for (const entry of entries) {
if (!entry.isDirectory()) continue
const gameDir = path.join(root, entry.name)
const manifestCandidates = [
path.join(gameDir, 'AppxManifest.xml'),
path.join(gameDir, 'Content', 'AppxManifest.xml')
]
for (const manifestPath of manifestCandidates) {
if (!await fs.pathExists(manifestPath)) continue
try {
const content = await fs.readFile(manifestPath, 'utf8')
const parsedName = parseDisplayNameFromManifest(content)
if (!parsedName) continue
map.set(normalizeFsPath(gameDir), normalizeDisplayName(parsedName))
break
} catch {
// ignore manifest read errors
}
}
}
}
return map
}
async function scanMicrosoftGames(): Promise<LocalGameScanResult[]> {
const drives = await getWindowsDriveRoots()
const roots = drives.map(root => path.join(root, 'XboxGames'))
const base = await scanFlatPlatformFolder('microsoft', roots)
const [registryHints, manifestHints] = await Promise.all([
getRegistryDisplayNameHints(),
getManifestDisplayNameHints(roots)
])
const hints = new Map<string, string>([...registryHints, ...manifestHints])
return base.map(game => {
const hintName = pickBestNameFromHints(game.installDir, hints)
return hintName ? { ...game, name: hintName } : game
})
}
export class GameScannerService {
/**
* 扫描所有支持平台的游戏
* @returns 扫描到的游戏列表 (去重后)
*/
async scanLocalGamesFromPlatforms(): Promise<LocalGameScanResult[]> {
if (process.platform !== 'win32') return []
const drives = await getWindowsDriveRoots()
@@ -369,6 +43,7 @@ export class GameScannerService {
)))
])
// 去重逻辑:名称和进程名都相同的视为同一个游戏
const dedup = new Map<string, LocalGameScanResult>()
for (const game of [...steam, ...microsoft, ...epic, ...ea]) {
const key = `${game.name.toLowerCase()}|${game.processName.toLowerCase()}`
@@ -378,6 +53,13 @@ export class GameScannerService {
return Array.from(dedup.values())
}
/**
* 扫描指定目录下的可执行文件
* @param dir 目标目录
* @param maxDepth 最大递归深度 (-1 为无限)
* @param currentDepth 当前深度 (内部使用)
* @returns 找到的 .exe 文件名列表
*/
async scanDir(dir: string, maxDepth: number, currentDepth: number = 1): Promise<string[]> {
let results: string[] = []
try {
+19
View File
@@ -59,22 +59,41 @@ export class NodeService {
this.registerIPC()
}
/**
* 获取所有节点
* @returns Promise<NodeConfig[]>
*/
public getAll() {
return this.db.getAllNodes()
}
/**
* 保存节点配置
* @param node - 节点配置对象
*/
public save(node: Partial<NodeConfig>) {
return this.db.saveNode(node)
}
/**
* 删除节点
* @param id - 节点 ID
*/
public delete(id: string) {
return this.db.deleteNode(id)
}
/**
* 批量导入节点
* @param newNodes - 节点列表
*/
public importNodes(newNodes: Partial<NodeConfig>[]) {
return this.db.importNodes(newNodes)
}
/**
* 注册 IPC 监听器
*/
private registerIPC() {
ipcMain.handle('nodes:get-all', () => this.getAll())
ipcMain.handle('nodes:save', (_, node: any) => this.save(node))
+3
View File
@@ -26,6 +26,9 @@ export class ProcessService {
this.setupIPC()
}
/**
* 注册 IPC 监听器
*/
private setupIPC() {
ipcMain.handle('process-scan', async () => {
return await this.scanProcesses()
+3
View File
@@ -28,6 +28,9 @@ export class ProxyMonitorService {
this.setupIPC();
}
/**
* 注册 IPC 监听器
*/
private setupIPC() {
ipcMain.handle('proxy-monitor:start', (_, gameId: string, processNames: string[]) => {
this.startMonitoring(gameId, processNames);
+36
View File
@@ -0,0 +1,36 @@
import fs from 'fs-extra'
import path from 'path'
import { LocalGameScanResult } from './types'
import { normalizeDisplayName, pickBestExecutable, safeReadDir } from './utils'
/**
* 扫描指定目录下的通用平台游戏 (Epic, EA, Microsoft Store)
*
* 这种扫描方式主要依赖于目录结构和可执行文件的启发式匹配。
*
* @param source 游戏平台标识
* @param roots 需要扫描的根目录列表
* @returns 扫描到的游戏列表
*/
export async function scanFlatPlatformFolder(source: 'epic' | 'ea' | 'microsoft', roots: string[]): Promise<LocalGameScanResult[]> {
const results: LocalGameScanResult[] = []
for (const root of roots) {
if (!await fs.pathExists(root)) continue
const entries = await safeReadDir(root)
for (const entry of entries) {
if (!entry.isDirectory()) continue
const installDir = path.join(root, entry.name)
const exe = await pickBestExecutable(installDir, entry.name)
if (!exe) continue
results.push({
name: normalizeDisplayName(entry.name),
processName: path.basename(exe),
source,
installDir
})
}
}
return results
}
+143
View File
@@ -0,0 +1,143 @@
import fs from 'fs-extra'
import path from 'path'
import { LocalGameScanResult } from './types'
import { getWindowsDriveRoots, normalizeDisplayName, normalizeFsPath, safeReadDir } from './utils'
import { runCommand } from '../../utils/command'
import { scanFlatPlatformFolder } from './flat'
/**
* 根据注册表或 Manifest 提供的提示信息,优化游戏名称
*/
function pickBestNameFromHints(installDir: string, hints: Map<string, string>) {
const dirKey = normalizeFsPath(installDir)
let bestName = ''
let bestLen = -1
for (const [hintPath, hintName] of hints.entries()) {
if (!hintName) continue
if (dirKey === hintPath || dirKey.startsWith(`${hintPath}\\`) || hintPath.startsWith(`${dirKey}\\`)) {
if (hintPath.length > bestLen) {
bestLen = hintPath.length
bestName = hintName
}
}
}
return bestName
}
/**
* 解析 PowerShell 返回的 JSON 字符串
*/
function parsePowershellJson<T>(raw: string): T[] {
const text = raw.trim()
if (!text) return []
try {
const parsed = JSON.parse(text)
if (Array.isArray(parsed)) return parsed as T[]
if (parsed && typeof parsed === 'object') return [parsed as T]
return []
} catch {
return []
}
}
/**
* 从 Windows 注册表中获取已安装软件的名称和安装路径映射
*/
async function getRegistryDisplayNameHints(): Promise<Map<string, string>> {
const map = new Map<string, string>()
if (process.platform !== 'win32') return map
const script = [
"$roots = @(",
" 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',",
" 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',",
" 'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'",
")",
"$items = foreach ($r in $roots) {",
" Get-ItemProperty -Path $r -ErrorAction SilentlyContinue |",
" Where-Object { $_.DisplayName -and $_.InstallLocation } |",
" Select-Object DisplayName, InstallLocation",
"}",
"$items | ConvertTo-Json -Compress"
].join('; ')
const { code, output } = await runCommand('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], 15000)
if (code !== 0 || !output) return map
const rows = parsePowershellJson<{ DisplayName?: string, InstallLocation?: string }>(output)
for (const row of rows) {
const name = String(row.DisplayName || '').trim()
const location = String(row.InstallLocation || '').trim()
if (!name || !location) continue
map.set(normalizeFsPath(location), name)
}
return map
}
/**
* 从 AppxManifest.xml 内容中提取显示名称
*/
function parseDisplayNameFromManifest(content: string): string {
const displayName = content.match(/<DisplayName>([^<]+)<\/DisplayName>/i)?.[1]?.trim() || ''
const identityName = content.match(/<Identity[^>]*\sName="([^"]+)"/i)?.[1]?.trim() || ''
if (displayName && !/^ms-resource:/i.test(displayName)) return displayName
return identityName
}
/**
* 从 XboxGames 目录下的 AppxManifest.xml 解析游戏名称
*/
async function getManifestDisplayNameHints(xboxRoots: string[]): Promise<Map<string, string>> {
const map = new Map<string, string>()
for (const root of xboxRoots) {
if (!await fs.pathExists(root)) continue
const entries = await safeReadDir(root)
for (const entry of entries) {
if (!entry.isDirectory()) continue
const gameDir = path.join(root, entry.name)
const manifestCandidates = [
path.join(gameDir, 'AppxManifest.xml'),
path.join(gameDir, 'Content', 'AppxManifest.xml')
]
for (const manifestPath of manifestCandidates) {
if (!await fs.pathExists(manifestPath)) continue
try {
const content = await fs.readFile(manifestPath, 'utf8')
const parsedName = parseDisplayNameFromManifest(content)
if (!parsedName) continue
map.set(normalizeFsPath(gameDir), normalizeDisplayName(parsedName))
break
} catch {
// ignore manifest read errors
}
}
}
}
return map
}
/**
* 扫描 Microsoft Store / Xbox 游戏
* 结合目录扫描、注册表和 AppxManifest 信息来获取准确的游戏名称
*/
export async function scanMicrosoftGames(): Promise<LocalGameScanResult[]> {
const drives = await getWindowsDriveRoots()
const roots = drives.map(root => path.join(root, 'XboxGames'))
const base = await scanFlatPlatformFolder('microsoft', roots)
const [registryHints, manifestHints] = await Promise.all([
getRegistryDisplayNameHints(),
getManifestDisplayNameHints(roots)
])
const hints = new Map<string, string>([...registryHints, ...manifestHints])
return base.map(game => {
const hintName = pickBestNameFromHints(game.installDir, hints)
return hintName ? { ...game, name: hintName } : game
})
}
+71
View File
@@ -0,0 +1,71 @@
import fs from 'fs-extra'
import path from 'path'
import { LocalGameScanResult } from './types'
import { getWindowsDriveRoots, normalizeDisplayName, pickBestExecutable, safeReadDir } from './utils'
/**
* 读取 Steam 库文件夹路径
* 解析 libraryfolders.vdf 文件获取所有库位置
*/
async function readSteamLibraryRoots(): Promise<string[]> {
const drives = await getWindowsDriveRoots()
const candidateSteamRoots = Array.from(new Set(
drives.flatMap(root => [
path.join(root, 'Program Files (x86)', 'Steam'),
path.join(root, 'Program Files', 'Steam'),
path.join(root, 'Steam')
])
))
const libs = new Set<string>()
for (const steamRoot of candidateSteamRoots) {
const libraryVdf = path.join(steamRoot, 'steamapps', 'libraryfolders.vdf')
if (!await fs.pathExists(libraryVdf)) continue
libs.add(path.normalize(steamRoot))
try {
const content = await fs.readFile(libraryVdf, 'utf8')
const lines = content.split(/\r?\n/)
for (const line of lines) {
const m = line.match(/"path"\s+"([^"]+)"/i)
if (!m?.[1]) continue
const p = m[1].replace(/\\\\/g, '\\').trim()
if (p) libs.add(path.normalize(p))
}
} catch (e) {
console.warn('[GameScan] Failed to parse Steam libraryfolders.vdf:', e)
}
}
return Array.from(libs)
}
/**
* 扫描 Steam 游戏
* 遍历所有 Steam 库目录下的 common 文件夹
*/
export async function scanSteamGames(): Promise<LocalGameScanResult[]> {
const roots = await readSteamLibraryRoots()
const results: LocalGameScanResult[] = []
for (const libRoot of roots) {
const commonDir = path.join(libRoot, 'steamapps', 'common')
if (!await fs.pathExists(commonDir)) continue
const games = await safeReadDir(commonDir)
for (const entry of games) {
if (!entry.isDirectory()) continue
const installDir = path.join(commonDir, entry.name)
const exe = await pickBestExecutable(installDir, entry.name)
if (!exe) continue
results.push({
name: normalizeDisplayName(entry.name),
processName: path.basename(exe),
source: 'steam',
installDir
})
}
}
return results
}
+53
View File
@@ -0,0 +1,53 @@
/**
* 本地游戏扫描结果接口
*/
export type LocalGameScanResult = {
/** 游戏显示名称 */
name: string
/** 游戏主进程名 (如 dota2.exe) */
processName: string
/** 来源平台 */
source: 'steam' | 'microsoft' | 'epic' | 'ea'
/** 安装目录绝对路径 */
installDir: string
}
/**
* 扫描时需要忽略的常见非游戏目录名
*/
export const GAME_SCAN_IGNORE_DIR_NAMES = new Set([
'_commonredist',
'redist',
'redistributable',
'installer',
'installers',
'directx',
'vcredist',
'prereq',
'prerequisites',
'support',
'tools',
'launcher'
])
/**
* 扫描时需要忽略的常见非游戏主进程的可执行文件关键词
*/
export const GAME_SCAN_IGNORE_EXE_KEYWORDS = [
'setup',
'unins',
'uninstall',
'installer',
'crashreport',
'updater',
'helper',
'bootstrap'
]
/**
* 必须排除的特定可执行文件名
*/
export const GAME_SCAN_EXE_HARD_EXCLUDE = new Set([
'unitycrashhandler64.exe',
'unitycrashhandler32.exe'
])
+133
View File
@@ -0,0 +1,133 @@
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'
/**
* 标准化游戏显示名称
* 去除下划线、多余空格等
*/
export function normalizeDisplayName(name: string) {
return name
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
/**
* 标准化文件系统路径
* 统一分隔符、转小写、去除尾部斜杠
*/
export function normalizeFsPath(p: string) {
return path.normalize(p).replace(/[\\\/]+$/, '').toLowerCase()
}
/**
* 安全地读取目录内容
* 遇到错误时返回空数组而不是抛出异常
*/
export async function safeReadDir(dir: string) {
try {
return await fs.readdir(dir, { withFileTypes: true })
} catch {
return []
}
}
/**
* 获取 Windows 系统下的所有驱动器根目录 (C:\, D:\, ...)
*/
export async function getWindowsDriveRoots(): Promise<string[]> {
if (process.platform !== 'win32') return []
const roots: string[] = []
for (let i = 67; i <= 90; i += 1) {
const letter = String.fromCharCode(i)
const root = `${letter}:\\`
if (await fs.pathExists(root)) roots.push(root)
}
return roots
}
/**
* 获取路径的基本名称(小写)
*/
export function basenameLower(p: string) {
return path.basename(p).toLowerCase()
}
/**
* 判断是否应跳过该可执行文件
*/
export function shouldSkipExeByName(exeName: string) {
const lower = exeName.toLowerCase()
if (!lower.endsWith('.exe')) return true
if (GAME_SCAN_EXE_HARD_EXCLUDE.has(lower)) return true
return GAME_SCAN_IGNORE_EXE_KEYWORDS.some(k => lower.includes(k))
}
/**
* 递归收集目录下的所有 .exe 文件路径
* @param maxDepth 最大递归深度 (-1 表示无限)
*/
export async function collectExePaths(dir: string, maxDepth: number, currentDepth: number = 1): Promise<string[]> {
const results: string[] = []
const dirents = await safeReadDir(dir)
for (const dirent of dirents) {
const full = path.join(dir, dirent.name)
if (dirent.isDirectory()) {
const folderName = dirent.name.toLowerCase()
if (GAME_SCAN_IGNORE_DIR_NAMES.has(folderName)) continue
if (maxDepth === -1 || currentDepth < maxDepth) {
const sub = await collectExePaths(full, maxDepth, currentDepth + 1)
results.push(...sub)
}
continue
}
if (!dirent.isFile() || !dirent.name.toLowerCase().endsWith('.exe')) continue
if (shouldSkipExeByName(dirent.name)) continue
results.push(full)
}
return results
}
/**
* 启发式算法:从目录下选择最像是游戏主进程的可执行文件
*
* 评分规则:
* - 文件名与文件夹名完全匹配 (+10)
* - 文件名包含文件夹名 (+6)
* - 文件夹名包含文件名 (+4)
* - 不包含 launcher 关键词 (+2)
* - 文件体积越大分数越高 (每 40MB +1, 上限 +8)
*/
export async function pickBestExecutable(gameDir: string, displayName: string): Promise<string | null> {
const exes = await collectExePaths(gameDir, 3)
if (exes.length === 0) return null
if (exes.length === 1) return exes[0] || null
const target = normalizeDisplayName(displayName).toLowerCase()
const ranked = await Promise.all(exes.map(async (exe) => {
let score = 0
const base = basenameLower(exe).replace(/\.exe$/, '')
const full = exe.toLowerCase()
if (base === target) score += 10
if (base.includes(target)) score += 6
if (target.includes(base)) score += 4
if (!full.includes('launcher')) score += 2
try {
const stat = await fs.stat(exe)
score += Math.min(8, Math.floor((stat.size || 0) / (40 * 1024 * 1024)))
} catch {
// ignore stat errors and keep heuristic score
}
return { exe, score }
}))
ranked.sort((a, b) => b.score - a.score)
return ranked[0]?.exe || null
}
-597
View File
@@ -1,597 +0,0 @@
import { spawn, ChildProcess } from 'child_process'
import path from 'path'
import fs from 'fs-extra'
import { app, BrowserWindow, ipcMain } from 'electron'
import AdmZip from 'adm-zip'
import * as tar from 'tar'
import { pipeline } from 'stream/promises'
import { createWriteStream } from 'fs'
import { Readable } from 'stream'
import https from 'node:https'
import { generateId } from '../utils/id'
import { normalizeProcessNames, areStringArraysEqual } from '../utils/process-helper'
const BIN_DIR = path.join(app.getPath('userData'), 'bin')
/**
* Sing-box 核心服务
*
* 负责管理 sing-box 内核的生命周期,包括:
* 1. 自动下载与安装内核
* 2. 启动/停止/重启内核进程
* 3. 实时日志捕获与分发
* 4. 动态更新路由规则 (Process Rules)
*/
export class SingBoxService {
private process: ChildProcess | null = null
private mainWindow: BrowserWindow | null = null
private retryCount = 0
private maxRetries = 3
private downloadPromise: Promise<string> | null = null
private lastLogs: string[] = []
private isStopping = false
private suppressNextStoppedStatus = false
private processRuleUpdateQueue: Promise<void> = Promise.resolve()
constructor(window: BrowserWindow) {
this.mainWindow = window
this.setupIPC()
}
private setupIPC() {
ipcMain.handle('singbox-start', async (_, configContent: string) => {
const configPath = path.join(app.getPath('userData'), 'config.json')
await fs.writeFile(configPath, configContent)
await this.start(configPath)
})
ipcMain.handle('singbox-stop', () => this.stop())
ipcMain.handle('singbox-restart', async (_, configContent: string) => {
this.suppressNextStoppedStatus = true
await this.stopAndWaitForExit(5000)
const configPath = path.join(app.getPath('userData'), 'config.json')
await fs.writeFile(configPath, configContent)
await this.start(configPath)
})
}
async checkAndDownloadBinary() {
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}`)
if (await fs.pathExists(binPath)) {
return binPath
}
if (!this.downloadPromise) {
this.downloadPromise = this.downloadAndInstallBinary(platform, arch)
.finally(() => {
this.downloadPromise = null
})
}
return this.downloadPromise
}
async start(configPath: string) {
if (this.process) {
this.log('Process already running', 'info')
return
}
this.isStopping = false
const binPath = await this.checkAndDownloadBinary()
if (!await fs.pathExists(binPath)) {
const msg = 'sing-box 可执行文件不存在,无法启动加速'
this.log(msg, 'error')
this.mainWindow?.webContents.send('singbox-error', msg)
throw new Error(msg)
}
await this.validateConfig(binPath, configPath)
this.log('正在启动 sing-box...')
this.lastLogs = []
const pushLog = (line: string) => {
const trimmed = this.stripAnsi(String(line || '').trim())
if (!trimmed) return
this.lastLogs.push(trimmed)
if (this.lastLogs.length > 80) this.lastLogs.shift()
}
this.process = spawn(binPath, ['run', '-c', configPath], { windowsHide: true, env: this.getSingboxEnv() })
this.process.stdout?.on('data', (data) => {
const text = data.toString().trim()
pushLog(text)
this.log(this.stripAnsi(text))
})
this.process.stderr?.on('data', (data) => {
const text = data.toString().trim()
pushLog(text)
this.log(this.stripAnsi(text), 'error')
})
const started = await new Promise<void>((resolve, reject) => {
let settled = false
const settle = (fn: () => void) => {
if (settled) return
settled = true
fn()
}
const startupTimer = setTimeout(() => {
settle(() => resolve())
}, 800)
this.process?.once('error', (err) => {
clearTimeout(startupTimer)
const msg = `sing-box 启动失败:${String(err?.message || err)}`
this.log(msg, 'error')
this.mainWindow?.webContents.send('singbox-error', msg)
settle(() => reject(new Error(msg)))
})
this.process?.once('close', (code) => {
if (!settled) {
clearTimeout(startupTimer)
const detail = this.buildHelpfulErrorDetail(this.lastLogs)
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)
settle(() => reject(new Error(msg)))
}
})
})
this.process.on('close', (code) => {
this.log(`sing-box exited with code ${code}`)
this.process = null
if (this.suppressNextStoppedStatus) {
this.suppressNextStoppedStatus = false
} else {
this.sendStatus('stopped')
}
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)
} else if (code !== 0) {
const detail = this.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)
}
})
await started
this.sendStatus('running')
this.retryCount = 0
}
/**
* 停止 sing-box
*/
stop() {
if (this.process) {
this.isStopping = true
this.retryCount = 0
const current = this.process
const pid = typeof current.pid === 'number' ? current.pid : null
current.kill('SIGINT')
setTimeout(() => {
if (!this.process) return
if (this.process !== current) return
if (process.platform === 'win32' && pid) {
spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true })
} else {
this.process.kill('SIGKILL')
}
}, 800)
}
}
private log(message: string, type: 'info' | 'error' = 'info') {
console.log(`[SingBox] ${message}`)
this.mainWindow?.webContents.send('singbox-log', { message, type, timestamp: Date.now() })
}
private sendStatus(status: 'running' | 'stopped') {
this.mainWindow?.webContents.send('singbox-status', status)
}
private async validateConfig(binPath: string, configPath: string): Promise<void> {
const { code, output } = await this.runOnce(binPath, ['check', '-c', configPath])
if (code === 0) return
const lines = output
.split(/\r?\n/g)
.map(l => this.stripAnsi(l.trim()))
.filter(Boolean)
.slice(-12)
const detail = this.buildHelpfulErrorDetail(lines)
const msg = `sing-box 配置校验失败(code=${String(code)})${lines.length ? `:${lines.at(-1)}` : ''}${detail ? `\n\n${detail}` : ''}`
this.log(msg, 'error')
this.mainWindow?.webContents.send('singbox-error', msg)
throw new Error(msg)
}
private runOnce(binPath: string, args: string[]): Promise<{ code: number, output: string }> {
return new Promise((resolve) => {
const p = spawn(binPath, args, { windowsHide: true, env: this.getSingboxEnv() })
const chunks: Buffer[] = []
const onData = (d: any) => chunks.push(Buffer.isBuffer(d) ? d : Buffer.from(String(d)))
p.stdout?.on('data', onData)
p.stderr?.on('data', onData)
p.on('close', (code) => {
resolve({ code: typeof code === 'number' ? code : -1, output: Buffer.concat(chunks).toString('utf8') })
})
p.on('error', () => {
resolve({ code: -1, output: '无法执行 sing-box,请检查可执行文件是否完整' })
})
})
}
private buildHelpfulErrorDetail(lines: string[]): string {
const text = lines.join('\n').toLowerCase()
const hints: string[] = []
console.log(text);
if (text.includes('create tun') || text.includes('tun') || text.includes('wintun') || text.includes('tunnel')) {
hints.push('提示:当前使用 TUN 模式,Windows 下通常需要管理员权限,并且需要可用的 TUN/Wintun 支持。请尝试“以管理员身份运行”LagZero。')
}
if (text.includes('access is denied') || text.includes('permission denied') || text.includes('operation not permitted')) {
hints.push('提示:看起来是权限不足导致启动失败,请尝试“以管理员身份运行”。')
}
if (text.includes('invalid') || text.includes('unknown field') || text.includes('parse') || text.includes('json')) {
hints.push('提示:节点/配置参数可能不兼容当前 sing-box 版本,请把弹窗里的最后一行报错发我。')
}
if (text.includes('deprecated_special_outbounds') || text.includes('deprecated special outbounds') || text.includes('enable_deprecated_special_outbounds')) {
hints.push('提示:当前 sing-box 版本要求开启兼容开关,我已在程序内自动注入环境变量 ENABLE_DEPRECATED_SPECIAL_OUTBOUNDS=true。')
}
if (text.includes('deprecated_tun_address_x') || text.includes('deprecated tun address x') || text.includes('enable_deprecated_tun_address_x')) {
hints.push('提示:当前 sing-box 版本要求开启 TUN 兼容开关,我已在程序内自动注入环境变量 ENABLE_DEPRECATED_TUN_ADDRESS_X=true。')
}
if (hints.length === 0) return ''
return Array.from(new Set(hints)).join('\n')
}
private getSingboxEnv(): NodeJS.ProcessEnv {
return {
...process.env,
ENABLE_DEPRECATED_SPECIAL_OUTBOUNDS: 'true',
ENABLE_DEPRECATED_TUN_ADDRESS_X: 'true'
}
}
private stripAnsi(input: string): string {
return input.replace(/\x1B\[[0-9;]*m/g, '')
}
/**
* 动态更新进程匹配规则
*
* 修改 config.json 中的 route.rules 和 dns.rules,添加或更新进程名列表,
* 然后重启 sing-box 以生效。
*
* @param processNames - 最新的需要代理的进程名列表
*/
async updateProcessNames(processNames: string[]): Promise<void> {
this.processRuleUpdateQueue = this.processRuleUpdateQueue
.then(() => this.applyProcessNameUpdate(processNames))
.catch((err) => {
const msg = `更新进程规则失败: ${String((err as any)?.message || err)}`
this.log(msg, 'error')
})
return this.processRuleUpdateQueue
}
private async applyProcessNameUpdate(processNames: string[]): Promise<void> {
const configPath = path.join(app.getPath('userData'), 'config.json')
if (!await fs.pathExists(configPath)) return
if (!this.process) return
const normalized = normalizeProcessNames(processNames)
if (normalized.length === 0) return
let configRaw = ''
try {
configRaw = await fs.readFile(configPath, 'utf8')
} catch {
return
}
let config: any
try {
config = JSON.parse(configRaw)
} catch {
return
}
let changed = false
const routeRules = Array.isArray(config?.route?.rules) ? config.route.rules : []
const routeRule = routeRules.find((r: any) => Array.isArray(r?.process_name) && r?.outbound === 'proxy')
if (routeRule) {
if (!areStringArraysEqual(routeRule.process_name, normalized)) {
routeRule.process_name = normalized
changed = true
}
} else {
routeRules.push({ process_name: normalized, outbound: 'proxy' })
if (config?.route) config.route.rules = routeRules
changed = true
}
const dnsRules = Array.isArray(config?.dns?.rules) ? config.dns.rules : []
const hasRemotePrimaryServer = Array.isArray(config?.dns?.servers)
&& config.dns.servers.some((s: any) => s?.tag === 'remote-primary')
if (hasRemotePrimaryServer) {
const dnsRule = dnsRules.find((r: any) => Array.isArray(r?.process_name) && r?.server === 'remote-primary')
if (dnsRule) {
if (!areStringArraysEqual(dnsRule.process_name, normalized)) {
dnsRule.process_name = normalized
changed = true
}
} else {
dnsRules.unshift({ process_name: normalized, server: 'remote-primary' })
if (config?.dns) config.dns.rules = dnsRules
changed = true
}
}
if (!changed) return
await fs.writeFile(configPath, JSON.stringify(config, null, 2))
this.log(`检测到子进程,已更新进程匹配规则(${normalized.length})并重启 sing-box`)
await this.restartWithCurrentConfig(configPath)
}
private async restartWithCurrentConfig(configPath: string): Promise<void> {
this.suppressNextStoppedStatus = true
await this.stopAndWaitForExit(5000)
await this.start(configPath)
}
private async stopAndWaitForExit(timeoutMs: number): Promise<void> {
if (!this.process) return
const current = this.process
this.stop()
await new Promise<void>((resolve) => {
let done = false
const finish = () => {
if (done) return
done = true
resolve()
}
const timer = setTimeout(finish, timeoutMs)
current.once('close', () => {
clearTimeout(timer)
finish()
})
})
}
private async downloadAndInstallBinary(platform: NodeJS.Platform, arch: string): Promise<string> {
const ext = platform === 'win32' ? '.exe' : ''
const binPath = path.join(BIN_DIR, `sing-box${ext}`)
if (await fs.pathExists(binPath)) return binPath
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()}`)
try {
await fs.ensureDir(extractDir)
await this.downloadToFile(downloadUrl, archivePath)
await this.extractAndInstall(archivePath, archiveExt, extractDir, binPath, platform === 'win32')
if (!await fs.pathExists(binPath)) {
throw new Error('安装完成后仍未找到 sing-box 可执行文件')
}
this.log(`sing-box 已安装:${binPath}`)
return binPath
} catch (e: any) {
const msg = `sing-box 自动下载/安装失败:${String(e?.message || e)}`
this.log(msg, 'error')
this.mainWindow?.webContents.send('singbox-error', msg)
throw new Error(msg)
} finally {
await fs.remove(extractDir).catch(() => {})
await fs.remove(archivePath).catch(() => {})
}
}
private async resolveLatestReleaseDownload(platform: NodeJS.Platform, arch: string): Promise<{ version: string, downloadUrl: string, archiveExt: string }> {
const platformName = platform === 'win32' ? 'windows'
: platform === 'darwin' ? 'darwin'
: platform === 'linux' ? 'linux'
: null
if (!platformName) {
throw new Error(`不支持的平台:${platform}`)
}
const archName = arch === 'x64' ? 'amd64'
: arch === 'arm64' ? 'arm64'
: arch === 'ia32' ? '386'
: null
if (!archName) {
throw new Error(`不支持的架构:${arch}`)
}
const archiveExt = platformName === 'windows' ? '.zip' : '.tar.gz'
const release = await this.fetchJson<any>('https://api.github.com/repos/SagerNet/sing-box/releases/latest')
const tagName = String(release?.tag_name || '')
const version = tagName.startsWith('v') ? tagName.slice(1) : tagName
if (!version) throw new Error('无法解析 sing-box 版本号')
const assetName = `sing-box-${version}-${platformName}-${archName}${archiveExt}`
const assets: any[] = Array.isArray(release?.assets) ? release.assets : []
const asset = assets.find(a => String(a?.name || '') === assetName)
const downloadUrl = String(asset?.browser_download_url || '')
if (!downloadUrl) {
throw new Error(`未找到对应的发布包:${assetName}`)
}
return { version, downloadUrl, archiveExt }
}
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[] = []
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 失败'))
}
})
})
req.on('error', reject)
req.end()
})
}
private async downloadToFile(url: string, destPath: string): Promise<void> {
await fs.ensureDir(path.dirname(destPath))
const doRequest = (u: string, redirectLeft: number): Promise<void> => new Promise((resolve, reject) => {
const req = https.request(u, {
method: 'GET',
headers: { 'User-Agent': 'LagZero' }
}, (res) => {
const status = res.statusCode || 0
const location = res.headers.location
if ([301, 302, 303, 307, 308].includes(status) && location && redirectLeft > 0) {
res.resume()
resolve(doRequest(location, redirectLeft - 1))
return
}
if (status >= 300) {
reject(new Error(`下载失败(${status})`))
res.resume()
return
}
const total = Number(res.headers['content-length'] || 0)
let received = 0
let lastLoggedBucket = -1
const fileStream = createWriteStream(destPath)
res.on('data', (chunk) => {
received += Buffer.byteLength(chunk)
if (total > 0) {
const pct = Math.floor((received / total) * 100)
const bucket = Math.floor(pct / 20) * 20
if (bucket !== lastLoggedBucket && bucket <= 100) {
lastLoggedBucket = bucket
this.log(`正在下载 sing-box:${bucket}%`)
}
}
})
pipeline(res as unknown as Readable, fileStream)
.then(() => resolve())
.catch(reject)
})
req.on('error', reject)
req.end()
})
await doRequest(url, 5)
}
private async extractAndInstall(
archivePath: string,
archiveExt: string,
extractDir: string,
binPath: string,
isWindows: boolean
): Promise<void> {
if (archiveExt === '.zip') {
const zip = new AdmZip(archivePath)
zip.extractAllTo(extractDir, true)
} else if (archiveExt === '.tar.gz') {
await tar.x({ file: archivePath, cwd: extractDir })
} else {
throw new Error(`不支持的压缩格式:${archiveExt}`)
}
const exeName = isWindows ? 'sing-box.exe' : 'sing-box'
const exePath = await this.findFileRecursive(extractDir, exeName)
if (!exePath) {
throw new Error('解压后未找到 sing-box 可执行文件')
}
await fs.copyFile(exePath, binPath)
if (isWindows) {
const exeDir = path.dirname(exePath)
const entries = await fs.readdir(exeDir, { withFileTypes: true })
const dlls = entries
.filter(e => e.isFile() && e.name.toLowerCase().endsWith('.dll'))
.map(e => path.join(exeDir, e.name))
for (const dll of dlls) {
const target = path.join(BIN_DIR, path.basename(dll))
await fs.copyFile(dll, target)
}
}
await fs.chmod(binPath, 0o755).catch(() => {})
}
private async findFileRecursive(rootDir: string, fileName: string): Promise<string | null> {
const entries = await fs.readdir(rootDir, { withFileTypes: true })
for (const entry of entries) {
const full = path.join(rootDir, entry.name)
if (entry.isFile() && entry.name === fileName) return full
}
for (const entry of entries) {
if (!entry.isDirectory()) continue
const found = await this.findFileRecursive(path.join(rootDir, entry.name), fileName)
if (found) return found
}
return null
}
}
+165
View File
@@ -0,0 +1,165 @@
import fs from 'fs-extra'
import path from 'path'
import { app } from 'electron'
import { normalizeProcessNames, areStringArraysEqual } from '../../utils/process-helper'
import { LogFn } from './installer'
import { getSingboxEnv, stripAnsi } from './utils'
import { spawn } from 'child_process'
/**
* Sing-box 配置管理器
* 负责配置文件校验、规则更新等操作
*/
export class SingBoxConfigManager {
private log: LogFn
constructor(logFn: LogFn) {
this.log = logFn
}
/**
* 校验 sing-box 配置文件是否合法
* @param binPath sing-box 可执行文件路径
* @param configPath 配置文件路径
*/
async validateConfig(binPath: string, configPath: string): Promise<void> {
const { code, output } = await this.runCheck(binPath, configPath)
if (code === 0) return
const lines = output
.split(/\r?\n/g)
.map(l => stripAnsi(l.trim()))
.filter(Boolean)
.slice(-12)
const detail = this.buildHelpfulErrorDetail(lines)
const msg = `sing-box 配置校验失败(code=${String(code)})${lines.length ? `:${lines.at(-1)}` : ''}${detail ? `\n\n${detail}` : ''}`
this.log(msg, 'error')
throw new Error(msg)
}
/**
* 运行 `sing-box check` 命令
*/
private runCheck(binPath: string, configPath: string): Promise<{ code: number, output: string }> {
return new Promise((resolve) => {
const p = spawn(binPath, ['check', '-c', configPath], { windowsHide: true, env: getSingboxEnv() })
const chunks: Buffer[] = []
const onData = (d: any) => chunks.push(Buffer.isBuffer(d) ? d : Buffer.from(String(d)))
p.stdout?.on('data', onData)
p.stderr?.on('data', onData)
p.on('close', (code) => {
resolve({ code: typeof code === 'number' ? code : -1, output: Buffer.concat(chunks).toString('utf8') })
})
p.on('error', () => {
resolve({ code: -1, output: '无法执行 sing-box,请检查可执行文件是否完整' })
})
})
}
/**
* 根据错误日志构建用户友好的提示信息
*/
buildHelpfulErrorDetail(lines: string[]): string {
const text = lines.join('\n').toLowerCase()
const hints: string[] = []
if (text.includes('create tun') || text.includes('tun') || text.includes('wintun') || text.includes('tunnel')) {
hints.push('提示:当前使用 TUN 模式,Windows 下通常需要管理员权限,并且需要可用的 TUN/Wintun 支持。请尝试“以管理员身份运行”LagZero。')
}
if (text.includes('access is denied') || text.includes('permission denied') || text.includes('operation not permitted')) {
hints.push('提示:看起来是权限不足导致启动失败,请尝试“以管理员身份运行”。')
}
if (text.includes('invalid') || text.includes('unknown field') || text.includes('parse') || text.includes('json')) {
hints.push('提示:节点/配置参数可能不兼容当前 sing-box 版本,请把弹窗里的最后一行报错发我。')
}
if (text.includes('deprecated_special_outbounds') || text.includes('deprecated special outbounds') || text.includes('enable_deprecated_special_outbounds')) {
hints.push('提示:当前 sing-box 版本要求开启兼容开关,我已在程序内自动注入环境变量 ENABLE_DEPRECATED_SPECIAL_OUTBOUNDS=true。')
}
if (text.includes('deprecated_tun_address_x') || text.includes('deprecated tun address x') || text.includes('enable_deprecated_tun_address_x')) {
hints.push('提示:当前 sing-box 版本要求开启 TUN 兼容开关,我已在程序内自动注入环境变量 ENABLE_DEPRECATED_TUN_ADDRESS_X=true。')
}
if (text.includes('mtu') || text.includes('fragment') || text.includes('message too long') || text.includes('packet too large')) {
hints.push('提示:可能是 MTU/分片导致的问题。请在“会话网络优化”里尝试把 MTU 调低到 1280 或 1240。')
}
if (text.includes('udp') || text.includes('quic') || text.includes('xudp')) {
hints.push('提示:检测到 UDP/QUIC 相关异常。请把 UDP 模式改为“自动”或“优先 TCP”,必要时关闭 xudp 覆盖。')
}
if (text.includes('address already in use') || text.includes('only one usage of each socket address') || text.includes('bind:')) {
hints.push('提示:本地端口被占用。请修改“系统代理端口/本地代理端口”,或关闭占用该端口的软件后重试。')
}
if (hints.length === 0) return ''
return Array.from(new Set(hints)).join('\n')
}
/**
* 更新配置文件中的进程匹配规则
* @param processNames 需要代理的进程名列表
* @returns 是否有更新
*/
async updateProcessNames(processNames: string[]): Promise<boolean> {
const configPath = path.join(app.getPath('userData'), 'config.json')
if (!await fs.pathExists(configPath)) return false
const normalized = normalizeProcessNames(processNames)
if (normalized.length === 0) return false
let configRaw = ''
try {
configRaw = await fs.readFile(configPath, 'utf8')
} catch {
return false
}
let config: any
try {
config = JSON.parse(configRaw)
} catch {
return false
}
let changed = false
const routeRules = Array.isArray(config?.route?.rules) ? config.route.rules : []
const routeRule = routeRules.find((r: any) => Array.isArray(r?.process_name) && r?.outbound === 'proxy')
if (routeRule) {
if (!areStringArraysEqual(routeRule.process_name, normalized)) {
routeRule.process_name = normalized
changed = true
}
} else {
routeRules.push({ process_name: normalized, outbound: 'proxy' })
if (config?.route) config.route.rules = routeRules
changed = true
}
const dnsRules = Array.isArray(config?.dns?.rules) ? config.dns.rules : []
const hasRemotePrimaryServer = Array.isArray(config?.dns?.servers)
&& config.dns.servers.some((s: any) => s?.tag === 'remote-primary')
if (hasRemotePrimaryServer) {
const dnsRule = dnsRules.find((r: any) => Array.isArray(r?.process_name) && r?.server === 'remote-primary')
if (dnsRule) {
if (!areStringArraysEqual(dnsRule.process_name, normalized)) {
dnsRule.process_name = normalized
changed = true
}
} else {
dnsRules.unshift({ process_name: normalized, server: 'remote-primary' })
if (config?.dns) config.dns.rules = dnsRules
changed = true
}
}
if (changed) {
await fs.writeFile(configPath, JSON.stringify(config, null, 2))
}
return changed
}
}
+8
View File
@@ -0,0 +1,8 @@
import path from 'path'
import { app } from 'electron'
/**
* 二进制文件存储目录
* 位于用户数据目录下的 bin 文件夹中
*/
export const BIN_DIR = path.join(app.getPath('userData'), 'bin')
+240
View File
@@ -0,0 +1,240 @@
import { spawn, ChildProcess } from 'child_process'
import path from 'path'
import fs from 'fs-extra'
import { app, BrowserWindow, ipcMain } from 'electron'
import { SingBoxInstaller } from './installer'
import { SingBoxConfigManager } from './config'
import { getSingboxEnv, stripAnsi } from './utils'
/**
* Sing-box 核心服务
*
* 负责管理 sing-box 内核的生命周期,包括:
* 1. 自动下载与安装内核
* 2. 启动/停止/重启内核进程
* 3. 实时日志捕获与分发
* 4. 动态更新路由规则 (Process Rules)
*/
export class SingBoxService {
private process: ChildProcess | null = null
private mainWindow: BrowserWindow | null = null
private retryCount = 0
private maxRetries = 3
private lastLogs: string[] = []
private isStopping = false
private suppressNextStoppedStatus = false
private processRuleUpdateQueue: Promise<void> = Promise.resolve()
private installer: SingBoxInstaller
private configManager: SingBoxConfigManager
constructor(window: BrowserWindow) {
this.mainWindow = window
const logFn = (msg: string, type: 'info' | 'error' = 'info') => this.log(msg, type)
this.installer = new SingBoxInstaller(logFn)
this.configManager = new SingBoxConfigManager(logFn)
this.setupIPC()
}
/**
* 注册 IPC 监听器
*/
private setupIPC() {
ipcMain.handle('singbox-start', async (_, configContent: string) => {
const configPath = path.join(app.getPath('userData'), 'config.json')
await fs.writeFile(configPath, configContent)
await this.start(configPath)
})
ipcMain.handle('singbox-stop', () => this.stop())
ipcMain.handle('singbox-restart', async (_, configContent: string) => {
this.suppressNextStoppedStatus = true
await this.stopAndWaitForExit(5000)
const configPath = path.join(app.getPath('userData'), 'config.json')
await fs.writeFile(configPath, configContent)
await this.start(configPath)
})
}
/**
* 启动 sing-box 内核
* @param configPath 配置文件路径
*/
async start(configPath: string) {
if (this.process) {
this.log('Process already running', 'info')
return
}
this.isStopping = false
const binPath = await this.installer.checkAndDownloadBinary()
try {
await this.configManager.validateConfig(binPath, configPath)
} catch (e: any) {
this.mainWindow?.webContents.send('singbox-error', e.message)
throw e
}
this.log('正在启动 sing-box...')
this.lastLogs = []
const pushLog = (line: string) => {
const trimmed = stripAnsi(String(line || '').trim())
if (!trimmed) return
this.lastLogs.push(trimmed)
if (this.lastLogs.length > 80) this.lastLogs.shift()
}
this.process = spawn(binPath, ['run', '-c', configPath], { windowsHide: true, env: getSingboxEnv() })
this.process.stdout?.on('data', (data) => {
const text = data.toString().trim()
pushLog(text)
this.log(stripAnsi(text))
})
this.process.stderr?.on('data', (data) => {
const text = data.toString().trim()
pushLog(text)
this.log(stripAnsi(text), 'error')
})
const started = await new Promise<void>((resolve, reject) => {
let settled = false
const settle = (fn: () => void) => {
if (settled) return
settled = true
fn()
}
const startupTimer = setTimeout(() => {
settle(() => resolve())
}, 800)
this.process?.once('error', (err) => {
clearTimeout(startupTimer)
const msg = `sing-box 启动失败:${String(err?.message || err)}`
this.log(msg, 'error')
this.mainWindow?.webContents.send('singbox-error', msg)
settle(() => reject(new Error(msg)))
})
this.process?.once('close', (code) => {
if (!settled) {
clearTimeout(startupTimer)
const detail = this.configManager.buildHelpfulErrorDetail(this.lastLogs)
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)
settle(() => reject(new Error(msg)))
}
})
})
this.process.on('close', (code) => {
this.log(`sing-box exited with code ${code}`)
this.process = null
if (this.suppressNextStoppedStatus) {
this.suppressNextStoppedStatus = false
} else {
this.sendStatus('stopped')
}
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)
} 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)
}
})
await started
this.sendStatus('running')
this.retryCount = 0
}
/**
* 停止 sing-box 内核
*/
stop() {
if (this.process) {
this.isStopping = true
this.retryCount = 0
const current = this.process
const pid = typeof current.pid === 'number' ? current.pid : null
current.kill('SIGINT')
setTimeout(() => {
if (!this.process) return
if (this.process !== current) return
if (process.platform === 'win32' && pid) {
spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true })
} else {
this.process.kill('SIGKILL')
}
}, 800)
}
}
private log(message: string, type: 'info' | 'error' = 'info') {
console.log(`[SingBox] ${message}`)
this.mainWindow?.webContents.send('singbox-log', { message, type, timestamp: Date.now() })
}
private sendStatus(status: 'running' | 'stopped') {
this.mainWindow?.webContents.send('singbox-status', status)
}
/**
* 动态更新进程匹配规则
*
* @param processNames 需要代理的进程名列表
*/
async updateProcessNames(processNames: string[]): Promise<void> {
this.processRuleUpdateQueue = this.processRuleUpdateQueue
.then(async () => {
const changed = await this.configManager.updateProcessNames(processNames)
if (changed && this.process) {
const configPath = path.join(app.getPath('userData'), 'config.json')
this.log(`检测到子进程,已更新进程匹配规则(${processNames.length})并重启 sing-box`)
await this.restartWithCurrentConfig(configPath)
}
})
.catch((err) => {
const msg = `更新进程规则失败: ${String((err as any)?.message || err)}`
this.log(msg, 'error')
})
return this.processRuleUpdateQueue
}
private async restartWithCurrentConfig(configPath: string): Promise<void> {
this.suppressNextStoppedStatus = true
await this.stopAndWaitForExit(5000)
await this.start(configPath)
}
private async stopAndWaitForExit(timeoutMs: number): Promise<void> {
if (!this.process) return
const current = this.process
this.stop()
await new Promise<void>((resolve) => {
let done = false
const finish = () => {
if (done) return
done = true
resolve()
}
const timer = setTimeout(finish, timeoutMs)
current.once('close', () => {
clearTimeout(timer)
finish()
})
})
}
}
+267
View File
@@ -0,0 +1,267 @@
import fs from 'fs-extra'
import path from 'path'
import AdmZip from 'adm-zip'
import * as tar from 'tar'
import { pipeline } from 'stream/promises'
import { createWriteStream } from 'fs'
import { Readable } from 'stream'
import https from 'node:https'
import { generateId } from '../../utils/id'
import { BIN_DIR } from './constants'
/**
* 日志回调函数类型
*/
export type LogFn = (message: string, type?: 'info' | 'error') => void
/**
* Sing-box 安装器
* 负责检查、下载和安装 sing-box 内核
*/
export class SingBoxInstaller {
private log: LogFn
constructor(logFn: LogFn) {
this.log = logFn
}
/**
* 检查 sing-box 是否存在,不存在则自动下载
* @returns 可执行文件的绝对路径
*/
async checkAndDownloadBinary(): 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}`)
if (await fs.pathExists(binPath)) {
return binPath
}
return this.downloadAndInstallBinary(platform, arch, binPath)
}
/**
* 执行下载和安装流程
*/
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()}`)
try {
await fs.ensureDir(extractDir)
await this.downloadToFile(downloadUrl, archivePath)
await this.extractAndInstall(archivePath, archiveExt, extractDir, binPath, platform === 'win32')
if (!await fs.pathExists(binPath)) {
throw new Error('安装完成后仍未找到 sing-box 可执行文件')
}
this.log(`sing-box 已安装:${binPath}`)
return binPath
} catch (e: any) {
const msg = `sing-box 自动下载/安装失败:${String(e?.message || e)}`
this.log(msg, 'error')
throw new Error(msg)
} finally {
await fs.remove(extractDir).catch(() => {})
await fs.remove(archivePath).catch(() => {})
}
}
/**
* 解析 GitHub 最新 Release 版本信息
*/
private async resolveLatestReleaseDownload(platform: NodeJS.Platform, arch: string): Promise<{ version: string, downloadUrl: string, archiveExt: string }> {
const platformName = platform === 'win32' ? 'windows'
: platform === 'darwin' ? 'darwin'
: platform === 'linux' ? 'linux'
: null
if (!platformName) {
throw new Error(`不支持的平台:${platform}`)
}
const archName = arch === 'x64' ? 'amd64'
: arch === 'arm64' ? 'arm64'
: arch === 'ia32' ? '386'
: null
if (!archName) {
throw new Error(`不支持的架构:${arch}`)
}
const archiveExt = platformName === 'windows' ? '.zip' : '.tar.gz'
const release = await this.fetchJson<any>('https://api.github.com/repos/SagerNet/sing-box/releases/latest')
const tagName = String(release?.tag_name || '')
const version = tagName.startsWith('v') ? tagName.slice(1) : tagName
if (!version) throw new Error('无法解析 sing-box 版本号')
const assetName = `sing-box-${version}-${platformName}-${archName}${archiveExt}`
const assets: any[] = Array.isArray(release?.assets) ? release.assets : []
const asset = assets.find(a => String(a?.name || '') === assetName)
const downloadUrl = String(asset?.browser_download_url || '')
if (!downloadUrl) {
throw new Error(`未找到对应的发布包:${assetName}`)
}
return { version, downloadUrl, archiveExt }
}
/**
* 简单的 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[] = []
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 失败'))
}
})
})
req.on('error', reject)
req.end()
})
}
/**
* 下载文件到本地
*/
private async downloadToFile(url: string, destPath: string): Promise<void> {
await fs.ensureDir(path.dirname(destPath))
const doRequest = (u: string, redirectLeft: number): Promise<void> => new Promise((resolve, reject) => {
const req = https.request(u, {
method: 'GET',
headers: { 'User-Agent': 'LagZero' }
}, (res) => {
const status = res.statusCode || 0
const location = res.headers.location
if ([301, 302, 303, 307, 308].includes(status) && location && redirectLeft > 0) {
res.resume()
resolve(doRequest(location, redirectLeft - 1))
return
}
if (status >= 300) {
reject(new Error(`下载失败(${status})`))
res.resume()
return
}
const total = Number(res.headers['content-length'] || 0)
let received = 0
let lastLoggedBucket = -1
const fileStream = createWriteStream(destPath)
res.on('data', (chunk) => {
received += Buffer.byteLength(chunk)
if (total > 0) {
const pct = Math.floor((received / total) * 100)
const bucket = Math.floor(pct / 20) * 20
if (bucket !== lastLoggedBucket && bucket <= 100) {
lastLoggedBucket = bucket
this.log(`正在下载 sing-box:${bucket}%`)
}
}
})
pipeline(res as unknown as Readable, fileStream)
.then(() => resolve())
.catch(reject)
})
req.on('error', reject)
req.end()
})
await doRequest(url, 5)
}
/**
* 解压并安装二进制文件
*/
private async extractAndInstall(
archivePath: string,
archiveExt: string,
extractDir: string,
binPath: string,
isWindows: boolean
): Promise<void> {
if (archiveExt === '.zip') {
const zip = new AdmZip(archivePath)
zip.extractAllTo(extractDir, true)
} else if (archiveExt === '.tar.gz') {
await tar.x({ file: archivePath, cwd: extractDir })
} else {
throw new Error(`不支持的压缩格式:${archiveExt}`)
}
const exeName = isWindows ? 'sing-box.exe' : 'sing-box'
const exePath = await this.findFileRecursive(extractDir, exeName)
if (!exePath) {
throw new Error('解压后未找到 sing-box 可执行文件')
}
await fs.copyFile(exePath, binPath)
if (isWindows) {
// 复制可能存在的 DLL 文件
const exeDir = path.dirname(exePath)
const entries = await fs.readdir(exeDir, { withFileTypes: true })
const dlls = entries
.filter(e => e.isFile() && e.name.toLowerCase().endsWith('.dll'))
.map(e => path.join(exeDir, e.name))
for (const dll of dlls) {
const target = path.join(BIN_DIR, path.basename(dll))
await fs.copyFile(dll, target)
}
}
await fs.chmod(binPath, 0o755).catch(() => {})
}
/**
* 递归查找文件
*/
private async findFileRecursive(rootDir: string, fileName: string): Promise<string | null> {
const entries = await fs.readdir(rootDir, { withFileTypes: true })
for (const entry of entries) {
const full = path.join(rootDir, entry.name)
if (entry.isFile() && entry.name === fileName) return full
}
for (const entry of entries) {
if (!entry.isDirectory()) continue
const found = await this.findFileRecursive(path.join(rootDir, entry.name), fileName)
if (found) return found
}
return null
}
}
+20
View File
@@ -0,0 +1,20 @@
/**
* 移除字符串中的 ANSI 颜色代码
* @param input 包含 ANSI 代码的字符串
* @returns 纯文本字符串
*/
export function stripAnsi(input: string): string {
return input.replace(/\x1B\[[0-9;]*m/g, '')
}
/**
* 获取运行 sing-box 所需的环境变量
* 注入了兼容性开关,以支持新版 sing-box 的某些过时特性
*/
export function getSingboxEnv(): NodeJS.ProcessEnv {
return {
...process.env,
ENABLE_DEPRECATED_SPECIAL_OUTBOUNDS: 'true',
ENABLE_DEPRECATED_TUN_ADDRESS_X: 'true'
}
}
+241 -6
View File
@@ -5,18 +5,59 @@ import { ping, tcpPing } from '../utils/ping'
import nodeNet from 'node:net'
import nodeTls from 'node:tls'
/**
* 系统命令执行结果
*/
type SystemCommandResult = {
/** 命令是否执行成功 */
ok: boolean
/** 退出代码 */
code: number
/** 标准输出内容 */
output: string
/** 结果消息或错误提示 */
message: string
}
/**
* Windows 系统代理配置快照
* 用于备份和恢复代理设置
*/
type SystemProxySnapshot = {
ProxyEnable?: number
ProxyServer?: string
ProxyOverride?: string
AutoConfigURL?: string
AutoDetect?: number
}
/**
* 系统代理操作结果
*/
type SystemProxyResult = {
ok: boolean
message: string
snapshot?: SystemProxySnapshot
}
/**
* 系统服务
*
* 提供底层系统操作能力,包括:
* - 网络工具 (Ping, TCP Ping, DNS 刷新)
* - 虚拟网卡管理
* - 系统代理设置与清除
* - HTTP 代理连通性测试
*/
export class SystemService {
constructor() {
this.registerIPC()
}
/**
* 刷新系统 DNS 缓存
* 目前仅支持 Windows
*/
async flushDnsCache(): Promise<SystemCommandResult> {
if (process.platform === 'win32') {
const { code, output } = await runCommand('ipconfig', ['/flushdns'])
@@ -36,6 +77,11 @@ export class SystemService {
}
}
/**
* 重置 TUN 虚拟网卡
* 尝试卸载指定的网络适配器,以便 sing-box 重新创建
* @param interfaceName 网卡名称,默认为 'LagZero'
*/
async reinstallTunAdapter(interfaceName: string = 'LagZero'): Promise<SystemCommandResult> {
if (process.platform !== 'win32') {
return {
@@ -74,6 +120,16 @@ export class SystemService {
}
}
/**
* 测试 HTTP 代理连通性
*
* 尝试通过指定的本地代理连接到目标服务器,支持 HTTPS 握手验证
*
* @param proxyPort 代理端口
* @param targetHost 目标主机
* @param targetPort 目标端口 (默认 443)
* @param timeoutMs 超时时间 (默认 5000ms)
*/
async testHttpProxyConnect(proxyPort: number, targetHost: string, targetPort: number = 443, timeoutMs: number = 5000) {
return new Promise<{ ok: boolean, statusLine: string, error?: string }>((resolve) => {
const socket = nodeNet.createConnection({ host: '127.0.0.1', port: proxyPort })
@@ -151,17 +207,187 @@ export class SystemService {
})
}
/**
* 获取当前系统代理状态 (仅 Windows)
*/
async getSystemProxyState(): Promise<{ ok: boolean, message: string, state?: SystemProxySnapshot }> {
if (process.platform !== 'win32') {
return {
ok: false,
message: `Unsupported platform: ${process.platform}`
}
}
try {
const state = await this.readWindowsSystemProxyState()
return { ok: true, message: 'OK', state }
} catch (e: any) {
return { ok: false, message: String(e?.message || e) }
}
}
/**
* 设置系统代理 (仅 Windows)
* 会自动备份当前代理设置
*
* @param port 代理端口
* @param bypass 不走代理的地址列表
*/
async setSystemProxy(port: number, bypass: string = '<local>'): Promise<SystemProxyResult> {
if (process.platform !== 'win32') {
return {
ok: false,
message: `Unsupported platform: ${process.platform}`
}
}
const proxyPort = Math.max(1, Math.floor(Number(port || 0)))
if (!proxyPort) {
return {
ok: false,
message: 'Invalid proxy port'
}
}
const snapshot = await this.readWindowsSystemProxyState()
const safeBypass = String(bypass || '<local>').replace(/'/g, "''")
const safeServer = `127.0.0.1:${proxyPort}`.replace(/'/g, "''")
const psScript = [
"$path = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'",
"$old = Get-ItemProperty -Path $path",
"Set-ItemProperty -Path $path -Name ProxyEnable -Type DWord -Value 1",
`Set-ItemProperty -Path $path -Name ProxyServer -Type String -Value '${safeServer}'`,
`Set-ItemProperty -Path $path -Name ProxyOverride -Type String -Value '${safeBypass}'`,
"Set-ItemProperty -Path $path -Name AutoDetect -Type DWord -Value 0",
"Remove-ItemProperty -Path $path -Name AutoConfigURL -ErrorAction SilentlyContinue",
"Write-Output 'system proxy configured'"
].join('; ')
const { code, output } = await runCommand('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', psScript], 15000)
if (code !== 0) {
return {
ok: false,
message: `Failed to set system proxy: ${output || `code=${code}`}`,
snapshot
}
}
return {
ok: true,
message: 'System proxy enabled',
snapshot
}
}
/**
* 清除或恢复系统代理设置
* @param snapshot 如果提供,则恢复到该快照状态;否则直接清除代理
*/
async clearSystemProxy(snapshot?: SystemProxySnapshot): Promise<SystemProxyResult> {
if (process.platform !== 'win32') {
return {
ok: false,
message: `Unsupported platform: ${process.platform}`
}
}
const psScript = this.buildClearSystemProxyScript(snapshot)
const { code, output } = await runCommand('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', psScript], 15000)
if (code !== 0) {
return {
ok: false,
message: `Failed to clear system proxy: ${output || `code=${code}`}`
}
}
return {
ok: true,
message: 'System proxy cleared'
}
}
/**
* 构建清除或恢复系统代理的 PowerShell 脚本
*/
private buildClearSystemProxyScript(snapshot?: SystemProxySnapshot): string {
const hasSnapshot = !!snapshot && typeof snapshot === 'object'
if (!hasSnapshot) {
return [
"$path = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'",
"Set-ItemProperty -Path $path -Name ProxyEnable -Type DWord -Value 0",
"Set-ItemProperty -Path $path -Name AutoDetect -Type DWord -Value 0",
"Remove-ItemProperty -Path $path -Name ProxyServer -ErrorAction SilentlyContinue",
"Remove-ItemProperty -Path $path -Name AutoConfigURL -ErrorAction SilentlyContinue",
"Write-Output 'system proxy cleared'"
].join('; ')
}
const proxyEnable = Number(snapshot?.ProxyEnable || 0)
const autoDetect = Number(snapshot?.AutoDetect || 0)
const proxyServer = String(snapshot?.ProxyServer || '').replace(/'/g, "''")
const proxyOverride = String(snapshot?.ProxyOverride || '').replace(/'/g, "''")
const autoConfigUrl = String(snapshot?.AutoConfigURL || '').replace(/'/g, "''")
return [
"$path = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'",
`Set-ItemProperty -Path $path -Name ProxyEnable -Type DWord -Value ${Number.isFinite(proxyEnable) ? proxyEnable : 0}`,
`Set-ItemProperty -Path $path -Name AutoDetect -Type DWord -Value ${Number.isFinite(autoDetect) ? autoDetect : 0}`,
proxyServer
? `Set-ItemProperty -Path $path -Name ProxyServer -Type String -Value '${proxyServer}'`
: "Remove-ItemProperty -Path $path -Name ProxyServer -ErrorAction SilentlyContinue",
proxyOverride
? `Set-ItemProperty -Path $path -Name ProxyOverride -Type String -Value '${proxyOverride}'`
: "Remove-ItemProperty -Path $path -Name ProxyOverride -ErrorAction SilentlyContinue",
autoConfigUrl
? `Set-ItemProperty -Path $path -Name AutoConfigURL -Type String -Value '${autoConfigUrl}'`
: "Remove-ItemProperty -Path $path -Name AutoConfigURL -ErrorAction SilentlyContinue",
"Write-Output 'system proxy restored'"
].join('; ')
}
/**
* 读取 Windows 系统代理当前状态
*/
private async readWindowsSystemProxyState(): Promise<SystemProxySnapshot> {
const psScript = `
$path = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'
$p = Get-ItemProperty -Path $path
$result = [ordered]@{
ProxyEnable = [int]($p.ProxyEnable)
ProxyServer = [string]($p.ProxyServer)
ProxyOverride = [string]($p.ProxyOverride)
AutoConfigURL = [string]($p.AutoConfigURL)
AutoDetect = [int]($p.AutoDetect)
}
$result | ConvertTo-Json -Compress
`.trim()
const { code, output } = await runCommand('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', psScript], 10000)
if (code !== 0) {
throw new Error(output || `read system proxy state failed: code=${code}`)
}
try {
return JSON.parse(output || '{}') as SystemProxySnapshot
} catch {
throw new Error(`parse system proxy state failed: ${output}`)
}
}
/**
* 注册 IPC 监听器
*/
private registerIPC() {
ipcMain.handle('system:flush-dns-cache', () => this.flushDnsCache())
ipcMain.handle('system:flush-dns-cache', () => this.flushDnsCache()) // 刷新 DNS 缓存
ipcMain.handle('system:tun-reinstall', async (_, interfaceName?: string) => {
return this.reinstallTunAdapter(interfaceName || 'LagZero')
})
}) // 重装 TUN 适配器
ipcMain.handle('system:find-available-port', async (_, port: number, count?: number) => {
return findAvailablePort(port, count)
})
}) // 查找可用端口
ipcMain.handle('system:test-http-proxy-connect', async (_, proxyPort: number, targetHost: string, targetPort: number = 443, timeoutMs: number = 5000) => {
return this.testHttpProxyConnect(proxyPort, targetHost, targetPort, timeoutMs)
})
}) // 测试 HTTP 代理连接
ipcMain.handle('system:ping', async (_, host: string) => {
try {
const result = await ping(host)
@@ -170,7 +396,7 @@ export class SystemService {
console.warn('[SystemService] system:ping failed', host, e)
return -1
}
})
}) // 测试 ICMP 代理连接
ipcMain.handle('system:tcp-ping', async (_, host: string, port: number) => {
try {
const result = await tcpPing(host, port)
@@ -179,6 +405,15 @@ export class SystemService {
console.warn('[SystemService] system:tcp-ping failed', host, port, e)
return -1
}
})
}) // 测试 TCP 代理连接
ipcMain.handle('system:set-system-proxy', async (_, port: number, bypass?: string) => {
return this.setSystemProxy(port, bypass)
}) // 设置系统代理
ipcMain.handle('system:clear-system-proxy', async (_, snapshot?: SystemProxySnapshot) => {
return this.clearSystemProxy(snapshot)
}) // 清除系统代理
ipcMain.handle('system:get-system-proxy-state', async () => {
return this.getSystemProxyState()
}) // 获取系统代理状态
}
}
+20
View File
@@ -1,14 +1,34 @@
import { net, app } from 'electron'
/**
* 更新检查结果信息
*/
export type UpdateInfo = {
/** 是否有可用更新 */
updateAvailable?: boolean
/** 最新版本号 */
version?: string
/** 发布日期 */
releaseDate?: string
/** 更新日志 */
releaseNotes?: string
/** 错误信息 */
error?: string
}
/**
* 自动更新服务
*
* 负责检查 GitHub Release 或 Tags 获取最新版本信息。
* 仅提供检查功能,不包含自动下载和安装逻辑(通常由用户手动下载或跳转)。
*/
export class UpdaterService {
/**
* 检查更新
*
* 对比当前应用版本与 GitHub 最新版本。
* @returns Promise<UpdateInfo>
*/
async checkUpdate(): Promise<UpdateInfo> {
try {
const fetchGithub = async (url: string): Promise<string> => {
+2 -1
View File
@@ -88,7 +88,8 @@ export function ping(host: string, timeout = 2000): Promise<PingResult> {
export function tcpPing(host: string, port: number, timeout = 2000): Promise<PingResult> {
return new Promise((resolve) => {
const start = Date.now()
const socket = new net.Socket()
const socket = new net.Socket()
socket.setNoDelay(true)
let resolved = false
+1 -1
View File
@@ -22,7 +22,7 @@ const { isDark, themeColor } = useTheme()
const theme = computed(() => isDark.value ? darkTheme : null)
const themeColors: Record<ThemeColor, { primary: string; hover: string; pressed: string }> = {
green: { primary: '#3eaf7c', hover: '#10b981', pressed: '#047857' },
green: { primary: '#10b981', hover: '#059669', pressed: '#047857' },
blue: { primary: '#3b82f6', hover: '#2563eb', pressed: '#1d4ed8' },
purple: { primary: '#8b5cf6', hover: '#7c3aed', pressed: '#6d28d9' },
orange: { primary: '#f97316', hover: '#ea580c', pressed: '#c2410c' },
+1
View File
@@ -4,4 +4,5 @@ export const categoryApi = {
getAll: () => window.categories.getAll(),
save: (category: Category) => window.categories.save(category),
delete: (id: string) => window.categories.delete(id),
match: (name: string, processes: string | string[]) => window.categories.match(name, processes),
}
+39
View File
@@ -1,3 +1,12 @@
function toIpcSafeSnapshot(snapshot?: any) {
if (snapshot == null) return undefined
try {
return JSON.parse(JSON.stringify(snapshot))
} catch {
return undefined
}
}
export const systemApi = {
scanProcesses: () => window.system.scanProcesses(),
scanLocalGames: () => window.system.scanLocalGames(),
@@ -13,4 +22,34 @@ export const systemApi = {
targetPort?: number,
timeoutMs?: number
) => window.system.testHttpProxyConnect(proxyPort, targetHost, targetPort, timeoutMs),
setSystemProxy: (port: number, bypass?: string) => {
const fn = (window.system as any)?.setSystemProxy
if (typeof fn !== 'function') {
return Promise.resolve({
ok: false,
message: 'system.setSystemProxy is unavailable in current preload. Please restart app or update electron main/preload build.'
})
}
return fn(port, bypass)
},
clearSystemProxy: (snapshot?: any) => {
const fn = (window.system as any)?.clearSystemProxy
if (typeof fn !== 'function') {
return Promise.resolve({
ok: false,
message: 'system.clearSystemProxy is unavailable in current preload. Please restart app or update electron main/preload build.'
})
}
return fn(toIpcSafeSnapshot(snapshot))
},
getSystemProxyState: () => {
const fn = (window.system as any)?.getSystemProxyState
if (typeof fn !== 'function') {
return Promise.resolve({
ok: false,
message: 'system.getSystemProxyState is unavailable in current preload. Please restart app or update electron main/preload build.'
})
}
return fn()
},
}
+19 -56
View File
@@ -35,6 +35,7 @@
--rgb-surface: 255, 255, 255; /* Pure white for cards */
--rgb-surface-panel: 244, 245, 247; /* Panel background */
--rgb-surface-overlay: 238, 240, 243; /* Hover/Input background */
--rgb-surface-variant: 232, 235, 240; /* Stronger chips/media placeholders */
/* Text - Slate Palette for better legibility */
--rgb-on-surface: 15, 23, 42; /* Slate-900 */
@@ -45,7 +46,7 @@
--rgb-border: 226, 232, 240; /* Slate-200 */
--rgb-border-hover: 203, 213, 225; /* Slate-300 */
--rgb-overlay: 0, 0, 0;
--rgb-overlay: 15, 23, 42;
/* CSS Variables for direct usage */
--color-primary: rgb(var(--rgb-primary));
@@ -53,7 +54,7 @@
--color-surface: rgb(var(--rgb-surface));
--color-on-surface: rgb(var(--rgb-on-surface));
--color-border: rgb(var(--rgb-border));
/* Font Base */
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
@@ -121,11 +122,12 @@ html.dark {
--rgb-surface: 18, 18, 22; /* Ebony surface */
--rgb-surface-panel: 28, 28, 32; /* Obsidian panel */
--rgb-surface-overlay: 38, 38, 44; /* Hover state */
--rgb-surface-variant: 48, 48, 56; /* Chips/media placeholders */
/* Text - High Contrast for Dark Mode */
--rgb-on-surface: 248, 250, 252; /* Slate-50 */
--rgb-on-surface-variant: 148, 163, 184; /* Slate-400 */
--rgb-on-surface-muted: 71, 85, 105; /* Slate-600 */
--rgb-on-surface-variant: 203, 213, 225; /* Slate-300 */
--rgb-on-surface-muted: 161, 161, 170; /* Zinc-400, brighter than Slate-600 */
/* Borders - Subdued Obsidian */
--rgb-border: 38, 38, 44; /* Quiet borders */
@@ -133,9 +135,14 @@ html.dark {
/* Adjusted Status Colors for Dark Mode (Vibrant but readable) */
--rgb-success: 52, 211, 153; /* Emerald-400 */
--rgb-success-bg: 6, 78, 59; /* Emerald-900 */
--rgb-warning: 251, 191, 36; /* Amber-400 */
--rgb-warning-bg: 120, 53, 15; /* Amber-900 */
--rgb-error: 251, 113, 133; /* Rose-400 */
--rgb-error-bg: 136, 19, 55; /* Rose-900 */
--rgb-info: 56, 189, 248; /* Sky-400 */
--rgb-info-bg: 12, 74, 110; /* Sky-900 */
--rgb-overlay: 0, 0, 0;
}
/* Transitions */
@@ -283,61 +290,17 @@ html.dark {
caused by Tailwind Preflight or style conflicts.
*/
/* Green Theme (Default) */
html.theme-green .n-button.n-button--primary-type,
.n-button.n-button--primary-type,
html:not([class*="theme-"]) .n-button.n-button--primary-type {
background-color: #3eaf7c !important;
color: #ffffff !important;
border: 1px solid #3eaf7c !important;
background-color: rgb(var(--rgb-primary)) !important;
color: rgb(var(--rgb-on-primary)) !important;
border: 1px solid rgb(var(--rgb-primary)) !important;
}
html.theme-green .n-button.n-button--primary-type:hover,
.n-button.n-button--primary-type:hover,
html:not([class*="theme-"]) .n-button.n-button--primary-type:hover {
background-color: #10b981 !important;
border-color: #10b981 !important;
}
/* Blue Theme */
html.theme-blue .n-button.n-button--primary-type {
background-color: #3b82f6 !important;
color: #ffffff !important;
border: 1px solid #3b82f6 !important;
}
html.theme-blue .n-button.n-button--primary-type:hover {
background-color: #2563eb !important;
border-color: #2563eb !important;
}
/* Purple Theme */
html.theme-purple .n-button.n-button--primary-type {
background-color: #8b5cf6 !important;
color: #ffffff !important;
border: 1px solid #8b5cf6 !important;
}
html.theme-purple .n-button.n-button--primary-type:hover {
background-color: #7c3aed !important;
border-color: #7c3aed !important;
}
/* Orange Theme */
html.theme-orange .n-button.n-button--primary-type {
background-color: #f97316 !important;
color: #ffffff !important;
border: 1px solid #f97316 !important;
}
html.theme-orange .n-button.n-button--primary-type:hover {
background-color: #ea580c !important;
border-color: #ea580c !important;
}
/* Red Theme */
html.theme-red .n-button.n-button--primary-type {
background-color: #ef4444 !important;
color: #ffffff !important;
border: 1px solid #ef4444 !important;
}
html.theme-red .n-button.n-button--primary-type:hover {
background-color: #dc2626 !important;
border-color: #dc2626 !important;
background-color: rgb(var(--rgb-primary-hover)) !important;
border-color: rgb(var(--rgb-primary-hover)) !important;
}
/* Fix for Error and Warning buttons transparency issue */
+4
View File
@@ -17,9 +17,11 @@ declare module 'vue' {
GameEditModal: typeof import('./../components/library/GameEditModal.vue')['default']
GeneralSettings: typeof import('./../components/settings/GeneralSettings.vue')['default']
IconSelector: typeof import('./../components/common/IconSelector.vue')['default']
LanguageToggle: typeof import('./../components/common/LanguageToggle.vue')['default']
LatencyChart: typeof import('./../components/dashboard/LatencyChart.vue')['default']
LogsSettings: typeof import('./../components/settings/LogsSettings.vue')['default']
NButton: typeof import('naive-ui')['NButton']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDivider: typeof import('naive-ui')['NDivider']
@@ -37,6 +39,7 @@ declare module 'vue' {
NodeImportModal: typeof import('./../components/node/NodeImportModal.vue')['default']
NodeManager: typeof import('./../components/node/NodeManager.vue')['default']
NodeSelector: typeof import('./../components/dashboard/NodeSelector.vue')['default']
NPopselect: typeof import('naive-ui')['NPopselect']
NRadio: typeof import('naive-ui')['NRadio']
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NSelect: typeof import('naive-ui')['NSelect']
@@ -47,6 +50,7 @@ declare module 'vue' {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SettingsSidebar: typeof import('./../components/settings/SettingsSidebar.vue')['default']
SubscriptionManagerModal: typeof import('./../components/node/SubscriptionManagerModal.vue')['default']
ThemeToggle: typeof import('./../components/common/ThemeToggle.vue')['default']
}
}
+41
View File
@@ -0,0 +1,41 @@
<template>
<button @click="toggleLanguage"
class="relative w-7 h-7 flex items-center justify-center rounded hover:bg-on-surface/10 transition-colors overflow-hidden group focus:outline-none"
:title="$t('settings.language')" :aria-label="$t('settings.language')">
<div class="relative w-5 h-5">
<div
class="absolute inset-0 transform transition-transform duration-300 ease-spring flex items-center justify-center text-on-surface-variant group-hover:text-on-surface font-bold text-xs"
:class="isZh ? 'opacity-100 scale-100' : 'opacity-0 scale-75'">
ZH
</div>
<div
class="absolute inset-0 transform transition-transform duration-300 ease-spring flex items-center justify-center text-on-surface-variant group-hover:text-on-surface font-bold text-xs"
:class="!isZh ? 'opacity-100 scale-100' : 'opacity-0 scale-75'">
EN
</div>
</div>
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useSettingsStore } from '@/stores/settings'
import { useI18n } from 'vue-i18n'
const settingsStore = useSettingsStore()
const { locale } = useI18n()
const isZh = computed(() => settingsStore.language === 'zh-CN')
function toggleLanguage() {
const next = isZh.value ? 'en-US' : 'zh-CN'
settingsStore.language = next
locale.value = next
}
</script>
<style scoped>
.ease-spring {
transition-timing-function: cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
</style>
+21 -7
View File
@@ -7,6 +7,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import * as echarts from 'echarts'
import { useNodeStore } from '@/stores/nodes'
import { useIntervalFn } from '@vueuse/core'
import { useTheme } from '@/composables/useTheme'
const props = defineProps<{
nodeKey?: string | null
@@ -16,6 +17,8 @@ const nodeStore = useNodeStore()
const chartRef = ref<HTMLElement>()
let chart: echarts.ECharts | null = null
const { isDark, themeColor } = useTheme()
const activeNodeKey = computed(() => props.nodeKey || '')
function formatTime(timestamp: number) {
@@ -25,6 +28,15 @@ function formatTime(timestamp: number) {
function renderChart(xData: string[], yData: (number | null)[]) {
if (!chart) return
const css = getComputedStyle(document.documentElement)
const primaryRgb = css.getPropertyValue('--rgb-primary').trim() || '16, 185, 129'
const borderRgb = css.getPropertyValue('--rgb-border').trim() || '226, 232, 240'
const mutedTextRgb = css.getPropertyValue('--rgb-on-surface-muted').trim() || '148, 163, 184'
const axisColor = `rgba(${borderRgb}, 0.9)`
const splitColor = `rgba(${borderRgb}, 0.45)`
const axisTextColor = `rgba(${mutedTextRgb}, 1)`
chart.setOption({
backgroundColor: 'transparent',
tooltip: {
@@ -41,12 +53,14 @@ function renderChart(xData: string[], yData: (number | null)[]) {
xAxis: {
type: 'category',
data: xData,
axisLine: { lineStyle: { color: '#666' } }
axisLabel: { color: axisTextColor },
axisLine: { lineStyle: { color: axisColor } }
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: '#333' } },
axisLine: { lineStyle: { color: '#666' } }
axisLabel: { color: axisTextColor },
splitLine: { lineStyle: { color: splitColor } },
axisLine: { lineStyle: { color: axisColor } }
},
series: [
{
@@ -57,11 +71,11 @@ function renderChart(xData: string[], yData: (number | null)[]) {
connectNulls: false,
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(62, 175, 124, 0.5)' },
{ offset: 1, color: 'rgba(62, 175, 124, 0.0)' }
{ offset: 0, color: `rgba(${primaryRgb}, 0.5)` },
{ offset: 1, color: `rgba(${primaryRgb}, 0.0)` }
])
},
itemStyle: { color: '#3eaf7c' }
itemStyle: { color: `rgba(${primaryRgb}, 1)` }
}
]
})
@@ -93,7 +107,7 @@ onMounted(() => {
window.addEventListener('resize', resize)
})
watch(activeNodeKey, () => {
watch([activeNodeKey, themeColor, isDark], () => {
void refreshChart()
})
+112 -162
View File
@@ -1,64 +1,62 @@
<template>
<!-- 根容器:使用 surface-panel 背景,确保主题适应性 -->
<div class="flex flex-col h-full overflow-hidden gap-3 bg-surface-panel/50 rounded-2xl">
<!-- 工具栏:固定高度,不缩放 -->
<div class="flex-none flex flex-col gap-2 bg-surface/50 rounded-xl border border-border/50 backdrop-blur-md transition-all sticky top-0 z-10"
:class="compact ? 'p-2' : 'p-3 md:flex-row md:items-center md:justify-between shadow-sm'">
:class="compact ? 'p-2 shadow-sm' : 'p-3 shadow-sm'">
<div class="flex items-center gap-2 w-full" :class="compact ? '' : 'md:w-auto'">
<!-- 搜索框 -->
<div class="relative group flex-1" :class="compact ? '' : 'md:min-w-[200px]'">
<div class="flex items-center gap-2 w-full">
<div class="relative group flex-1">
<div class="absolute inset-y-0 left-0 pl-2.5 flex items-center pointer-events-none">
<div
class="i-material-symbols-search text-on-surface-muted group-focus-within:text-primary transition-colors text-base">
</div>
</div>
<input v-model="searchQuery" type="text" :placeholder="$t('common.search')"
class="pl-9 pr-3 py-1.5 bg-surface-overlay/50 rounded-lg border border-border focus:border-primary/50 focus:bg-surface-overlay outline-none text-on-surface text-xs w-full transition-all placeholder:text-on-surface-muted/50" />
<input v-model="nodeStore.searchQuery" type="text" :placeholder="$t('common.search')"
class="pl-9 pr-3 py-1.5 bg-surface-overlay/50 rounded-lg border border-border focus:border-primary/50 focus:bg-surface-overlay outline-none text-on-surface text-xs w-full transition-all placeholder:text-on-surface-muted" />
</div>
<!-- 排序 (显示当前模式) -->
<button @click="cycleSort"
class="px-2 py-1.5 rounded-lg border border-transparent hover:border-border hover:bg-surface-overlay text-on-surface-muted hover:text-primary transition-colors flex items-center gap-1.5"
:title="$t('common.sort_by')">
<div :class="getSortIcon(sortBy)" class="text-lg"></div>
<span class="text-[10px] font-bold uppercase tracking-wider">{{ getSortLabel(sortBy) }}</span>
<div :class="getSortIcon(nodeStore.activeSortType)" class="text-lg"></div>
<span class="text-[10px] font-bold uppercase tracking-wider">{{
getSortLabel(nodeStore.activeSortType) }}</span>
</button>
</div>
<!-- 过滤器 (非紧凑模式) -->
<div v-if="!compact" class="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto mt-2 md:mt-0">
<div class="flex gap-2 overflow-x-auto pb-1 md:pb-0 no-scrollbar w-full md:w-auto">
<button v-for="type in uniqueTypes" :key="type" @click="toggleTypeFilter(type)"
class="px-3 py-1 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all whitespace-nowrap border"
:class="selectedTypes.includes(type)
? 'bg-primary/10 text-primary border-primary/30'
: 'bg-surface-overlay/50 text-on-surface-muted border-border hover:border-primary/30 hover:text-on-surface'">
{{ type }}
</button>
</div>
<div class="flex flex-row gap-2 justify-center items-center w-full">
<select v-model="nodeStore.activeTypeFilter"
class=" w-full px-2 py-1.5 rounded-lg border border-border bg-surface-overlay/50 text-xs text-on-surface outline-none focus:border-primary/50">
<option value="all">{{ $t('nodes.filter_all_types') }}</option>
<option v-for="type in availableTypes" :key="type" :value="type" class=" ">
{{ type.toUpperCase() }}
</option>
</select>
<select v-model="nodeStore.activeGroupFilter"
class=" w-full px-2 py-1.5 rounded-lg border border-border bg-surface-overlay/50 text-xs text-on-surface outline-none focus:border-primary/50">
<option value="all">{{ $t('nodes.filter_all_groups') }}</option>
<option v-for="group in availableGroups" :key="group" :value="group">
{{ group === DEFAULT_NODE_GROUP ? $t('nodes.default_group') : group }}
</option>
</select>
</div>
</div>
<!-- 节点列表区域 -->
<div class="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-1">
<div class="grid gap-2 pb-2" :class="compact ? 'grid-cols-1' : 'grid-cols-1 md:grid-cols-2 xl:grid-cols-3'">
<div v-for="node in sortedNodes" :key="node.id" @click="selectNode(node)"
class="group relative rounded-xl border transition-all duration-200 cursor-pointer overflow-hidden backdrop-blur-sm select-none"
<div v-for="node in visibleNodes" :key="node.id || node.tag" @click="selectNode(node)"
class="group relative rounded-xl border transition-all duration-200 overflow-hidden backdrop-blur-sm "
:class="[
modelValue === (node.id || node.tag)
? 'bg-primary/5 border-primary shadow-[0_0_15px_-5px_rgba(var(--rgb-primary),0.3)]'
: 'bg-surface/50 border-border hover:border-primary/50 hover:bg-surface-overlay'
]">
<!-- 选中时的背景高亮指示 -->
<div v-if="modelValue === (node.id || node.tag)"
class="absolute inset-0 bg-gradient-to-r from-primary/10 to-transparent opacity-50 pointer-events-none">
</div>
<div class="relative z-10 flex items-center gap-3" :class="compact ? 'p-2.5 pl-3' : 'p-3'">
<!-- Protocol Icon -->
<div class="relative z-10 flex items-center gap-3" :class="compact ? 'p-2.5 pl-3' : 'p-3'">
<div class="shrink-0 w-8 h-8 rounded-lg flex items-center justify-center transition-colors border"
:class="modelValue === (node.id || node.tag)
? 'bg-primary/20 text-primary border-primary/20'
@@ -66,39 +64,56 @@
<div :class="getNodeIcon(node.type)" class="text-lg"></div>
</div>
<!-- Info -->
<div class="flex-1 min-w-0 flex flex-col gap-0.5">
<div class="flex-1 min-w-0 flex flex-col gap-0.5 ">
<div class="flex items-center justify-between gap-2">
<span
class="text-xs font-bold text-on-surface truncate group-hover:text-primary transition-colors">
class="text-xs font-bold text-on-surface truncate group-hover:text-primary transition-colors ">
{{ node.tag || node.server }}
</span>
<!-- Latency Badge -->
<span class="shrink-0 font-mono text-[10px] font-bold"
:class="getLatencyTextColor(node.latency)">
{{ node.latency > 0 ? node.latency + 'ms' : '--' }}
</span>
</div>
<!-- Sub-info -->
<div class="flex items-center gap-2">
<span
class="text-[9px] uppercase tracking-wider text-on-surface-muted/70 border border-border px-1 rounded bg-surface-overlay/50 group-hover:border-primary/20 transition-colors">
class="text-[9px] mt-1 uppercase tracking-wider text-on-surface-muted border border-border px-1 rounded bg-surface-overlay/50 group-hover:border-primary/20 transition-colors">
{{ node.type }}
</span>
<span v-if="!compact" class="text-[10px] text-on-surface-muted/50 truncate">{{
<span v-if="!compact" class="text-[10px] text-on-surface-muted truncate">{{
node.server }}</span>
<div class="flex items-center gap-1 mt-0.5 min-h-[14px]">
<span v-if="subscriptionLabelOf(node)"
class="text-[9px] text-primary/85 bg-primary/10 border border-primary/20 px-1 rounded truncate"
:title="subscriptionLabelOf(node)">
<button class="hover:underline cursor-pointer"
@click.stop="jumpToGroup(subscriptionLabelOf(node))">
@{{ subscriptionLabelOf(node) }}
</button>
</span>
<span v-if="manualTagOf(node)"
class="text-[9px] text-success bg-success/10 border border-success/20 px-1 rounded truncate"
:title="manualTagOf(node)">
<button class="hover:underline cursor-pointer"
@click.stop="jumpToGroup(manualTagOf(node))">
#{{ manualTagOf(node) }}
</button>
</span>
</div>
</div>
</div>
</div>
</div>
<!-- 空状态展示 -->
<div v-if="sortedNodes.length === 0"
<div v-if="visibleNodes.length === 0"
class="col-span-full flex flex-col items-center justify-center py-10 text-center opacity-70">
<div class="w-16 h-16 rounded-full bg-surface-overlay flex items-center justify-center mb-3">
<div class="i-material-symbols-dns-outline text-3xl text-on-surface-muted/50"></div>
</div>
<span class="text-xs font-medium text-on-surface-muted">No nodes found</span>
<span class="text-xs font-medium text-on-surface-muted">
<p class="text-lg font-medium">{{ $t('common.no_nodes') }}</p>
</span>
</div>
</div>
</div>
@@ -106,22 +121,20 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { NodeConfig } from '@/utils/protocol'
import { useNodeStore } from '@/stores/nodes'
import { DEFAULT_NODE_GROUP, useNodeStore } from '@/stores/nodes'
interface ExtendedNode extends NodeConfig {
latency: number
loss: number
load: number
bandwidth: number // bytes/sec
}
const props = defineProps<{
modelValue: string | null | undefined
nodes: NodeConfig[]
compact?: boolean
compact?: boolean // 是否紧凑显示,不显示节点类型和服务器地址
}>()
const emit = defineEmits<{
@@ -129,15 +142,11 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
// 响应式状态
const searchQuery = ref('')
const selectedTypes = ref<string[]>([])
const sortBy = ref<'creation' | 'latency' | 'alphabetical'>('creation')
const nodeStore = useNodeStore()
// 合并节点实时统计数据
const availableTypes = computed(() => nodeStore.availableNodeTypes)
const availableGroups = computed(() => nodeStore.availableGroups)
const nodesWithStats = computed<ExtendedNode[]>(() => {
return props.nodes.map(node => {
const defaultStat = { latency: -1, loss: 0 }
@@ -147,77 +156,44 @@ const nodesWithStats = computed<ExtendedNode[]>(() => {
return {
...node,
latency: stat.latency,
loss: stat.loss,
load: 0, // 待实现
bandwidth: 0 // 待实现
loss: stat.loss
}
})
})
// 提取不重复的节点类型
const uniqueTypes = computed(() => {
const types = new Set(props.nodes.map(n => n.type))
return Array.from(types).sort()
const visibleNodes = computed<ExtendedNode[]>(() => {
const allowedKeys = new Set(
props.nodes
.map(node => String(node.id || node.tag || '').trim())
.filter(Boolean)
)
const matched = nodeStore.filteredNodes.filter((node) => {
const key = String(node.id || node.tag || '').trim()
return !!key && allowedKeys.has(key)
})
const statMap = new Map(nodesWithStats.value.map((node) => [String(node.id || node.tag || '').trim(), node]))
return matched.map((node) => {
const key = String(node.id || node.tag || '').trim()
return statMap.get(key) || {
...node,
latency: -1,
loss: 0
}
})
})
// 过滤与排序逻辑
const sortedNodes = computed(() => {
let result = [...nodesWithStats.value]
// 搜索过滤
if (searchQuery.value) {
const q = searchQuery.value.toLowerCase()
result = result.filter(n =>
(n.tag && n.tag.toLowerCase().includes(q)) ||
(n.server && n.server.toLowerCase().includes(q))
)
}
// 类型过滤
if (selectedTypes.value.length > 0) {
result = result.filter(n => selectedTypes.value.includes(n.type))
}
// 排序
if (sortBy.value === 'latency') {
result.sort((a, b) => {
const latA = a.latency <= 0 ? 999999 : a.latency
const latB = b.latency <= 0 ? 999999 : b.latency
return latA - latB
})
} else if (sortBy.value === 'alphabetical') {
result.sort((a, b) => {
const nameA = a.tag || a.server || ''
const nameB = b.tag || b.server || ''
return nameA.localeCompare(nameB)
})
}
// 'creation' 默认为列表顺序,不做额外排序
return result
})
// 切换类型过滤器
function toggleTypeFilter(type: string) {
const index = selectedTypes.value.indexOf(type)
if (index > -1) {
selectedTypes.value.splice(index, 1)
} else {
selectedTypes.value.push(type)
}
}
// 切换排序模式
function cycleSort() {
const modes: ('creation' | 'latency' | 'alphabetical')[] = ['creation', 'latency', 'alphabetical']
const currentIndex = modes.indexOf(sortBy.value)
const modes: ('default' | 'latency' | 'alphabetical')[] = ['default', 'latency', 'alphabetical']
const currentIndex = modes.indexOf(nodeStore.activeSortType)
const nextIndex = (currentIndex + 1) % modes.length
sortBy.value = modes[nextIndex]!
nodeStore.activeSortType = modes[nextIndex]!
}
function getSortIcon(mode: string) {
switch (mode) {
case 'creation': return 'i-material-symbols-access-time'
case 'default': return 'i-material-symbols-nest-clock-farsight-analog-outline-rounded'
case 'latency': return 'i-material-symbols-network-check'
case 'alphabetical': return 'i-material-symbols-sort-by-alpha'
default: return 'i-material-symbols-sort'
@@ -226,14 +202,13 @@ function getSortIcon(mode: string) {
function getSortLabel(mode: string) {
switch (mode) {
case 'creation': return t('common.creation')
case 'default': return t('common.creation')
case 'latency': return t('common.latency_short')
case 'alphabetical': return t('common.alphabetical')
default: return mode
}
}
// 选择节点触发事件
function selectNode(node: ExtendedNode) {
const id = node.id || node.tag
if (id) {
@@ -241,21 +216,34 @@ function selectNode(node: ExtendedNode) {
}
}
// 根据节点协议获取图标
function subscriptionLabelOf(node: NodeConfig): string {
const group = nodeStore.getNodeSubscriptionGroup(node)
return group === DEFAULT_NODE_GROUP ? '' : group
}
function manualTagOf(node: NodeConfig): string {
return nodeStore.getNodeManualTag(node)
}
function jumpToGroup(group: string) {
const value = String(group || '').trim()
if (!value) return
nodeStore.activeGroupFilter = value
}
function getNodeIcon(type: string) {
const t = type.toLowerCase()
if (t.includes('vmess')) return 'i-carbon-network-4'
if (t.includes('vless')) return 'i-carbon-ibm-cloud-vpc'
if (t.includes('shadowsocks') || t.includes('ss')) return 'i-carbon-send-alt'
if (t.includes('trojan')) return 'i-carbon-security'
if (t.includes('hysteria')) return 'i-carbon-flash'
if (t.includes('tuic')) return 'i-carbon-rocket'
const normalized = type.toLowerCase()
if (normalized.includes('vmess')) return 'i-carbon-network-4'
if (normalized.includes('vless')) return 'i-carbon-ibm-cloud-vpc'
if (normalized.includes('shadowsocks') || normalized === 'ss' || normalized.startsWith('ss-')) return 'i-carbon-send-alt'
if (normalized.includes('trojan')) return 'i-carbon-security'
if (normalized.includes('hysteria')) return 'i-carbon-flash'
if (normalized.includes('tuic')) return 'i-carbon-rocket'
return 'i-carbon-network-1'
}
// 延迟状态文字颜色
function getLatencyTextColor(ms: number) {
if (ms <= 0) return 'text-on-surface-muted/40'
if (ms <= 0) return 'text-on-surface-muted'
if (ms < 100) return 'text-success'
if (ms < 200) return 'text-warning'
return 'text-error'
@@ -263,7 +251,6 @@ function getLatencyTextColor(ms: number) {
</script>
<style scoped>
/* 自定义滚动条样式 */
.custom-scrollbar::-webkit-scrollbar {
width: 5px;
}
@@ -273,51 +260,14 @@ function getLatencyTextColor(ms: number) {
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(var(--primary-rgb), 0.15);
background: rgba(var(--rgb-primary), 0.15);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(var(--primary-rgb), 0.35);
background: rgba(var(--rgb-primary), 0.35);
}
/* 缩放动画 */
@keyframes scale-in {
from {
transform: scale(0.6);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.animate-scale-in {
animation: scale-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* 脉冲动画 */
@keyframes pulse-slow {
0%,
100% {
opacity: 0.1;
transform: scale(1);
}
50% {
opacity: 0.2;
transform: scale(1.1);
}
}
.animate-pulse-slow {
animation: pulse-slow 4s infinite ease-in-out;
}
/* 隐藏滚动条 */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
+5 -5
View File
@@ -3,7 +3,7 @@
class="group relative bg-surface-panel rounded-xl border transition-all duration-300 cursor-pointer overflow-hidden hover:bg-surface/10"
:class="[
selected
? 'border-primary shadow-[0_0_15px_rgba(var(--primary-rgb),0.15)] bg-primary/5'
? 'border-primary shadow-[0_0_15px_rgba(var(--rgb-primary),0.15)] bg-primary/5'
: 'border-border hover:border-primary/30'
]">
@@ -115,10 +115,10 @@ function getNodeIcon(type: string) {
}
function getLatencyColor(ms: number) {
if (ms <= 0) return 'bg-gray-500/50'
if (ms < 100) return 'bg-success shadow-[0_0_8px_rgba(var(--success-rgb),0.3)]'
if (ms < 200) return 'bg-warning shadow-[0_0_8px_rgba(var(--warning-rgb),0.3)]'
return 'bg-error shadow-[0_0_8px_rgba(var(--error-rgb),0.3)]'
if (ms <= 0) return 'bg-secondary/50'
if (ms < 100) return 'bg-success shadow-[0_0_8px_rgba(var(--rgb-success),0.3)]'
if (ms < 200) return 'bg-warning shadow-[0_0_8px_rgba(var(--rgb-warning),0.3)]'
return 'bg-error shadow-[0_0_8px_rgba(var(--rgb-error),0.3)]'
}
function getLatencyTextColor(ms: number) {
+1 -1
View File
@@ -1,4 +1,4 @@
<template>
<template>
<n-modal :show="modelValue" @update:show="$emit('update:modelValue', $event)" preset="card"
:title="isEdit ? $t('nodes.edit_node') : $t('nodes.add_node')" class="w-[640px]" :mask-closable="false">
<n-form label-placement="top" :model="form">
+246 -64
View File
@@ -1,65 +1,179 @@
<template>
<div class="h-full flex flex-col">
<div class="h-full flex flex-col relative">
<!-- Toolbar (Floating) -->
<div v-if="nodeStore.selectedNodes.length > 0"
class="absolute bottom-6 left-1/2 -translate-x-1/2 z-50 min-w-[550px]">
<div
class="flex items-center justify-center gap-2 bg-surface-overlay/90 backdrop-blur-md border border-border shadow-xl rounded-full px-6 py-3">
<span class="text-sm font-medium text-on-surface">{{ $t('nodes.selected_count', {
count:
nodeStore.selectedNodes.length
}) }}</span>
<div class="h-4 w-px bg-border"></div>
<input v-model="groupName" type="text" :placeholder="$t('nodes.tag_placeholder_short')"
class="h-8 w-40 rounded-full border border-border bg-surface px-3 text-xs text-on-surface outline-none focus:border-primary"
@click.stop />
<button @click="applyGroupToSelected"
class="flex items-center gap-2 text-primary hover:text-primary-hover transition font-medium text-sm">
<div class="i-material-symbols-folder-copy"></div>
<span>{{ $t('nodes.apply_tag') }}</span>
</button>
<div class="h-4 w-px bg-border"></div>
<button @click="nodeStore.clearSelectedNodes()"
class="flex items-center gap-2 text-on-surface-muted hover:text-on-surface transition font-medium text-sm">
<div class="i-material-symbols-close"></div>
<span>{{ $t('common.cancel') }}</span>
</button>
</div>
</div>
<!-- Node List -->
<div class="flex-1 overflow-y-auto min-h-0 bg-surface-panel">
<div class="flex-1 overflow-y-auto min-h-0 bg-surface-panel p-4">
<div v-if="nodeStore.nodes.length === 0"
class="h-full flex flex-col items-center justify-center text-on-surface-muted p-8">
<div class="i-material-symbols-dns text-4xl mb-2"></div>
<p>{{ $t('common.no_nodes') }}</p>
<div class="i-material-symbols-dns text-6xl mb-4 opacity-50"></div>
<p class="text-lg font-medium">{{ $t('common.no_nodes') }}</p>
<p class="text-sm opacity-60 mt-2">{{ $t('common.add_node_hint') }}</p>
</div>
<div v-else class="divide-y divide-border">
<div v-for="node in nodeStore.nodes" :key="node.id || node.tag"
class="p-3 hover:bg-surface flex items-center justify-between group transition cursor-default">
<div class="flex items-center gap-3 min-w-0">
<div class="w-2 h-2 rounded-full" :class="getNodeStatusColor(node)"></div>
<div class="min-w-0">
<div class="font-medium text-sm text-on-surface truncate">{{ node.tag || 'Unnamed Node' }}</div>
<div class="text-xs text-on-surface-muted flex items-center gap-2">
<span class="uppercase bg-surface-overlay my-1 px-1 rounded text-[10px]">{{ node.type }}</span>
<span class="truncate">{{ node.server }}:{{ node.server_port }}</span>
</div>
<div v-if="isLocalProxyActiveNode(node)" class="text-[10px] text-primary font-mono">
HTTP {{ settingsStore.localProxyPort }} / SOCKS5 {{ settingsStore.localProxyPort + 1 }}
</div>
</div>
<div v-else-if="nodeStore.filteredNodes.length === 0"
class="h-full flex flex-col items-center justify-center text-on-surface-muted p-8">
<div class="i-material-symbols-search-off text-6xl mb-4 opacity-50"></div>
<p class="text-lg font-medium">{{ $t('nodes.no_nodes_found') }}</p>
</div>
<div v-else>
<div class="mb-3 px-1 flex items-center justify-between text-xs text-on-surface-muted h-8">
<div class="flex items-center gap-2">
<n-checkbox :checked="allVisibleSelected" :indeterminate="someVisibleSelected"
@update:checked="toggleSelectAllVisible" />
<span>{{ $t('nodes.select_all_visible') }}</span>
</div>
<div class="flex items-center gap-4">
<!-- Status -->
<div v-if="getNodeStats(node)" class="flex items-center gap-3 text-xs font-mono">
<div class="flex items-center gap-1" :class="getLatencyColor(getNodeStats(node)!.latency)">
<div class="i-carbon-chart-line"></div>
<span>{{ getNodeStats(node)!.latency > 0 ? getNodeStats(node)!.latency + 'ms' : 'Timeout' }}</span>
</div>
<div v-if="getNodeStats(node)!.loss > 0" class="flex items-center gap-1 text-error">
<div class="i-carbon-data-error"></div>
<span>{{ getNodeStats(node)!.loss }}%</span>
</div>
<div v-if="nodeStore.selectedNodes.length > 0"
class="flex items-center gap-3 animate-in fade-in slide-in-from-right-4 duration-200">
<span class="text-xs font-medium text-on-surface">{{ $t('nodes.selected_count', {
count:
nodeStore.selectedNodes.length
}) }}</span>
<div class="h-4 w-px bg-border"></div>
<button @click="deleteSelectedNodes"
class="flex items-center gap-1 text-error hover:text-error-hover transition font-medium hover:bg-error/10 px-2 py-1 rounded">
<div class="i-material-symbols-delete"></div>
<span>{{ $t('common.delete') }}</span>
</button>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-20">
<div v-for="node in nodeStore.filteredNodes" :key="node.id || node.tag"
class="group relative flex flex-col bg-surface border rounded-xl transition-all duration-200 overflow-hidden hover:shadow-lg hover:-translate-y-0.5"
:class="[
isSelected(node)
? 'border-primary ring-1 ring-primary bg-primary/5 shadow-[0_0_15px_rgba(var(--rgb-primary),0.1)]'
: 'border-border hover:border-primary/50'
]">
<!-- Selection Checkbox -->
<div class="absolute top-3 right-3 z-10 transition-opacity duration-200"
:class="isSelected(node) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'">
<n-checkbox :checked="isSelected(node)" @update:checked="(checked) => setSelection(node, checked)"
@click.stop />
</div>
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<n-button quaternary circle size="small" @click="editNode(node)" :title="$t('common.edit')">
<template #icon>
<div class="i-material-symbols-edit text-xs"></div>
</template>
</n-button>
<n-button quaternary circle size="small" @click="testNodeLatency(node)" :loading="isTestingNode(node)"
:title="$t('nodes.check_latency')">
<template #icon>
<div class="i-material-symbols-network-check text-xs"></div>
</template>
</n-button>
<n-button quaternary circle size="small" @click="shareNode(node)" :title="$t('common.share')">
<template #icon>
<div class="i-material-symbols-share text-xs"></div>
</template>
</n-button>
<n-button quaternary circle size="small" type="error" @click="deleteNode(node)"
:title="$t('common.delete')">
<template #icon>
<div class="i-material-symbols-delete text-xs"></div>
</template>
</n-button>
<!-- Active Indicator Strip -->
<div v-if="isLocalProxyActiveNode(node)"
class="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-primary via-primary-hover to-primary">
</div>
<div class="p-4 flex flex-col h-full">
<!-- Header -->
<div class="flex items-start gap-3 mb-4 pr-6">
<div class="mt-1.5 w-2 h-2 rounded-full shrink-0 shadow-[0_0_8px_currentColor]"
:class="[getNodeStatusColor(node), getNodeStatusColor(node).replace('bg-', 'text-')]"></div>
<div class="min-w-0 flex-1">
<div class="font-bold text-base text-on-surface truncate leading-tight mb-1"
:title="node.tag">
{{ node.tag || $t('nodes.unnamed_node') }}
</div>
<div class="flex items-center justify-between">
<span
class="uppercase bg-surface-overlay border border-border px-1.5 py-0.5 rounded text-[10px] tracking-wider font-mono text-on-surface-variant">
{{ node.type }}
</span>
<div v-if="isLocalProxyActiveNode(node)"
class="text-[8px] bg-surface-overlay px-1.5 py-0.5 rounded text-primary/80 font-bold uppercase tracking-wider">
H: {{ settingsStore.localProxyPort }} <span class="opacity-30 mx-1">|</span>
S: {{ settingsStore.localProxyPort + 1 }}</div>
</div>
</div>
</div>
<!-- Stats -->
<div class="mb-4">
<div
class="bg-surface-overlay/50 rounded-lg p-3 border border-border/50 flex items-center justify-between">
<span class="text-xs font-bold text-on-surface-muted uppercase tracking-wider">{{
$t('common.latency_short') }}</span>
<div class="flex items-baseline gap-1" :class="getLatencyColor(getNodeStats(node)?.latency ?? -1)">
<div v-if="getNodeStats(node)?.latency && getNodeStats(node)!.latency > 0"
class="flex items-baseline gap-1">
<span class="text-2xl font-mono font-bold leading-none tracking-tight">
{{ getNodeStats(node)!.latency }}
</span>
<span class="text-xs font-medium opacity-80">ms</span>
</div>
<span v-else class="text-sm font-mono opacity-50">--</span>
</div>
</div>
</div>
<!-- Footer Info -->
<div class="mt-auto pt-3 border-t border-border/50 flex items-center justify-between gap-2">
<div class="flex flex-col justify-start">
<div class="text-[10px] text-on-surface-muted/50 font-mono truncate ">
<span>{{ node.server }}:{{ node.server_port }}</span>
</div>
<div class="text-[10px] text-on-surface-muted/50 font-mono truncate ">
<span v-if="subscriptionLabelOf(node)" class="ml-2 text-primary/80">
<button class="hover:underline cursor-pointer"
@click.stop="jumpToGroup(subscriptionLabelOf(node))">
@{{ subscriptionLabelOf(node) }}
</button>
</span>
<span v-if="manualTagOf(node)" class="ml-2 text-success">
<button class="hover:underline cursor-pointer" @click.stop="jumpToGroup(manualTagOf(node))">
#{{ manualTagOf(node) }}
</button>
</span>
</div>
</div>
<!-- Action Buttons -->
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<n-button quaternary circle size="tiny" @click.stop="testNodeLatency(node)"
:loading="isTestingNode(node)" :title="$t('nodes.check_latency')">
<template #icon>
<div class="i-material-symbols-network-check text-xs"></div>
</template>
</n-button>
<n-button quaternary circle size="tiny" @click.stop="editNode(node)" :title="$t('common.edit')">
<template #icon>
<div class="i-material-symbols-edit text-xs"></div>
</template>
</n-button>
<n-button quaternary circle size="tiny" @click.stop="shareNode(node)" :title="$t('common.share')">
<template #icon>
<div class="i-material-symbols-share text-xs"></div>
</template>
</n-button>
<n-button quaternary circle size="tiny" type="error" @click.stop="deleteNode(node)"
:title="$t('common.delete')">
<template #icon>
<div class="i-material-symbols-delete text-xs"></div>
</template>
</n-button>
</div>
</div>
</div>
</div>
</div>
@@ -71,9 +185,9 @@
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ref, reactive, computed } from 'vue'
import { useMessage, useDialog } from 'naive-ui'
import { useNodeStore } from '@/stores/nodes'
import { DEFAULT_NODE_GROUP, useNodeStore } from '@/stores/nodes'
import { useSettingsStore } from '@/stores/settings'
import { useLocalProxyStore } from '@/stores/local-proxy'
import { generateShareLink } from '@/utils/protocol'
@@ -93,6 +207,15 @@ const { copy } = useClipboard()
const showEditModal = ref(false)
const editingNode = ref<NodeConfig | null>(null)
const testingNodes = reactive<Set<string>>(new Set())
const groupName = ref('')
function isSelected(node: NodeConfig) {
return nodeStore.isNodeSelected(node)
}
function setSelection(node: NodeConfig, checked: boolean) {
nodeStore.setNodeSelected(node, checked)
}
function isTestingNode(node: NodeConfig) {
const key = node.id || node.tag
@@ -134,26 +257,85 @@ function editNode(node: NodeConfig) {
async function deleteNode(node: NodeConfig) {
dialog.warning({
title: t('common.delete') || '确认删除',
content: t('nodes.delete_confirm', { name: node.tag }) || `确定要删除节点 ${node.tag} 吗?`,
positiveText: t('common.delete') || '删除',
negativeText: t('common.cancel') || '取消',
title: t('common.delete'),
content: t('nodes.delete_confirm', { name: node.tag }),
positiveText: t('common.delete'),
negativeText: t('common.cancel'),
positiveButtonProps: {
type: 'error'
},
onPositiveClick: async () => {
if (node.id) {
await nodeStore.removeNode(node.id)
message.success(t('common.deleted') || '已删除')
message.success(t('common.deleted'))
}
}
})
}
async function deleteSelectedNodes() {
const count = nodeStore.selectedNodes.length
if (count === 0) return
dialog.warning({
title: t('common.delete'),
content: t('nodes.delete_batch_confirm', { count }),
positiveText: t('common.delete'),
negativeText: t('common.cancel'),
positiveButtonProps: {
type: 'error'
},
onPositiveClick: async () => {
const ids = nodeStore.selectedNodes.map(n => n.id).filter(Boolean) as string[]
if (ids.length) {
await nodeStore.removeNodes(ids)
message.success(t('common.deleted'))
}
}
})
}
function applyGroupToSelected() {
nodeStore.setGroupForSelectedNodes(groupName.value)
message.success(t('nodes.tag_applied'))
}
function subscriptionLabelOf(node: NodeConfig) {
const group = nodeStore.getNodeSubscriptionGroup(node)
return group === DEFAULT_NODE_GROUP ? '' : group
}
function manualTagOf(node: NodeConfig) {
return nodeStore.getNodeManualTag(node)
}
function jumpToGroup(group: string) {
const value = String(group || '').trim()
if (!value) return
nodeStore.activeGroupFilter = value
}
const allVisibleSelected = computed(() => {
if (!nodeStore.filteredNodes.length) return false
return nodeStore.filteredNodes.every((node) => nodeStore.isNodeSelected(node))
})
const someVisibleSelected = computed(() => {
if (!nodeStore.filteredNodes.length) return false
const selectedCount = nodeStore.filteredNodes.filter((node) => nodeStore.isNodeSelected(node)).length
return selectedCount > 0 && selectedCount < nodeStore.filteredNodes.length
})
function toggleSelectAllVisible(checked: boolean) {
for (const node of nodeStore.filteredNodes) {
nodeStore.setNodeSelected(node, checked)
}
}
async function handleSaveNode(node: Omit<NodeConfig, 'id'> | NodeConfig) {
const ok = await nodeStore.saveNode(node as NodeConfig)
if (!ok) {
message.error('保存失败,请检查填写内容或查看控制台错误')
message.error(t('nodes.save_failed'))
}
}
@@ -161,9 +343,9 @@ async function shareNode(node: NodeConfig) {
const link = generateShareLink(node)
if (link) {
await copy(link)
message.success('节点链接已复制')
message.success(t('nodes.link_copied'))
} else {
message.error('生成分享链接失败')
message.error(t('nodes.link_copy_failed'))
}
}
@@ -0,0 +1,184 @@
<template>
<n-modal :show="modelValue" @update:show="$emit('update:modelValue', $event)" preset="card"
:title="$t('nodes.subscription_manage')" class="w-[800px]" :mask-closable="false">
<div class="mb-4 rounded-xl border border-border/50 bg-surface-panel/40 p-4">
<div class="grid grid-cols-1 md:grid-cols-[1fr_2fr_180px_120px] gap-2 mb-3">
<n-input v-model:value="subscriptionName" :placeholder="$t('nodes.subscription_name')" />
<n-input v-model:value="subscriptionUrl" :placeholder="$t('nodes.subscription_url')" />
<n-select v-model:value="subscriptionSchedule" :options="subscriptionScheduleOptions" />
<n-button type="primary" @click="handleAddSubscription">{{ $t('common.add') }}</n-button>
</div>
<div v-if="nodeStore.subscriptions.length > 0" class="space-y-2 max-h-[400px] overflow-y-auto">
<div v-for="sub in nodeStore.subscriptions" :key="sub.id"
class="rounded-lg border border-border/60 bg-surface/40 px-3 py-2 flex flex-wrap items-center gap-2 justify-between">
<div class="min-w-0 flex-1">
<template v-if="editingId === sub.id">
<div class="grid grid-cols-1 md:grid-cols-[1fr_2fr_150px] gap-2">
<n-input v-model:value="editDraft.name" size="small" :placeholder="$t('nodes.subscription_name')" />
<n-input v-model:value="editDraft.url" size="small" :placeholder="$t('nodes.subscription_url')" />
<n-select v-model:value="editDraft.schedule" size="small" :options="subscriptionScheduleOptions" />
</div>
</template>
<template v-else>
<div class="text-sm font-medium truncate">{{ sub.name }}</div>
<div class="text-xs text-on-surface-muted truncate">{{ sub.url }}</div>
</template>
<div v-if="editingId !== sub.id" class="text-[11px] text-on-surface-muted">
{{ $t('nodes.subscription_last_fetch') }}: {{ formatLastFetched(sub.lastFetchedAt) }}
</div>
</div>
<div class="flex items-center gap-2">
<n-switch v-if="editingId !== sub.id" :value="sub.enabled"
@update:value="(val) => toggleSubscriptionEnabled(sub.id, val)" />
<n-button v-if="editingId !== sub.id" size="small" @click="startEdit(sub.id)">{{ $t('common.edit')
}}</n-button>
<n-button v-else size="small" type="primary" @click="saveEdit(sub.id)">{{ $t('common.save') }}</n-button>
<n-button v-if="editingId === sub.id" size="small" @click="cancelEdit">{{ $t('common.cancel') }}</n-button>
<n-button v-if="editingId !== sub.id" size="small" @click="refreshSubscription(sub.id)">{{
$t('nodes.subscription_refresh') }}</n-button>
<n-button v-if="editingId !== sub.id" size="small" type="error" @click="removeSubscription(sub.id)">{{
$t('common.delete') }}</n-button>
</div>
</div>
</div>
<div v-else class="text-center py-8 text-on-surface-muted">
{{ $t('common.no_data') }}
</div>
</div>
</n-modal>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useMessage, useDialog } from 'naive-ui'
import { useNodeStore, type NodeSubscriptionSchedule } from '@/stores/nodes'
import i18n from '@/i18n'
defineProps<{
modelValue: boolean
}>()
defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const nodeStore = useNodeStore()
const message = useMessage()
const dialog = useDialog()
const subscriptionName = ref('')
const subscriptionUrl = ref('')
const subscriptionSchedule = ref<NodeSubscriptionSchedule>('daily')
const editingId = ref('')
const editDraft = ref<{
name: string
url: string
schedule: NodeSubscriptionSchedule
}>({
name: '',
url: '',
schedule: 'daily'
})
const subscriptionScheduleOptions = computed(() => [
{ label: i18n.global.t('nodes.subscription_schedule_startup'), value: 'startup' },
{ label: i18n.global.t('nodes.subscription_schedule_daily'), value: 'daily' },
{ label: i18n.global.t('nodes.subscription_schedule_monthly'), value: 'monthly' }
])
function formatLastFetched(ts?: number) {
if (!ts) return i18n.global.t('nodes.subscription_never')
return new Date(ts).toLocaleString()
}
function handleAddSubscription() {
const ok = nodeStore.addSubscription({
name: subscriptionName.value,
url: subscriptionUrl.value,
schedule: subscriptionSchedule.value
})
if (!ok) {
message.warning(i18n.global.t('nodes.subscription_invalid_form'))
return
}
subscriptionName.value = ''
subscriptionUrl.value = ''
subscriptionSchedule.value = 'daily'
message.success(i18n.global.t('nodes.subscription_added'))
}
async function refreshSubscription(id: string) {
const result = await nodeStore.refreshSubscription(id)
if (result.ok) {
message.success(i18n.global.t('common.import_success', { count: result.count }))
} else {
message.error(i18n.global.t('nodes.subscription_update_failed'))
}
}
function removeSubscription(id: string) {
const sub = nodeStore.subscriptions.find((row) => row.id === id)
const name = String(sub?.name || '').trim()
const count = name
? nodeStore.nodes.filter((node) => nodeStore.getNodeSubscriptionGroup(node) === name).length
: 0
dialog.warning({
title: i18n.global.t('common.delete'),
content: i18n.global.t('nodes.subscription_delete_confirm', { name }),
positiveText: i18n.global.t('nodes.subscription_delete_with_nodes', { count }),
negativeText: i18n.global.t('nodes.subscription_delete_only'),
onPositiveClick: async () => {
await nodeStore.removeSubscription(id, { deleteNodes: true })
message.success(i18n.global.t('common.deleted'))
},
onNegativeClick: async () => {
await nodeStore.removeSubscription(id, { deleteNodes: false })
message.success(i18n.global.t('common.deleted'))
}
})
}
function toggleSubscriptionEnabled(id: string, enabled: boolean) {
nodeStore.updateSubscription(id, { enabled })
}
function startEdit(id: string) {
const target = nodeStore.subscriptions.find((row) => row.id === id)
if (!target) return
editingId.value = id
editDraft.value = {
name: target.name,
url: target.url,
schedule: target.schedule
}
}
function cancelEdit() {
editingId.value = ''
editDraft.value = {
name: '',
url: '',
schedule: 'daily'
}
}
function saveEdit(id: string) {
const name = editDraft.value.name.trim()
const url = editDraft.value.url.trim()
if (!name || !url) {
message.warning(i18n.global.t('nodes.subscription_invalid_form'))
return
}
nodeStore.updateSubscription(id, {
name,
url,
schedule: editDraft.value.schedule
})
message.success(i18n.global.t('common.save'))
cancelEdit()
}
</script>
+4 -4
View File
@@ -11,7 +11,7 @@
<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.name }}
{{ pkg.productName }}
</h1>
<p class="text-on-surface-muted mb-8 tracking-wide">{{ $t('settings.tagline') }}</p>
@@ -49,12 +49,12 @@
<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-red-500 flex items-center gap-2">
<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-green-500 font-bold flex items-center gap-2 text-base">
<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>
@@ -73,7 +73,7 @@
<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-green-500 text-lg"></div>
<div class="i-material-symbols-check-circle text-success text-lg"></div>
{{ $t('settings.no_update') }}
</div>
</div>
+5 -2
View File
@@ -49,18 +49,20 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTheme, type ThemeColor } from '@/composables/useTheme'
import { useSettingsStore } from '@/stores/settings'
const { locale } = useI18n()
const { themeColor, setThemeColor } = useTheme()
const settingsStore = useSettingsStore()
const currentLocale = computed(() => locale.value)
const currentLocale = computed(() => settingsStore.language)
const availableLocales = [
{ code: 'zh-CN', name: '简体中文' },
{ code: 'en-US', name: 'English' }
]
const themeColors: { value: ThemeColor, label: string, bg: string }[] = [
{ value: 'green', label: 'Green', bg: 'bg-[#3eaf7c]' },
{ value: 'green', label: 'Green', bg: 'bg-[#10b981]' },
{ value: 'blue', label: 'Blue', bg: 'bg-[#3b82f6]' },
{ value: 'purple', label: 'Purple', bg: 'bg-[#8b5cf6]' },
{ value: 'orange', label: 'Orange', bg: 'bg-[#f97316]' },
@@ -68,6 +70,7 @@ const themeColors: { value: ThemeColor, label: string, bg: string }[] = [
]
function setLanguage(lang: string) {
settingsStore.language = lang
locale.value = lang
}
</script>
+18 -18
View File
@@ -17,18 +17,18 @@
{{ $t('settings.logs_count_info', { filtered: filteredLogs.length, total: logEntries.length }) }}
</div>
<div class="rounded-xl border border-border/60 bg-black/30 h-[520px] overflow-y-auto custom-scrollbar">
<div class="rounded-xl border border-border/60 bg-surface-overlay/50 h-[520px] overflow-y-auto custom-scrollbar">
<div v-if="filteredLogs.length === 0"
class="h-full flex items-center justify-center text-sm text-on-surface-muted">
{{ $t('settings.logs_empty') }}
</div>
<div v-else class="divide-y divide-white/5">
<div v-else class="divide-y divide-border/40">
<div v-for="row in filteredLogs" :key="row.id" class="flex gap-4 px-4 py-3 font-mono text-sm border-l-[4px]"
:class="[
row.level === 'error' ? 'border-[#ff4d4f] bg-[#ff4d4f]/10' :
row.level === 'warn' ? 'border-[#faad14] bg-[#faad14]/10' :
row.level === 'debug' ? 'border-[#8c8c8c] bg-[#8c8c8c]/5' :
'border-[#52c41a] bg-[#52c41a]/10'
row.level === 'error' ? 'border-error bg-error/10' :
row.level === 'warn' ? 'border-warning bg-warning/10' :
row.level === 'debug' ? 'border-secondary bg-secondary/5' :
'border-success bg-success/10'
]">
<!-- Left Side: Fixed Meta -->
<div class="flex-none flex flex-col items-center gap-2 w-20">
@@ -46,21 +46,21 @@
<div class="flex-1 min-w-0 space-y-1">
<div class="flex items-center gap-2">
<span
class="text-[10px] font-black px-1.5 py-0.5 rounded bg-white/5 text-primary/80 uppercase tracking-tighter shadow-inner">
class="text-[10px] font-black px-1.5 py-0.5 rounded bg-surface-overlay/80 text-primary/80 uppercase tracking-tighter shadow-inner">
{{ $t('settings.logs_category_' + row.category) }}
</span>
<span class="text-[10px] text-white dark:text-gray-400 italic truncate">
<span class="text-[10px] text-on-surface-muted italic truncate">
{{ row.source }}
</span>
</div>
<div class="break-words leading-relaxed font-medium text-white dark:text-gray-400"
<div class="break-words leading-relaxed font-medium text-on-surface "
:class="logMessageTextClass(row.level)">
{{ row.message }}
</div>
<div v-if="row.detail"
class="mt-2 p-3 rounded-lg text-white dark:text-gray-400 bg-black/40 border border-white/5 text-xs break-all whitespace-pre-wrap leading-relaxed shadow-inner">
class="mt-2 p-3 rounded-lg text-on-surface bg-surface/60 border border-border/50 text-xs break-all whitespace-pre-wrap leading-relaxed shadow-inner">
{{ row.detail }}
</div>
</div>
@@ -150,19 +150,19 @@ function formatLogTime(ts: number) {
function logLevelLabelClass(level: LogEntry['level']) {
switch (level) {
case 'error': return 'bg-red-500 text-white'
case 'warn': return 'bg-amber-500 text-black'
case 'debug': return 'bg-zinc-700 text-zinc-300'
default: return 'bg-emerald-500 text-white'
case 'error': return 'bg-error text-on-error'
case 'warn': return 'bg-warning text-on-warning'
case 'debug': return 'bg-secondary text-on-secondary'
default: return 'bg-success text-on-success'
}
}
function logMessageTextClass(level: LogEntry['level']) {
switch (level) {
case 'error': return 'text-[#ff4d4f] font-bold'
case 'warn': return 'text-[#fadb14] font-bold'
case 'debug': return 'text-[#bfbfbf]'
default: return 'text-[#95de64] font-bold'
case 'error': return 'text-error font-bold'
case 'warn': return 'text-warning font-bold'
case 'debug': return 'text-secondary'
default: return 'text-success font-bold'
}
}
+164 -8
View File
@@ -41,15 +41,13 @@
</h2>
<div class="flex bg-surface-overlay/50 rounded-lg p-1 border border-border/30">
<button @click="settingsStore.dnsMode = 'secure'" :disabled="applyingDns"
class="px-3 py-1 text-xs rounded-md transition font-medium"
:class="settingsStore.dnsMode === 'secure'
class="px-3 py-1 text-xs rounded-md transition font-medium" :class="settingsStore.dnsMode === 'secure'
? 'bg-surface shadow-sm text-primary'
: 'text-on-surface-muted hover:text-on-surface'">
{{ $t('settings.dns_mode_secure') }}
</button>
<button @click="settingsStore.dnsMode = 'system'" :disabled="applyingDns"
class="px-3 py-1 text-xs rounded-md transition font-medium"
:class="settingsStore.dnsMode === 'system'
class="px-3 py-1 text-xs rounded-md transition font-medium" :class="settingsStore.dnsMode === 'system'
? 'bg-surface shadow-sm text-primary'
: 'text-on-surface-muted hover:text-on-surface'">
{{ $t('settings.dns_mode_system') }}
@@ -131,11 +129,11 @@
{{ $t('common.status') }}
</label>
<div class="px-3 py-2 rounded-lg border text-xs" :class="localProxyStore.statusLevel === 'error'
? 'border-red-500/40 text-red-500 bg-red-500/10'
? 'border-error/40 text-error bg-error/10'
: localProxyStore.statusLevel === 'warning'
? 'border-yellow-500/40 text-yellow-500 bg-yellow-500/10'
: localProxyStore.statusLevel === 'success'
? 'border-green-500/40 text-green-500 bg-green-500/10'
? 'border-success/40 text-success bg-success/10'
: 'border-border/50 text-on-surface-muted bg-surface-overlay/40'">
{{ localProxyStore.statusText || (localProxyStore.starting ? $t('common.checking') : '-') }}
</div>
@@ -149,6 +147,119 @@
<div class="w-full h-px bg-border/30"></div>
<!-- System Proxy -->
<section>
<h2 class="text-xl font-bold mb-4 flex items-center gap-2">
<div class="i-material-symbols-public text-primary"></div>
{{ $t('settings.system_proxy') }}
</h2>
<div class="bg-surface-panel/50 border border-border/50 rounded-2xl p-6 backdrop-blur-sm space-y-4">
<div class="space-y-2">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">
{{ $t('settings.system_proxy_port') }} (HTTP)
</label>
<n-input-number v-model:value="settingsStore.systemProxyPort" :min="1024" :max="65535" class="glass-select"
button-placement="both" />
<p class="text-xs text-on-surface-muted mt-1">
{{ $t('settings.system_proxy_info') }}
</p>
</div>
</div>
</section>
<div class="w-full h-px bg-border/30"></div>
<!-- Game Network Tuning -->
<section>
<h2 class="text-xl font-bold mb-4 flex items-center gap-2">
<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">
<p class="text-xs text-on-surface-muted">
{{ $t('settings.game_network_tuning_hint') }}
</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" />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"
:class="{ 'opacity-50 pointer-events-none': !settingsStore.sessionNetworkTuning.enabled }">
<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') }}
</label>
<div class="flex bg-surface-overlay/50 p-1 rounded-xl border border-border/30">
<button @click="applyProfilePreset('stable')"
class="flex-1 py-2 text-sm rounded-lg transition-all font-bold" :class="settingsStore.sessionNetworkTuning.profile === 'stable'
? 'bg-surface shadow-sm text-primary'
: 'text-on-surface-muted hover:text-on-surface'">
{{ $t('settings.network_profile_stable') }}
</button>
<button @click="applyProfilePreset('aggressive')"
class="flex-1 py-2 text-sm rounded-lg transition-all font-bold" :class="settingsStore.sessionNetworkTuning.profile === 'aggressive'
? 'bg-surface shadow-sm text-primary'
: 'text-on-surface-muted hover:text-on-surface'">
{{ $t('settings.network_profile_aggressive') }}
</button>
</div>
</div>
<div class="space-y-2">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">
{{ $t('settings.udp_mode') }}
</label>
<n-select v-model:value="settingsStore.sessionNetworkTuning.udpMode" :options="udpModeOptions"
class="glass-select" />
</div>
<div class="space-y-2">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">
MTU
</label>
<n-input-number v-model:value="settingsStore.sessionNetworkTuning.tunMtu" :min="1200" :max="1500"
class="glass-select" button-placement="both" />
</div>
<div class="space-y-2">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">
{{ $t('settings.tun_stack') }}
</label>
<n-select v-model:value="settingsStore.sessionNetworkTuning.tunStack" :options="tunStackOptions"
class="glass-select" />
</div>
<div class="space-y-2">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">
{{ $t('settings.vless_packet_encoding') }} ({{ $t('settings.vless_only') }})
</label>
<n-select v-model:value="settingsStore.sessionNetworkTuning.vlessPacketEncodingOverride"
:options="vlessEncodingOptions" class="glass-select" />
<p class="text-xs text-on-surface-muted">{{ $t('settings.vless_only_hint') }}</p>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">
{{ $t('settings.strict_route') }}
</label>
<n-switch v-model:value="settingsStore.sessionNetworkTuning.strictRoute" />
</div>
</div>
</div>
<div class="flex items-center justify-between gap-3">
<p class="text-xs text-on-surface-muted">
{{ $t('settings.high_loss_hint_only') }}
</p>
<n-switch v-model:value="settingsStore.sessionNetworkTuning.highLossHintOnly" />
</div>
<div class="pt-1">
<n-button secondary @click="settingsStore.resetSessionNetworkTuning()" class="glass-button">
{{ $t('settings.reset_session_network_tuning') }}
</n-button>
</div>
</div>
</section>
<div class="w-full h-px bg-border/30"></div>
<!-- Advanced Network -->
<section>
<h2 class="text-xl font-bold mb-4 flex items-center gap-2">
@@ -180,7 +291,7 @@
</div>
<div v-if="opMessage" class="px-4 py-3 rounded-xl text-sm flex items-center gap-3 border animate-scale-in"
:class="opOk ? 'bg-green-500/10 text-green-500 border-green-500/20' : 'bg-red-500/10 text-red-500 border-red-500/20'">
:class="opOk ? 'bg-success/10 text-success border-success/20' : 'bg-error/10 text-error border-error/20'">
<div :class="opOk ? 'i-material-symbols-check-circle text-lg' : 'i-material-symbols-error text-lg'">
</div>
{{ opMessage }}
@@ -191,7 +302,7 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/settings'
import { useLocalProxyStore } from '@/stores/local-proxy'
@@ -206,6 +317,7 @@ const gameStore = useGameStore()
const working = ref(false)
const applyingDns = ref(false)
const applyingSessionTuning = ref(false)
const opMessage = ref('')
const opOk = ref(true)
@@ -217,12 +329,29 @@ const intervalOptions = [
{ label: '30s', value: 30000 },
{ label: '1m', value: 60000 }
]
const udpModeOptions = computed(() => ([
{ label: t('settings.udp_mode_auto'), value: 'auto' },
{ label: t('settings.udp_mode_prefer_udp'), value: 'prefer_udp' },
{ label: t('settings.udp_mode_prefer_tcp'), value: 'prefer_tcp' }
]))
const tunStackOptions = [
{ label: 'system', value: 'system' },
{ label: 'mixed', value: 'mixed' }
]
const vlessEncodingOptions = computed(() => ([
{ label: t('settings.vless_packet_encoding_off'), value: 'off' },
{ label: 'xudp', value: 'xudp' }
]))
function setCheckMethod(method: string) {
// @ts-ignore
settingsStore.checkMethod = method
}
function applyProfilePreset(profile: 'stable' | 'aggressive') {
settingsStore.applySessionNetworkProfilePreset(profile)
}
async function handleFlushDns() {
if (working.value) return
working.value = true
@@ -288,4 +417,31 @@ watch(
}
}
)
watch(
() => JSON.stringify(settingsStore.sessionNetworkTuning),
async (_next, prev) => {
if (_next === prev) return
if (applyingSessionTuning.value) return
const active = gameStore.getAcceleratingGame()
if (!active?.id) return
if (!gameStore.isUsingGlobalSessionNetworkTuning(active.id)) return
if (gameStore.operationState !== 'idle') return
applyingSessionTuning.value = true
try {
opOk.value = true
opMessage.value = t('settings.session_network_tuning_restarting')
const restarted = await gameStore.applySessionNetworkTuningChange()
if (restarted) {
opMessage.value = t('settings.session_network_tuning_applied')
}
} catch (e: any) {
opOk.value = false
opMessage.value = String(e?.message || e || t('settings.session_network_tuning_apply_failed'))
} finally {
applyingSessionTuning.value = false
}
}
)
</script>
+1 -1
View File
@@ -34,7 +34,7 @@
<img src="/logo.svg" class="w-5 h-5" alt="Logo" />
</div>
<div>
<div class="text-sm font-bold">{{ pkg.name }}</div>
<div class="text-sm font-bold">{{ pkg.productName }}</div>
<div class="text-[10px] text-on-surface-muted font-mono">v{{ appVersion }}</div>
</div>
</div>
Whitespace-only changes.
+2 -1
View File
@@ -3,9 +3,10 @@ import { useNotification, NButton } from 'naive-ui'
import { h } from 'vue'
import { useI18n } from 'vue-i18n'
import { appApi } from '@/api'
import pkg from '../../package.json'
export function useAppUpdater() {
const appVersion = ref('0.0.0')
const appVersion = ref(pkg.version)
const checkingUpdate = ref(false)
const updateInfo = ref<{
available: boolean,
+15 -5
View File
@@ -1,16 +1,24 @@
import { ref } from 'vue'
import { useMessage } from 'naive-ui'
import { systemApi } from '@/api'
import { categoryApi, systemApi } from '@/api'
import { useGameStore } from '@/stores/games'
import { useCategoryStore } from '@/stores/categories'
import type { Game, LocalScanGame } from '@/types'
export function useGameScanner() {
const isScanning = ref(false)
const message = useMessage()
const gameStore = useGameStore()
// 临时使用 useCategoryStore 来获取默认分类,或者硬编码 'other'
// 为了解耦,这里简单硬编码,或者要求外部传入 fallbackCategoryId
const fallbackCategoryId = 'other'
const categoryStore = useCategoryStore()
function getFallbackCategoryId() {
const categories = categoryStore.categories || []
const preferred = categories.find(c => String(c.id) === 'other')
|| categories.find(c => String(c.name || '').toLowerCase() === 'other')
|| categories.find(c => String(c.name || '').includes('其他'))
|| categories[0]
return preferred?.id || 'other'
}
async function scanGames() {
isScanning.value = true
@@ -56,10 +64,12 @@ export function useGameScanner() {
let added = 0
for (const item of candidates) {
const matchedCategoryId = await categoryApi.match(item.name, [item.processName])
const categoryId = matchedCategoryId || getFallbackCategoryId()
await gameStore.addGame({
name: item.name,
processName: [item.processName],
category: fallbackCategoryId,
category: categoryId,
status: 'idle',
latency: 0,
proxyMode: 'process',
+31 -25
View File
@@ -1,18 +1,29 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useSettingsStore } from '@/stores/settings'
import type { Theme, ThemeColor } from '@/types'
export type { Theme, ThemeColor }
const theme = ref<Theme>('auto')
const resolvedTheme = ref<'light' | 'dark'>('dark') // Default to dark
const themeColor = ref<ThemeColor>('green')
export function useTheme() {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const settingsStore = useSettingsStore()
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') // 监听系统主题变化
const resolvedTheme = ref<'light' | 'dark'>('dark')
// Keep compatibility with existing code
const theme = computed({
get: () => settingsStore.theme,
set: (val) => settingsStore.theme = val
})
const themeColor = computed({
get: () => settingsStore.themeColor,
set: (val) => settingsStore.themeColor = val
})
function applyTheme() {
const root = document.documentElement
const isDark = theme.value === 'dark' || (theme.value === 'auto' && mediaQuery.matches)
const currentTheme = settingsStore.theme
const isDark = currentTheme === 'dark' || (currentTheme === 'auto' && mediaQuery.matches)
resolvedTheme.value = isDark ? 'dark' : 'light'
@@ -24,45 +35,40 @@ export function useTheme() {
}
// Apply theme color
// Remove previous theme classes
root.classList.remove('theme-green', 'theme-blue', 'theme-purple', 'theme-orange', 'theme-red')
root.classList.add(`theme-${themeColor.value}`)
root.classList.add(`theme-${settingsStore.themeColor}`)
}
function setTheme(newTheme: Theme) {
theme.value = newTheme
localStorage.setItem('theme', newTheme)
applyTheme()
settingsStore.theme = newTheme
}
function setThemeColor(color: ThemeColor) {
themeColor.value = color
localStorage.setItem('themeColor', color)
applyTheme()
settingsStore.themeColor = color
}
function toggleTheme() {
const next = resolvedTheme.value === 'dark' ? 'light' : 'dark'
// If auto, force set to opposite of current resolved
setTheme(next)
}
function handleSystemChange() {
if (theme.value === 'auto') {
if (settingsStore.theme === 'auto') {
applyTheme()
}
}
onMounted(() => {
const savedTheme = localStorage.getItem('theme') as Theme
if (savedTheme && ['light', 'dark', 'auto'].includes(savedTheme)) {
theme.value = savedTheme
}
const savedColor = localStorage.getItem('themeColor') as ThemeColor
if (savedColor && ['green', 'blue', 'purple', 'orange', 'red'].includes(savedColor)) {
themeColor.value = savedColor
// Sync with store changes
watch(
() => [settingsStore.theme, settingsStore.themeColor],
() => {
applyTheme()
}
)
onMounted(() => {
// Initial apply
applyTheme()
mediaQuery.addEventListener('change', handleSystemChange)
})
+4 -1
View File
@@ -2,9 +2,12 @@ import { createI18n } from 'vue-i18n'
import zhCN from './locales/zh-CN.json'
import enUS from './locales/en-US.json'
const savedLocale = localStorage.getItem('settings-language')
const defaultLocale = savedLocale && ['zh-CN', 'en-US'].includes(savedLocale) ? savedLocale : 'zh-CN'
const i18n = createI18n({
legacy: false,
locale: 'zh-CN', // default locale
locale: defaultLocale, // default locale
fallbackLocale: 'en-US',
messages: {
'zh-CN': zhCN,
+4 -2
View File
@@ -15,7 +15,8 @@
</div>
</div>
</div>
<div class="flex gap-1 no-drag items-center">
<div class="flex gap-2 no-drag items-center">
<LanguageToggle />
<ThemeToggle />
<div class="w-[1px] h-4 bg-border mx-2"></div>
<button @click="minimize"
@@ -24,7 +25,7 @@
</button>
<button @click="maximize"
class="w-7 h-7 flex items-center justify-center rounded hover:bg-on-surface/10 text-on-surface-variant hover:text-on-surface transition">
<div class="i-material-symbols-crop-square text-lg"></div>
<div class="i-material-symbols-crop-square-outline text-lg"></div>
</button>
<button @click="close"
class="w-7 h-7 flex items-center justify-center rounded hover:bg-error hover:text-white text-on-surface-variant transition">
@@ -120,6 +121,7 @@ import { ref, watchEffect, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useIntervalFn, useWindowSize } from '@vueuse/core'
import ThemeToggle from '@/components/common/ThemeToggle.vue'
import LanguageToggle from '@/components/common/LanguageToggle.vue'
import pkg from '../../package.json'
import { electronApi } from '@/api'
import { useAppUpdater } from '@/composables/useAppUpdater'
+84 -12
View File
@@ -32,7 +32,7 @@
"uncategorized": "Uncategorized",
"search": "Search",
"paste_link": "Paste links here (Batch/Sub)",
"no_nodes": "No nodes available. Import some!",
"no_nodes": "No nodes available. Import some or add manually!",
"import_success": "Successfully imported {count} nodes",
"import_fail": "No valid nodes found or duplicates ignored",
"save": "Save",
@@ -48,7 +48,9 @@
"none": "None",
"auto": "Auto",
"enabled": "Enabled",
"disabled": "Disabled"
"disabled": "Disabled",
"add_node_hint": "Click the add button at the top right to import or create a node",
"no_data": "No Data"
},
"nodes": {
"add_node": "Add Node",
@@ -75,8 +77,12 @@
"no_nodes_found": "No nodes found matching your criteria.",
"check_latency": "Check Latency",
"delete_confirm": "Are you sure you want to delete node {name}?",
"delete_batch_confirm": "Are you sure you want to delete {count} selected nodes?",
"selected_count": "{count} nodes selected",
"select_all_visible": "Select all visible nodes",
"save_failed": "Save failed, please check inputs or console",
"no_nodes_to_export": "No nodes to export",
"no_selected_nodes_to_export": "Please select nodes to export first",
"export_success": "{count} node configurations copied to clipboard",
"export_failed": "Export failed",
"transport": "Transport",
@@ -98,15 +104,44 @@
"public_key": "Public Key (pbk)",
"short_id": "Short ID (sid)",
"plugin": "Plugin",
"plugin_opts": "Plugin Opts"
"plugin_opts": "Plugin Options",
"unnamed_node": "Unnamed Node",
"local_proxy": "Local Proxy",
"link_copied": "Node link copied to clipboard",
"link_copy_failed": "Failed to generate share link",
"group_placeholder": "Input group name",
"apply_group": "Apply Group",
"group_applied": "Group applied",
"tag_placeholder_short": "Input tag",
"apply_tag": "Apply Tag",
"tag_applied": "Tag updated",
"filter_all_types": "All Protocols",
"filter_all_groups": "All Groups",
"default_group": "Default Group",
"subscription_manage": "Subscription Management",
"subscription_name": "Subscription Name",
"subscription_url": "Subscription URL",
"subscription_schedule_startup": "Every App Launch",
"subscription_schedule_daily": "Daily",
"subscription_schedule_monthly": "Monthly",
"subscription_last_fetch": "Last Updated",
"subscription_refresh": "Refresh Now",
"subscription_never": "Never",
"subscription_delete_confirm": "Delete subscription \"{name}\"?",
"subscription_delete_with_nodes": "Delete subscription nodes too ({count})",
"subscription_delete_only": "Delete subscription only",
"subscription_invalid_url": "Please enter a valid subscription URL",
"subscription_invalid_form": "Please enter a subscription name and a valid subscription URL",
"subscription_added": "Subscription added",
"subscription_update_failed": "Subscription update failed"
},
"games": {
"library": "Game Library",
"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",
"configuration": "Configuration",
"mode": "Mode",
"mode_process": "Process Mode (Recommended)",
"mode_process_desc": "Only accelerate game processes.",
@@ -197,19 +232,56 @@
"local_proxy_info": "SOCKS5 port will be automatically set to: {socks}",
"local_proxy_node_strategy": "Node Selection",
"local_proxy_node_recursive": "Auto test nodes in order (Google + Apple), then use the first available one.",
"local_proxy_fixed_node_index": "Fixed Node Index (1-based)"
"local_proxy_fixed_node_index": "Fixed Node Index (1-based)",
"system_proxy": "System Proxy",
"system_proxy_port": "System Proxy Port",
"system_proxy_info": "System proxy uses HTTP port only (Windows system proxy settings).",
"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.",
"network_profile": "Network Profile",
"network_profile_stable": "Stable First",
"network_profile_aggressive": "Aggressive Low Latency",
"udp_mode": "UDP Strategy",
"udp_mode_auto": "Auto",
"udp_mode_prefer_udp": "Prefer UDP",
"udp_mode_prefer_tcp": "Prefer TCP",
"tun_stack": "TUN Stack",
"strict_route": "Strict Route",
"vless_packet_encoding": "VLESS Packet Encoding",
"vless_packet_encoding_off": "Override Off",
"vless_only": "VLESS only",
"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",
"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"
},
"categories": {
"new_category_placeholder": "Enter new category name..."
},
"dashboard": {
"hint": "Pro Tip: After starting acceleration, we recommend restarting the game to ensure all traffic is correctly routed through the virtual network interface.",
"securing_connection": "Securing Connection",
"ready_to_boost": "Ready to Boost",
"optimize_desc": "Optimize your network experience and say goodbye to lag.",
"active_protection": "Active Real-time Protection Enabled",
"hint": "Tip: After enabling acceleration, please restart the game to ensure traffic routes through the virtual network adapter.",
"hint_1": "After enabling acceleration, restart the game so traffic can route through the virtual adapter correctly.",
"hint_2": "Lower latency is not always more stable; prioritize nodes with lower packet loss when possible.",
"hint_3": "After switching node or mode, wait 3-5 seconds before judging latency changes.",
"hint_4": "If certificate warnings appear, try DoH DNS mode and flush DNS cache.",
"securing_connection": "Securing connection channel...",
"ready_to_boost": "Ready to boost - Project loaded",
"optimize_desc": "Optimize your network experience and say goodbye to latency.",
"active_protection": "Full-time network acceleration protection enabled",
"running_desc": "LagZero is routing your traffic through {node} with low-latency priority.",
"idle_desc": "Select your preferred node and click the button below to bypass congestion and reduce latency spikes.",
"did_you_know": "Did you know?",
"node_switch_failed": "Node switch failed, please try again",
"node_switch_failed": "Failed to switch node, please try again",
"system_proxy_mode": "System Proxy",
"system_proxy_windows_only": "System Proxy is only supported on Windows",
"mode_switching": "Switching mode...",
"mode_switch_failed": "Mode switch failed, rolled back",
"mode_switch_success": "Switched to {mode}",
"session_tuning": "Network Tuning",
"current_game_tuning_hint": "This panel applies to current game only",
"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",
"singbox_error": "sing-box error occurred"
@@ -233,4 +305,4 @@
"reason_settings": "Settings",
"reason_recheck": "Auto Recheck"
}
}
}
+75 -6
View File
@@ -32,7 +32,7 @@
"uncategorized": "未分类",
"search": "搜索",
"paste_link": "在此粘贴链接 (支持批量/订阅)",
"no_nodes": "暂无节点,请导入!",
"no_nodes": "暂无节点,请添加或导入!",
"import_success": "成功导入 {count} 个节点",
"import_fail": "未找到有效节点或全部重复",
"save": "保存",
@@ -48,7 +48,9 @@
"none": "无",
"auto": "自动",
"enabled": "已开启",
"disabled": "已关闭"
"disabled": "已关闭",
"add_node_hint": "点击右上角添加按钮导入或创建节点",
"no_data": "无数据"
},
"nodes": {
"add_node": "添加节点",
@@ -75,8 +77,12 @@
"no_nodes_found": "未找到符合条件的节点。",
"check_latency": "测速",
"delete_confirm": "确定要删除节点 {name} 吗?",
"delete_batch_confirm": "确定要删除选中的 {count} 个节点吗?",
"selected_count": "已选 {count} 个节点",
"select_all_visible": "全选当前可见节点",
"save_failed": "保存失败,请检查填写内容或查看控制台错误",
"no_nodes_to_export": "没有可导出的节点",
"no_selected_nodes_to_export": "请先选择要导出的节点",
"export_success": "已复制 {count} 个节点配置到剪贴板",
"export_failed": "导出失败",
"transport": "传输配置",
@@ -98,7 +104,36 @@
"public_key": "公钥 (pbk)",
"short_id": "短 ID (sid)",
"plugin": "插件",
"plugin_opts": "插件选项"
"plugin_opts": "插件选项",
"unnamed_node": "未命名节点",
"local_proxy": "本地代理",
"link_copied": "节点链接已复制",
"link_copy_failed": "生成分享链接失败",
"group_placeholder": "输入分组名",
"apply_group": "分组",
"group_applied": "已分组",
"tag_placeholder_short": "输入标签",
"apply_tag": "打标签",
"tag_applied": "标签已更新",
"filter_all_types": "全部协议",
"filter_all_groups": "全部分组",
"default_group": "默认分组",
"subscription_manage": "订阅管理",
"subscription_name": "订阅名称",
"subscription_url": "订阅 URL",
"subscription_schedule_startup": "每次启动软件",
"subscription_schedule_daily": "每天一次",
"subscription_schedule_monthly": "每月一次",
"subscription_last_fetch": "上次更新",
"subscription_refresh": "立即更新",
"subscription_never": "从未",
"subscription_delete_confirm": "删除订阅「{name}」?",
"subscription_delete_with_nodes": "同时删除该订阅节点({count})",
"subscription_delete_only": "仅删除订阅",
"subscription_invalid_url": "请输入有效订阅 URL",
"subscription_invalid_form": "请填写订阅名称并输入有效订阅 URL",
"subscription_added": "订阅已添加",
"subscription_update_failed": "订阅更新失败"
},
"games": {
"library": "游戏库",
@@ -107,7 +142,6 @@
"packet_loss": "丢包率",
"connection": "当前连接",
"duration": "加速时长",
"configuration": "加速配置",
"mode": "加速模式",
"mode_process": "进程模式",
"mode_process_desc": "仅加速游戏进程,不影响其他应用。",
@@ -198,13 +232,40 @@
"local_proxy_info": "SOCKS5 端口将自动设置为: {socks}",
"local_proxy_node_strategy": "节点选择",
"local_proxy_node_recursive": "按顺序测试节点(Google + Apple),自动使用第一个可用节点。",
"local_proxy_fixed_node_index": "固定节点序号(从 1 开始)"
"local_proxy_fixed_node_index": "固定节点序号(从 1 开始)",
"system_proxy": "系统代理",
"system_proxy_port": "系统代理端口",
"system_proxy_info": "系统代理仅使用 HTTP 端口(Windows 系统代理设置)。",
"game_network_tuning": "游戏网络优化",
"game_network_tuning_hint": "作为全局默认网络优化参数,作用于所有游戏(未单独覆盖时)。若加速中修改,将自动重启加速生效。",
"network_profile": "网络档位",
"network_profile_stable": "稳定优先",
"network_profile_aggressive": "激进低延迟",
"udp_mode": "UDP 策略",
"udp_mode_auto": "自动",
"udp_mode_prefer_udp": "优先 UDP",
"udp_mode_prefer_tcp": "优先 TCP",
"tun_stack": "TUN 协议栈",
"strict_route": "严格路由",
"vless_packet_encoding": "VLESS 数据包编码",
"vless_packet_encoding_off": "关闭覆盖",
"vless_only": "仅 VLESS",
"vless_only_hint": "当前所选节点不是 VLESS,该项不会生效。",
"high_loss_hint_only": "高丢包时仅提示,不自动回退配置",
"reset_session_network_tuning": "恢复默认",
"session_network_tuning_restarting": "正在应用网络优化参数并重启加速...",
"session_network_tuning_applied": "网络优化参数已生效",
"session_network_tuning_apply_failed": "应用网络优化参数失败"
},
"categories": {
"new_category_placeholder": "输入新分类名称..."
},
"dashboard": {
"hint": "温馨提示:开启加速后,建议重启游戏以确保所有流量正确经过虚拟网卡。",
"hint_1": "开启加速后,建议重启游戏,确保流量正确经过虚拟网卡。",
"hint_2": "节点延迟越低,不代表一定最稳;建议优先选择丢包率更低的节点。",
"hint_3": "切换节点或模式后,等待 3-5 秒再观察延迟变化,结果更准确。",
"hint_4": "如果出现网页证书异常,可尝试切换到 DoH DNS 模式并清理 DNS 缓存。",
"securing_connection": "正在建立安全加速通道",
"ready_to_boost": "准备就绪项目已加载",
"optimize_desc": "优化您的网络体验,告别延迟。",
@@ -213,6 +274,14 @@
"idle_desc": "选择您偏好的节点并点击下方按钮,即可绕过拥堵并减少延迟波动。",
"did_you_know": "你知道吗?",
"node_switch_failed": "切换节点失败,请重试",
"system_proxy_mode": "系统代理",
"system_proxy_windows_only": "系统代理目前仅支持 Windows",
"mode_switching": "正在切换模式...",
"mode_switch_failed": "模式切换失败,已回滚",
"mode_switch_success": "已切换为 {mode}",
"session_tuning": "网络优化",
"current_game_tuning_hint": "此处仅作用于当前游戏",
"high_loss_hint": "当前会话丢包偏高,建议切到“稳定优先”或将 UDP 改为“自动/优先 TCP”。",
"please_select_node": "请先选择一个节点",
"start_failed": "启动加速失败",
"singbox_error": "sing-box 发生错误"
@@ -236,4 +305,4 @@
"reason_settings": "设置项目",
"reason_recheck": "自动重检"
}
}
}
+168 -3
View File
@@ -1,12 +1,12 @@
import { defineStore } from 'pinia'
import { ref, computed, toRaw, reactive } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import type { Game } from '@/types'
import type { Game, SessionNetworkTuningOptions, NetworkProfile } from '@/types'
import { useNodeStore } from './nodes'
import { generateSingboxConfig } from '@/utils/singbox-config'
import { useSettingsStore } from './settings'
import { useLocalProxyStore } from './local-proxy'
import { gameApi, singboxApi, proxyMonitorApi } from '@/api'
import { gameApi, singboxApi, proxyMonitorApi, systemApi } from '@/api'
export type { Game }
@@ -65,6 +65,89 @@ export const useGameStore = defineStore('games', () => {
const operationState = ref<'idle' | 'starting' | 'stopping'>('idle')
/** 当前正在执行操作的游戏 ID。 */
const operationGameId = ref<string | null>(null)
/** 开启系统代理前的系统代理快照,用于停止和回滚恢复。 */
const systemProxySnapshot = ref<any | null>(null)
/** 当前会话中,按游戏维度覆盖的网络优化参数。 */
const gameSessionNetworkTuning = reactive<Record<string, SessionNetworkTuningOptions>>({})
function toPlainSnapshot(snapshot: any) {
if (snapshot == null) return null
try {
return JSON.parse(JSON.stringify(snapshot))
} catch {
return null
}
}
function cloneGlobalSessionNetworkTuning(): SessionNetworkTuningOptions {
const settingsStore = useSettingsStore()
const g = settingsStore.sessionNetworkTuning
return {
enabled: !!g.enabled,
profile: g.profile === 'aggressive' ? 'aggressive' : 'stable',
udpMode: g.udpMode,
tunMtu: g.tunMtu,
tunStack: g.tunStack,
strictRoute: !!g.strictRoute,
vlessPacketEncodingOverride: g.vlessPacketEncodingOverride,
highLossHintOnly: g.highLossHintOnly !== false
}
}
function getEffectiveSessionNetworkTuning(gameId?: string): SessionNetworkTuningOptions {
const id = String(gameId || '').trim()
if (id && gameSessionNetworkTuning[id]) return gameSessionNetworkTuning[id]!
return cloneGlobalSessionNetworkTuning()
}
function isUsingGlobalSessionNetworkTuning(gameId: string): boolean {
return !gameSessionNetworkTuning[String(gameId || '').trim()]
}
function ensureGameSessionNetworkTuning(gameId: string): SessionNetworkTuningOptions {
const id = String(gameId || '').trim()
if (!id) return cloneGlobalSessionNetworkTuning()
if (!gameSessionNetworkTuning[id]) {
gameSessionNetworkTuning[id] = cloneGlobalSessionNetworkTuning()
}
return gameSessionNetworkTuning[id]!
}
function resetGameSessionNetworkTuning(gameId: string): void {
const id = String(gameId || '').trim()
if (!id) return
gameSessionNetworkTuning[id] = cloneGlobalSessionNetworkTuning()
}
function applyGameSessionNetworkProfilePreset(
gameId: string,
profile: NetworkProfile,
options?: { isCurrentNodeVless?: boolean }
): void {
const tuning = ensureGameSessionNetworkTuning(gameId)
const isCurrentNodeVless = !!options?.isCurrentNodeVless
if (profile === 'aggressive') {
Object.assign(tuning, {
enabled: true,
profile: 'aggressive',
udpMode: 'prefer_udp',
tunMtu: 1360,
tunStack: 'mixed',
strictRoute: true,
vlessPacketEncodingOverride: isCurrentNodeVless ? 'xudp' : 'off'
})
return
}
Object.assign(tuning, {
enabled: true,
profile: 'stable',
udpMode: 'auto',
tunMtu: 1280,
tunStack: 'system',
strictRoute: false,
vlessPacketEncodingOverride: 'off'
})
}
/** 获取当前加速中的游戏(可排除某个 ID)。 */
function getAcceleratingGame(excludeId?: string): Game | null {
@@ -269,7 +352,30 @@ export const useGameStore = defineStore('games', () => {
operationState.value = 'starting'
operationGameId.value = id
const useSystemProxy = settingsStore.accelNetworkMode === 'system_proxy'
const previousSystemProxySnapshot = systemProxySnapshot.value
let systemProxyPortToUse = settingsStore.systemProxyPort
try {
if (useSystemProxy) {
const reservedPorts = new Set<number>()
if (settingsStore.localProxyEnabled) {
reservedPorts.add(settingsStore.localProxyPort)
reservedPorts.add(settingsStore.localProxyPort + 1)
}
let candidate = Math.max(1024, Math.floor(Number(settingsStore.systemProxyPort || 0)))
if (reservedPorts.has(candidate)) candidate += 2
let available = await systemApi.findAvailablePort(candidate, 1)
while (reservedPorts.has(available)) {
available = await systemApi.findAvailablePort(available + 1, 1)
}
if (available !== settingsStore.systemProxyPort) {
console.info(`[SystemProxy] system proxy port changed ${settingsStore.systemProxyPort} -> ${available}`)
settingsStore.systemProxyPort = available
}
systemProxyPortToUse = available
}
const rawGame = toRaw(game) as Game
const rawNode = toRaw(node) as any
const config = String(generateSingboxConfig(rawGame, rawNode, {
@@ -277,14 +383,40 @@ export const useGameStore = defineStore('games', () => {
primary: settingsStore.dnsPrimary,
secondary: settingsStore.dnsSecondary,
tunInterfaceName: settingsStore.tunInterfaceName,
accelNetworkMode: settingsStore.accelNetworkMode,
sessionTuning: { ...getEffectiveSessionNetworkTuning(rawGame.id) },
localProxyNode,
localProxy: {
enabled: settingsStore.localProxyEnabled,
port: settingsStore.localProxyPort
},
systemProxy: {
enabled: useSystemProxy,
port: systemProxyPortToUse
}
}))
await singboxApi.restart(config)
if (useSystemProxy) {
const proxyResult = await systemApi.setSystemProxy(systemProxyPortToUse, '<local>')
if (!proxyResult?.ok) {
await singboxApi.stop()
const rollbackResult = await systemApi.clearSystemProxy(proxyResult?.snapshot || previousSystemProxySnapshot || undefined)
if (!rollbackResult?.ok) {
console.warn('[SystemProxy] rollback failed after setSystemProxy error:', rollbackResult?.message)
}
systemProxySnapshot.value = previousSystemProxySnapshot || null
throw new Error(String(proxyResult?.message || 'Failed to set system proxy'))
}
systemProxySnapshot.value = toPlainSnapshot(proxyResult.snapshot) || toPlainSnapshot(previousSystemProxySnapshot)
} else {
const clearResult = await systemApi.clearSystemProxy(systemProxySnapshot.value || undefined)
if (!clearResult?.ok && !String(clearResult?.message || '').startsWith('Unsupported platform')) {
console.warn('[SystemProxy] clear before tun mode failed:', clearResult?.message)
}
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
if (shouldEnableChainProxy) await proxyMonitorApi.start(String(rawGame.id), procs)
@@ -292,6 +424,15 @@ export const useGameStore = defineStore('games', () => {
setGameStatus(id, 'accelerating')
} catch (e) {
if (useSystemProxy) {
try {
await singboxApi.stop()
} catch { }
try {
await systemApi.clearSystemProxy(previousSystemProxySnapshot || undefined)
} catch { }
systemProxySnapshot.value = toPlainSnapshot(previousSystemProxySnapshot)
}
console.error('Failed to start game acceleration:', e)
throw e
} finally {
@@ -314,6 +455,14 @@ export const useGameStore = defineStore('games', () => {
operationState.value = 'stopping'
operationGameId.value = _id
try {
try {
const clearResult = await systemApi.clearSystemProxy(systemProxySnapshot.value || undefined)
if (!clearResult?.ok && !String(clearResult?.message || '').startsWith('Unsupported platform')) {
console.warn('[SystemProxy] clear on stop failed:', clearResult?.message)
}
} finally {
systemProxySnapshot.value = null
}
await proxyMonitorApi.stop()
const settingsStore = useSettingsStore()
const localProxyStore = useLocalProxyStore()
@@ -334,6 +483,16 @@ export const useGameStore = defineStore('games', () => {
}
}
async function applySessionNetworkTuningChange(): Promise<boolean> {
const active = getAcceleratingGame()
if (!active?.id) return false
if (operationState.value !== 'idle') return false
await stopGame(active.id)
await startGame(active.id)
return true
}
/** 仓库创建后立即执行一次初始化。 */
init()
@@ -356,7 +515,13 @@ export const useGameStore = defineStore('games', () => {
updateGame,
removeGame,
startGame,
stopGame
stopGame,
applySessionNetworkTuningChange,
getEffectiveSessionNetworkTuning,
isUsingGlobalSessionNetworkTuning,
ensureGameSessionNetworkTuning,
resetGameSessionNetworkTuning,
applyGameSessionNetworkProfilePreset
}
})
-2
View File
@@ -182,8 +182,6 @@ export const useLocalProxyStore = defineStore('local-proxy', () => {
const primary = String(settings.dnsPrimary || '').trim()
const secondary = String(settings.dnsSecondary || '').trim()
const config = generateSingboxConfig(localGame, node, {
// Local browser/system proxy should always use secure DNS to avoid DNS pollution
// that can surface as certificate/privacy warnings across different node types.
mode: 'secure',
primary: primary.startsWith('https://') ? primary : DEFAULT_DNS_PRIMARY,
secondary: secondary.startsWith('https://') ? secondary : DEFAULT_DNS_SECONDARY,
+483 -5
View File
@@ -1,10 +1,11 @@
import { defineStore } from 'pinia'
import { ref, reactive } from 'vue'
import { ref, reactive, computed } from 'vue'
import { parseShareLink, parseBatchLinks, normalizeNodeType } from '@/utils/protocol'
import type { NodeConfig, CheckMethod, CheckRecordContext } from '@/types'
import { useSettingsStore } from './settings'
import { useGameStore } from './games'
import { nodeApi, systemApi } from '@/api'
import { useLocalStorage } from '@vueuse/core'
import {
appendLatencyRecord,
getGameLatencyStats,
@@ -12,9 +13,33 @@ import {
initLatencySessionStore
} from '@/utils/latency-session'
export type NodeSubscriptionSchedule = 'startup' | 'daily' | 'monthly'
export const DEFAULT_NODE_GROUP = 'default'
export interface NodeSubscription {
id: string
name: string
url: string
enabled: boolean
schedule: NodeSubscriptionSchedule
lastFetchedAt?: number
lastFetchStatus?: 'ok' | 'failed'
lastFetchMessage?: string
}
export const useNodeStore = defineStore('nodes', () => {
const nodes = ref<NodeConfig[]>([])
const nodeStats = reactive<Record<string, { latency: number; loss: number }>>({})
const selectedNodeKeys = ref<string[]>([])
const searchQuery = useLocalStorage('nodes-search-query', '')
const activeTypeFilter = useLocalStorage('nodes-filter-type', 'all')
const activeGroupFilter = useLocalStorage('nodes-filter-group', 'all')
const activeSortType = useLocalStorage<'default' | 'latency' | 'alphabetical'>('nodes-sort-type', 'default')
const nodeManualTags = useLocalStorage<Record<string, string>>('nodes-manual-tags', {})
const nodeSubscriptionGroups = useLocalStorage<Record<string, string>>('nodes-subscription-groups', {})
const legacyNodeGroups = useLocalStorage<Record<string, string>>('nodes-group-map', {})
const subscriptions = useLocalStorage<NodeSubscription[]>('nodes-subscriptions', [])
const startupScheduleChecked = ref(false)
const latencySessionReady = initLatencySessionStore().catch((e) => {
console.error('Failed to init latency session store:', e)
})
@@ -39,6 +64,399 @@ export const useNodeStore = defineStore('nodes', () => {
return -1
}
function nodeKeyOf(node: NodeConfig): string {
return String(node.id || node.tag || '').trim()
}
const selectedNodes = computed(() => {
const keySet = new Set(selectedNodeKeys.value)
return nodes.value.filter((node) => keySet.has(nodeKeyOf(node)))
})
const availableNodeTypes = computed(() => {
const set = new Set<string>()
for (const node of nodes.value) {
const type = String(node.type || '').trim().toLowerCase()
if (type) set.add(type)
}
return Array.from(set).sort((a, b) => a.localeCompare(b))
})
const availableGroups = computed(() => {
const set = new Set<string>()
for (const node of nodes.value) {
const labels = getNodeGroupLabels(node)
for (const label of labels) {
if (label) set.add(label)
}
}
set.add(DEFAULT_NODE_GROUP)
return Array.from(set).sort((a, b) => a.localeCompare(b))
})
const filteredNodes = computed(() => {
const q = String(searchQuery.value || '').trim().toLowerCase()
const typeFilter = String(activeTypeFilter.value || 'all')
const groupFilter = String(activeGroupFilter.value || 'all')
const sortType = activeSortType.value
const result = nodes.value.filter((node) => {
if (typeFilter !== 'all' && normalizeNodeType(node.type) !== typeFilter) return false
const labels = getNodeGroupLabels(node)
if (groupFilter !== 'all') {
if (groupFilter === DEFAULT_NODE_GROUP) {
if (labels.length !== 0) return false
} else if (!labels.includes(groupFilter)) {
return false
}
}
if (!q) return true
const haystack = [
node.tag,
node.server,
String(node.server_port || ''),
node.type,
...labels
].filter(Boolean).join(' ').toLowerCase()
return haystack.includes(q)
})
if (sortType === 'alphabetical') {
return result.sort((a, b) => {
const nameA = String(a.tag || a.server || '').toLowerCase()
const nameB = String(b.tag || b.server || '').toLowerCase()
return nameA.localeCompare(nameB)
})
}
if (sortType === 'latency') {
return result.sort((a, b) => {
const keyA = nodeKeyOf(a)
const keyB = nodeKeyOf(b)
const latA = nodeStats[keyA]?.latency ?? 999999
const latB = nodeStats[keyB]?.latency ?? 999999
// -1 usually means timeout or error, treat as high latency
const valA = latA < 0 ? 999999 : latA
const valB = latB < 0 ? 999999 : latB
return valA - valB
})
}
// Default (Creation Time / Insertion Order)
// Assuming nodes.value is already in insertion order
// If 'default' usually means newest first or oldest first?
// "Creation time" usually implies we want to see when it was added.
// Let's keep it as is (insertion order).
// If user wants reverse insertion order, I might need another option.
// But usually "Creation Time" in UI means "As added".
return result
})
function isNodeSelected(node: NodeConfig): boolean {
const key = nodeKeyOf(node)
return !!key && selectedNodeKeys.value.includes(key)
}
function pruneSelectedNodeKeys() {
const available = new Set(nodes.value.map(node => nodeKeyOf(node)).filter(Boolean))
selectedNodeKeys.value = selectedNodeKeys.value.filter(key => available.has(key))
}
function setNodeSelected(node: NodeConfig, selected: boolean) {
const key = nodeKeyOf(node)
if (!key) return
if (selected) {
if (!selectedNodeKeys.value.includes(key)) {
selectedNodeKeys.value = [...selectedNodeKeys.value, key]
}
return
}
selectedNodeKeys.value = selectedNodeKeys.value.filter(item => item !== key)
}
function toggleNodeSelected(node: NodeConfig) {
setNodeSelected(node, !isNodeSelected(node))
}
function clearSelectedNodes() {
selectedNodeKeys.value = []
}
function getNodeSubscriptionGroup(node: NodeConfig): string {
const key = nodeKeyOf(node)
if (!key) return ''
return String(nodeSubscriptionGroups.value[key] || '').trim()
}
function getNodeManualTag(node: NodeConfig): string {
const key = nodeKeyOf(node)
if (!key) return ''
return String(nodeManualTags.value[key] || '').trim()
}
function getNodeGroupLabels(node: NodeConfig): string[] {
const sub = getNodeSubscriptionGroup(node)
const tag = getNodeManualTag(node)
return Array.from(new Set([sub, tag].filter(Boolean)))
}
function getNodeGroup(node: NodeConfig): string {
const labels = getNodeGroupLabels(node)
return labels[0] || DEFAULT_NODE_GROUP
}
function setNodeGroup(node: NodeConfig, group: string) {
const key = nodeKeyOf(node)
if (!key) return
const next = { ...nodeManualTags.value }
const normalized = String(group || '').trim()
if (normalized && normalized !== DEFAULT_NODE_GROUP) {
next[key] = normalized
} else {
delete next[key]
}
nodeManualTags.value = next
}
function setGroupForSelectedNodes(group: string) {
const normalized = String(group || '').trim()
for (const node of selectedNodes.value) {
setNodeGroup(node, normalized)
}
}
function pruneNodeGroups() {
const available = new Set(nodes.value.map(node => nodeKeyOf(node)).filter(Boolean))
const nextManual: Record<string, string> = {}
for (const [key, group] of Object.entries(nodeManualTags.value || {})) {
const normalized = String(group || '').trim()
if (available.has(key) && normalized && normalized !== DEFAULT_NODE_GROUP) {
nextManual[key] = normalized
}
}
nodeManualTags.value = nextManual
const nextSub: Record<string, string> = {}
for (const [key, group] of Object.entries(nodeSubscriptionGroups.value || {})) {
const normalized = String(group || '').trim()
if (available.has(key) && normalized) {
nextSub[key] = normalized
}
}
nodeSubscriptionGroups.value = nextSub
}
function nodeSignatureOf(node: Partial<NodeConfig>): string {
return [
normalizeNodeType(String(node.type || '')),
String(node.server || '').trim().toLowerCase(),
String(node.server_port || '').trim(),
String(node.uuid || '').trim(),
String(node.password || '').trim(),
String(node.method || '').trim(),
String(node.username || '').trim()
].join('|')
}
function applyGroupFromContent(content: string, group: string) {
const normalizedGroup = String(group || '').trim() || DEFAULT_NODE_GROUP
const parsed = parseBatchLinks(content).map(normalizeNode)
if (!parsed.length) return
const signatures = new Set(parsed.map(nodeSignatureOf))
const next = { ...nodeSubscriptionGroups.value }
for (const node of nodes.value) {
if (signatures.has(nodeSignatureOf(node))) {
const key = nodeKeyOf(node)
if (!key) continue
if (normalizedGroup === DEFAULT_NODE_GROUP) {
delete next[key]
} else {
next[key] = normalizedGroup
}
}
}
nodeSubscriptionGroups.value = next
}
function sanitizeSubscriptions() {
const dedup = new Set<string>()
const next: NodeSubscription[] = []
for (const row of subscriptions.value || []) {
const id = String(row.id || '').trim()
const url = String(row.url || '').trim()
if (!id || !url || dedup.has(id)) continue
dedup.add(id)
next.push({
id,
name: String(row.name || '').trim() || 'Subscription',
url,
enabled: row.enabled !== false,
schedule: row.schedule || 'daily',
lastFetchedAt: row.lastFetchedAt,
lastFetchStatus: row.lastFetchStatus,
lastFetchMessage: row.lastFetchMessage
})
}
subscriptions.value = next
}
function makeSubscriptionId() {
return `sub-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
}
function isValidSubscriptionUrl(raw: string): boolean {
try {
const url = new URL(raw)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
function addSubscription(payload: {
name: string
url: string
schedule: NodeSubscriptionSchedule
enabled?: boolean
}) {
const name = String(payload.name || '').trim()
const url = String(payload.url || '').trim()
if (!name || !url || !isValidSubscriptionUrl(url)) return false
const item: NodeSubscription = {
id: makeSubscriptionId(),
name,
url,
enabled: payload.enabled !== false,
schedule: payload.schedule || 'daily'
}
subscriptions.value = [item, ...subscriptions.value]
sanitizeSubscriptions()
return true
}
async function removeSubscription(id: string, options?: { deleteNodes?: boolean }) {
const target = subscriptions.value.find((row) => row.id === id)
const targetName = String(target?.name || '').trim()
if (options?.deleteNodes && targetName) {
const ids = nodes.value
.filter((node) => getNodeSubscriptionGroup(node) === targetName)
.map((node) => node.id)
.filter(Boolean) as string[]
if (ids.length > 0) {
await removeNodes(ids)
}
}
subscriptions.value = subscriptions.value.filter((row) => row.id !== id)
if (target?.name) {
const name = String(target.name).trim()
const nextMap: Record<string, string> = {}
for (const [key, group] of Object.entries(nodeSubscriptionGroups.value || {})) {
if (group !== name) nextMap[key] = group
}
nodeSubscriptionGroups.value = nextMap
}
}
function updateSubscription(id: string, patch: Partial<Omit<NodeSubscription, 'id'>>) {
let oldName = ''
let newName = ''
subscriptions.value = subscriptions.value.map((row) => {
if (row.id !== id) return row
oldName = String(row.name || '').trim()
const next = {
...row,
...patch
}
newName = String(next.name || '').trim()
return next
})
// Keep subscription grouping consistent when subscription name changes.
if (oldName && newName && oldName !== newName) {
const nextMap: Record<string, string> = {}
for (const [key, group] of Object.entries(nodeSubscriptionGroups.value || {})) {
nextMap[key] = group === oldName ? newName : group
}
nodeSubscriptionGroups.value = nextMap
}
sanitizeSubscriptions()
}
function shouldRunSubscription(
sub: NodeSubscription,
reason: 'startup' | 'manual',
now = Date.now()
): boolean {
if (!sub.enabled) return false
if (reason === 'manual') return true
if (sub.schedule === 'startup') return true
if (!sub.lastFetchedAt) return true
if (sub.schedule === 'daily') {
return now - sub.lastFetchedAt >= 24 * 60 * 60 * 1000
}
if (sub.schedule === 'monthly') {
const last = new Date(sub.lastFetchedAt)
const current = new Date(now)
return last.getFullYear() !== current.getFullYear() || last.getMonth() !== current.getMonth()
}
return false
}
async function fetchSubscriptionContent(url: string): Promise<string> {
const response = await fetch(url, { method: 'GET', cache: 'no-store' })
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return response.text()
}
async function refreshSubscription(id: string): Promise<{ ok: boolean; count: number; message?: string }> {
const sub = subscriptions.value.find((row) => row.id === id)
if (!sub) return { ok: false, count: 0, message: 'subscription-not-found' }
try {
const content = await fetchSubscriptionContent(sub.url)
const count = await addNodes(content)
applyGroupFromContent(content, sub.name)
updateSubscription(id, {
lastFetchedAt: Date.now(),
lastFetchStatus: count > 0 ? 'ok' : 'failed',
lastFetchMessage: count > 0 ? '' : 'no-valid-nodes'
})
return {
ok: count > 0,
count,
message: count > 0 ? undefined : 'no-valid-nodes'
}
} catch (e) {
const message = e instanceof Error ? e.message : 'fetch-failed'
updateSubscription(id, {
lastFetchedAt: Date.now(),
lastFetchStatus: 'failed',
lastFetchMessage: message
})
return { ok: false, count: 0, message }
}
}
async function runScheduledSubscriptions(reason: 'startup' | 'manual' = 'startup') {
if (reason === 'startup' && startupScheduleChecked.value) {
return []
}
if (reason === 'startup') {
startupScheduleChecked.value = true
}
const runs: Array<{ id: string; ok: boolean; count: number; message?: string }> = []
const now = Date.now()
const due = subscriptions.value.filter((sub) => shouldRunSubscription(sub, reason, now))
for (const sub of due) {
const result = await refreshSubscription(sub.id)
runs.push({ id: sub.id, ...result })
}
return runs
}
async function checkNode(node: NodeConfig, methodOverride?: CheckMethod, context?: CheckRecordContext) {
if (!node.server) return
@@ -137,6 +555,13 @@ export const useNodeStore = defineStore('nodes', () => {
try {
const loaded = await nodeApi.getAll()
nodes.value = (loaded as NodeConfig[]).map(normalizeNode)
if (Object.keys(nodeManualTags.value || {}).length === 0 && Object.keys(legacyNodeGroups.value || {}).length > 0) {
nodeManualTags.value = { ...legacyNodeGroups.value }
legacyNodeGroups.value = {}
}
pruneSelectedNodeKeys()
pruneNodeGroups()
sanitizeSubscriptions()
} catch (e) {
console.error('Failed to load nodes:', e)
}
@@ -149,6 +574,8 @@ export const useNodeStore = defineStore('nodes', () => {
const normalized = normalizeNode(node)
const saved = await nodeApi.save(normalized)
nodes.value = (saved as NodeConfig[]).map(normalizeNode)
pruneSelectedNodeKeys()
pruneNodeGroups()
return true
} catch (e) {
console.error('Failed to save node:', e)
@@ -163,6 +590,8 @@ export const useNodeStore = defineStore('nodes', () => {
const normalized = normalizeNode(node)
const saved = await nodeApi.save(normalized)
nodes.value = (saved as NodeConfig[]).map(normalizeNode)
pruneSelectedNodeKeys()
pruneNodeGroups()
return true
} catch (e) {
console.error('Failed to save node:', e)
@@ -177,6 +606,8 @@ export const useNodeStore = defineStore('nodes', () => {
try {
const imported = await nodeApi.import(newNodes)
nodes.value = (imported as NodeConfig[]).map(normalizeNode)
pruneSelectedNodeKeys()
pruneNodeGroups()
// The import method on backend handles duplicates and returns all nodes
// We can't easily count how many were added unless backend returns that info
// But for now we just return parsed count
@@ -190,8 +621,14 @@ export const useNodeStore = defineStore('nodes', () => {
async function removeNode(id: string) {
try {
const removed = nodes.value.find((n) => n.id === id)
const remain = await nodeApi.delete(id)
nodes.value = (remain as NodeConfig[]).map(normalizeNode)
if (!removed) return
await nodeApi.delete(id)
// Update local state
nodes.value = nodes.value.filter(n => n.id !== id)
pruneSelectedNodeKeys()
pruneNodeGroups()
// If the accelerating game is bound to the removed node, stop acceleration immediately.
const removedKeys = new Set<string>([
@@ -202,11 +639,12 @@ export const useNodeStore = defineStore('nodes', () => {
const gameStore = useGameStore()
const accelerating = gameStore.getAcceleratingGame()
if (!accelerating?.id) return
if (!accelerating?.id || !accelerating.nodeId) return
const selectedNodeId = String(accelerating.nodeId || '').trim()
if (!selectedNodeId || !removedKeys.has(selectedNodeId)) return
// Double check if it still exists (in case multiple nodes had same ID/tag which shouldn't happen but safe to check)
const stillExists = nodes.value.some((n) => n.id === selectedNodeId || n.tag === selectedNodeId)
if (stillExists) return
@@ -216,6 +654,18 @@ export const useNodeStore = defineStore('nodes', () => {
}
}
async function removeNodes(ids: string[]) {
if (!ids.length) return
// Process one by one for now to ensure proper cleanup and game stopping logic
// In future this could be optimized to a batch API call if backend supports it
for (const id of ids) {
await removeNode(id)
}
clearSelectedNodes()
}
async function updateNode(node: NodeConfig) {
return saveNode(node)
}
@@ -225,11 +675,39 @@ export const useNodeStore = defineStore('nodes', () => {
return {
nodes,
searchQuery,
activeTypeFilter,
activeGroupFilter,
activeSortType,
filteredNodes,
availableNodeTypes,
availableGroups,
nodeManualTags,
nodeSubscriptionGroups,
subscriptions,
selectedNodeKeys,
selectedNodes,
isNodeSelected,
setNodeSelected,
toggleNodeSelected,
clearSelectedNodes,
getNodeGroup,
getNodeGroupLabels,
getNodeManualTag,
getNodeSubscriptionGroup,
setNodeGroup,
setGroupForSelectedNodes,
addSubscription,
removeSubscription,
updateSubscription,
refreshSubscription,
runScheduledSubscriptions,
loadNodes,
addNode,
saveNode,
addNodes,
removeNode,
removeNodes,
updateNode,
nodeStats,
checkNode,
+82 -13
View File
@@ -1,26 +1,90 @@
import { defineStore } from 'pinia'
import { reactive } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import { DEFAULT_DNS_PRIMARY, DEFAULT_DNS_SECONDARY } from '@/constants'
import type { CheckMethod, DnsMode } from '@/types'
import type { CheckMethod, DnsMode, Theme, ThemeColor, SessionNetworkTuningOptions, NetworkProfile } from '@/types'
function createDefaultSessionNetworkTuning(): SessionNetworkTuningOptions {
return {
enabled: true,
profile: 'stable',
udpMode: 'auto',
tunMtu: 1280,
tunStack: 'system',
strictRoute: false,
vlessPacketEncodingOverride: 'off',
highLossHintOnly: true
}
}
function createSessionNetworkTuningPreset(
profile: NetworkProfile,
isCurrentNodeVless: boolean
): Partial<SessionNetworkTuningOptions> {
if (profile === 'aggressive') {
return {
profile: 'aggressive',
udpMode: 'prefer_udp',
tunMtu: 1360,
tunStack: 'mixed',
strictRoute: true,
vlessPacketEncodingOverride: isCurrentNodeVless ? 'xudp' : 'off'
}
}
return {
profile: 'stable',
udpMode: 'auto',
tunMtu: 1280,
tunStack: 'system',
strictRoute: false,
vlessPacketEncodingOverride: 'off'
}
}
export const useSettingsStore = defineStore('settings', () => {
const checkInterval = useLocalStorage('settings-check-interval', 5000)
const checkMethod = useLocalStorage<CheckMethod>('settings-check-method', 'ping')
const checkUrl = useLocalStorage('settings-check-url', 'http://www.gstatic.com/generate_204')
const dnsMode = useLocalStorage<DnsMode>('settings-dns-mode', 'secure')
const dnsPrimary = useLocalStorage('settings-dns-primary', DEFAULT_DNS_PRIMARY)
const dnsSecondary = useLocalStorage('settings-dns-secondary', DEFAULT_DNS_SECONDARY)
const localProxyEnabled = useLocalStorage('settings-local-proxy-enabled', true)
const localProxyPort = useLocalStorage('settings-local-proxy-port', 10860)
const localProxyNodeRecursiveTest = useLocalStorage('settings-local-proxy-node-recursive-test', true)
const localProxyFixedNodeIndex = useLocalStorage('settings-local-proxy-fixed-node-index', 1)
const tunInterfaceName = useLocalStorage('settings-tun-interface-name', 'LagZero')
// Appearance
const language = useLocalStorage('settings-language', 'zh-CN') // 语言 默认zh-CN
const theme = useLocalStorage<Theme>('settings-theme', 'auto') // 主题 默认auto
const themeColor = useLocalStorage<ThemeColor>('settings-theme-color', 'green') // 主题色 默认green
// Network & Proxy
const checkInterval = useLocalStorage('settings-check-interval', 5000) // 检测间隔 默认5000ms
const checkMethod = useLocalStorage<CheckMethod>('settings-check-method', 'ping') // 检测模式 默认ping
// 检测URL
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 localProxyEnabled = useLocalStorage('settings-local-proxy-enabled', true) // 本地代理是否启用 默认true
const localProxyPort = useLocalStorage('settings-local-proxy-port', 10860) // 本地代理端口 默认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 tunInterfaceName = useLocalStorage('settings-tun-interface-name', 'LagZero') // TUN接口名称 默认LagZero
if (tunInterfaceName.value === 'singbox-tun') {
tunInterfaceName.value = 'LagZero'
}
// 会话级网络调优(仅内存生效,不持久化)
const sessionNetworkTuning = reactive<SessionNetworkTuningOptions>(createDefaultSessionNetworkTuning())
function resetSessionNetworkTuning() {
Object.assign(sessionNetworkTuning, createDefaultSessionNetworkTuning())
}
function applySessionNetworkProfilePreset(
profile: NetworkProfile,
options?: { isCurrentNodeVless?: boolean }
) {
const isCurrentNodeVless = !!options?.isCurrentNodeVless
Object.assign(sessionNetworkTuning, createSessionNetworkTuningPreset(profile, isCurrentNodeVless), { enabled: true })
}
return {
language,
theme,
themeColor,
checkInterval,
checkMethod,
checkUrl,
@@ -31,6 +95,11 @@ export const useSettingsStore = defineStore('settings', () => {
localProxyPort,
localProxyNodeRecursiveTest,
localProxyFixedNodeIndex,
tunInterfaceName
accelNetworkMode,
systemProxyPort,
tunInterfaceName,
sessionNetworkTuning,
resetSessionNetworkTuning,
applySessionNetworkProfilePreset
}
})
+7 -3
View File
@@ -4,9 +4,9 @@ export interface ElectronAPI {
minimize: () => Promise<void>
maximize: () => Promise<void>
close: () => Promise<void>
pickImage: () => Promise<string>
pickProcess: () => Promise<string[]>
pickProcessFolder: (maxDepth?: number) => Promise<string[]>
pickImage: () => Promise<string | null>
pickProcess: () => Promise<string[] | null>
pickProcessFolder: (maxDepth?: number) => Promise<string[] | null>
on: (channel: string, callback: (...args: any[]) => void) => void
off: (channel: string, callback: (...args: any[]) => void) => void
}
@@ -37,6 +37,9 @@ export interface SystemAPI {
targetPort?: number,
timeoutMs?: number
) => Promise<{ ok: boolean; statusLine: string; error?: string }>
setSystemProxy: (port: number, bypass?: string) => Promise<{ ok: boolean; message: string; snapshot?: any }>
clearSystemProxy: (snapshot?: any) => Promise<{ ok: boolean; message: string }>
getSystemProxyState: () => Promise<{ ok: boolean; message: string; state?: any }>
}
export interface ProxyMonitorAPI {
@@ -61,6 +64,7 @@ export interface CategoriesAPI {
getAll: () => Promise<Category[]>
save: (category: Category) => Promise<Category[]>
delete: (id: string) => Promise<Category[]>
match: (name: string, processes: string | string[]) => Promise<string | null>
}
export interface AppAPI {
+22
View File
@@ -64,6 +64,22 @@ export interface NodeConfig {
// Settings Types
export type CheckMethod = 'ping' | 'tcp' | 'http'
export type DnsMode = 'secure' | 'system'
export type AccelNetworkMode = 'tun' | 'system_proxy'
export type NetworkProfile = 'stable' | 'aggressive'
export type UdpMode = 'auto' | 'prefer_udp' | 'prefer_tcp'
export type TunStackMode = 'system' | 'mixed'
export type VlessPacketEncodingOverride = 'off' | 'xudp'
export interface SessionNetworkTuningOptions {
enabled: boolean
profile: NetworkProfile
udpMode: UdpMode
tunMtu: number
tunStack: TunStackMode
strictRoute: boolean
vlessPacketEncodingOverride: VlessPacketEncodingOverride
highLossHintOnly: boolean
}
// Theme Types
export type Theme = 'light' | 'dark' | 'auto'
@@ -75,6 +91,7 @@ export interface DnsConfigOptions {
primary?: string // 主 DNS 服务器地址
secondary?: string // 备用 DNS 服务器地址
tunInterfaceName?: string
accelNetworkMode?: AccelNetworkMode
disableTun?: boolean
localProxyNode?: NodeConfig
localProxyStrictNode?: boolean
@@ -82,6 +99,11 @@ export interface DnsConfigOptions {
enabled: boolean
port: number
}
systemProxy?: {
enabled: boolean
port: number
}
sessionTuning?: SessionNetworkTuningOptions
}
// Latency Session Types
+68
View File
@@ -2,6 +2,8 @@ import { logsApi } from '@/api'
import type { FrontendLogLevel, FrontendLogPayload } from '@/types'
const MAX_TEXT_LENGTH = 2000
const PROMPT_DEDUPE_WINDOW_MS = 1200
const promptSeenAt = new Map<string, number>()
function stringifyPart(value: unknown): string {
if (typeof value === 'string') return value
@@ -38,6 +40,70 @@ function push(payload: FrontendLogPayload) {
}
}
function normalizePromptText(text: string) {
return truncate(text.replace(/\s+/g, ' ').trim())
}
function shouldPushPrompt(key: string) {
const now = Date.now()
const last = promptSeenAt.get(key) || 0
if (now - last < PROMPT_DEDUPE_WINDOW_MS) return false
promptSeenAt.set(key, now)
return true
}
function inferPromptLevelByClassName(className: string): FrontendLogLevel {
const cls = className.toLowerCase()
if (cls.includes('error') || cls.includes('danger')) return 'error'
if (cls.includes('warning') || cls.includes('warn')) return 'warn'
if (cls.includes('success')) return 'info'
return 'info'
}
function collectPromptLogsFromElement(root: Element): FrontendLogPayload[] {
const logs: FrontendLogPayload[] = []
const buckets: Array<{ source: string, selector: string, type: string }> = [
{ source: 'renderer.prompt.message', selector: '.n-message', type: 'message' },
{ source: 'renderer.prompt.notification', selector: '.n-notification', type: 'notification' },
{ source: 'renderer.prompt.dialog', selector: '.n-dialog', type: 'dialog' }
]
for (const bucket of buckets) {
const candidates: Element[] = []
if (root.matches?.(bucket.selector)) candidates.push(root)
root.querySelectorAll(bucket.selector).forEach((el) => candidates.push(el))
for (const el of candidates) {
const text = normalizePromptText(el.textContent || '')
if (!text) continue
const key = `${bucket.type}|${text}`
if (!shouldPushPrompt(key)) continue
logs.push({
level: inferPromptLevelByClassName((el as HTMLElement).className || ''),
source: bucket.source,
message: `[${bucket.type}] ${text}`
})
}
}
return logs
}
function installPromptLayerLogging() {
const onMutation: MutationCallback = (mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (!(node instanceof Element)) continue
const entries = collectPromptLogsFromElement(node)
for (const entry of entries) push(entry)
}
}
}
const observer = new MutationObserver(onMutation)
observer.observe(document.documentElement, { childList: true, subtree: true })
}
declare global {
interface Window {
__lagzeroRuntimeLoggerInstalled?: boolean
@@ -82,4 +148,6 @@ export function setupRuntimeLogging() {
detail: truncate(stringifyPart(event.reason))
})
})
installPromptLayerLogging()
}
+102
View File
@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest'
import { generateSingboxConfig } from './singbox-config'
import type { Game, DnsConfigOptions } from '@/types'
const baseGame: Game = {
id: 'g1',
name: 'Test Game',
processName: ['game.exe'],
category: 'fps',
proxyMode: 'process',
routingRules: []
}
const baseNode: any = {
type: 'vless',
tag: 'node-1',
server: 'example.com',
server_port: 443,
uuid: '11111111-1111-1111-1111-111111111111',
network: 'tcp',
tls: {
enabled: true,
insecure: false
},
packet_encoding: 'packetaddr'
}
function buildConfig(options?: DnsConfigOptions): any {
return JSON.parse(generateSingboxConfig(baseGame, baseNode, options))
}
describe('generateSingboxConfig session tuning', () => {
it('keeps legacy defaults without 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.strict_route).toBe(false)
expect(tun.stack).toBe('system')
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')
})
it('applies stable/aggressive tun mappings and vless xudp override', () => {
const cfg = buildConfig({
mode: 'secure',
sessionTuning: {
enabled: true,
profile: 'aggressive',
udpMode: 'auto',
tunMtu: 1360,
tunStack: 'mixed',
strictRoute: true,
vlessPacketEncodingOverride: 'xudp',
highLossHintOnly: true
}
})
const tun = cfg.inbounds.find((i: any) => i.tag === 'tun-in')
expect(tun.mtu).toBe(1360)
expect(tun.strict_route).toBe(true)
expect(tun.stack).toBe('mixed')
const nodeOut = cfg.outbounds.find((o: any) => o.tag === 'node-out')
expect(nodeOut.packet_encoding).toBe('xudp')
})
it('injects UDP preference rules by mode', () => {
const preferUdp = buildConfig({
mode: 'secure',
sessionTuning: {
enabled: true,
profile: 'stable',
udpMode: 'prefer_udp',
tunMtu: 1280,
tunStack: 'system',
strictRoute: false,
vlessPacketEncodingOverride: 'off',
highLossHintOnly: true
}
})
const udpProxyRule = preferUdp.route.rules.find((r: any) => r.protocol === 'udp')
expect(udpProxyRule?.outbound).toBe('proxy')
const preferTcp = buildConfig({
mode: 'secure',
sessionTuning: {
enabled: true,
profile: 'stable',
udpMode: 'prefer_tcp',
tunMtu: 1280,
tunStack: 'system',
strictRoute: false,
vlessPacketEncodingOverride: 'off',
highLossHintOnly: true
}
})
const udpDirectRule = preferTcp.route.rules.find((r: any) => r.protocol === 'udp')
expect(udpDirectRule?.outbound).toBe('direct')
})
})
+95 -12
View File
@@ -3,7 +3,7 @@
* 用于将游戏规则和节点配置转换为 Sing-box 可识别的 JSON 格式
*/
import type { Game, DnsConfigOptions, SingboxConfig } from '@/types'
import type { Game, DnsConfigOptions, SingboxConfig, SessionNetworkTuningOptions, VlessPacketEncodingOverride } from '@/types'
import type { NodeConfig } from '@/utils/protocol'
import pkg from '../../package.json'
@@ -23,19 +23,22 @@ const SAGERNET_GEOSITE_CN_SRS_URL = 'https://cdn.jsdelivr.net/gh/SagerNet/sing-g
export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?: DnsConfigOptions): string {
const isRoutingMode = game.proxyMode === 'routing'
const processes = normalizeProcessNames(Array.isArray(game.processName) ? game.processName : [game.processName])
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)
const hasRoutingProcessScope = isRoutingMode && processes.length > 0
const hasProcessScope = processes.length > 0
const hasRoutingProcessScope = isRoutingMode && hasProcessScope
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 tunInterfaceName = String(dnsOptions?.tunInterfaceName || pkg.productName).trim() || pkg.productName
const useSecureDns = dnsMode === 'secure'
const disableTun = !!dnsOptions?.disableTun
const disableTun = !!dnsOptions?.disableTun || accelNetworkMode === 'system_proxy'
const localProxyNode = dnsOptions?.localProxyNode
const localProxyStrictNode = !!dnsOptions?.localProxyStrictNode
const sessionTuning = resolveSessionTuning(dnsOptions?.sessionTuning)
// 基础配置结构
const config: SingboxConfig = {
@@ -110,14 +113,19 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
tag: 'tun-in',
interface_name: tunInterfaceName,
inet4_address: '172.19.0.1/30',
mtu: 1280,
mtu: sessionTuning.tunMtu,
auto_route: true,
strict_route: false,
stack: 'system',
strict_route: sessionTuning.strictRoute,
stack: sessionTuning.tunStack,
sniff: true
})
}
const udpRule = buildUdpPreferenceRule(sessionTuning, !disableTun)
if (udpRule) {
config.route.rules.push(udpRule)
}
if (dnsOptions?.localProxy?.enabled) {
const httpPort = dnsOptions.localProxy.port
const socksPort = httpPort + 1
@@ -159,6 +167,27 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
})
}
if (dnsOptions?.systemProxy?.enabled) {
const httpPort = dnsOptions.systemProxy.port
config.inbounds.push({
type: 'http',
tag: 'system-http-in',
listen: '127.0.0.1',
listen_port: httpPort,
sniff: true,
set_system_proxy: false
})
// In routing mode, keep geo/ip rule matching behavior. Do not force inbound->proxy.
if (!isRoutingMode) {
config.route.rules.push({
inbound: ['system-http-in'],
outbound: 'proxy'
})
}
}
if (useSecureDns) {
const bootstrapDomains = collectBootstrapDomains(node, localProxyNode)
if (bootstrapDomains.length > 0) {
@@ -171,11 +200,11 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
}
// 1. 将节点配置转换为 Outbound 格式并添加到配置中
const nodeOutbound = convertNodeToOutbound(node)
const nodeOutbound = convertNodeToOutbound(node, 'node-out', sessionTuning.vlessPacketEncodingOverride)
config.outbounds.push(nodeOutbound)
// 2. 配置 DNS 规则:如果开启了安全 DNS 且定义了进程,则强制该进程使用远端 DNS
if (processes.length > 0 && useSecureDns) {
if (hasProcessScope && useSecureDns && (!isRoutingMode || hasRoutingProcessScope)) {
config.dns.rules.unshift({
process_name: processes,
server: 'remote-primary'
@@ -235,14 +264,14 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
ip_cidr: routingIpCidrs,
outbound: 'proxy'
}
if (processes.length > 0) {
if (hasRoutingProcessScope) {
rule.process_name = processes
}
config.route.rules.push(rule)
}
} else {
// 进程模式:仅指定的游戏进程走代理,其余默认直连
if (processes.length > 0) {
if (hasProcessScope) {
config.route.rules.push({
process_name: processes,
outbound: 'proxy'
@@ -259,7 +288,11 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
* @param node 原始节点配置
* @returns Sing-box outbound 对象
*/
function convertNodeToOutbound(node: NodeConfig, tag: string = 'node-out'): any {
function convertNodeToOutbound(
node: NodeConfig,
tag: string = 'node-out',
vlessPacketEncodingOverride: VlessPacketEncodingOverride = 'off'
): any {
const base = {
tag,
server: node.server,
@@ -295,7 +328,7 @@ function convertNodeToOutbound(node: NodeConfig, tag: string = 'node-out'): any
type: 'vless',
uuid: node.uuid,
flow: node.flow,
packet_encoding: node.packet_encoding,
packet_encoding: vlessPacketEncodingOverride === 'xudp' ? 'xudp' : node.packet_encoding,
transport,
tls
}
@@ -527,3 +560,53 @@ function isValidCidr(value: string): boolean {
if (!isIpLiteral(ip)) return false
return bits >= 0 && bits <= 32
}
function resolveSessionTuning(tuning?: SessionNetworkTuningOptions): SessionNetworkTuningOptions {
if (!tuning?.enabled) {
return {
enabled: false,
profile: 'stable',
udpMode: 'auto',
tunMtu: 1280,
tunStack: 'system',
strictRoute: false,
vlessPacketEncodingOverride: 'off',
highLossHintOnly: true
}
}
const tunMtu = normalizeMtu(tuning.tunMtu)
return {
enabled: true,
profile: tuning.profile === 'aggressive' ? 'aggressive' : 'stable',
udpMode: tuning.udpMode === 'prefer_udp' || tuning.udpMode === 'prefer_tcp' ? tuning.udpMode : 'auto',
tunMtu,
tunStack: tuning.tunStack === 'mixed' ? 'mixed' : 'system',
strictRoute: !!tuning.strictRoute,
vlessPacketEncodingOverride: tuning.vlessPacketEncodingOverride === 'xudp' ? 'xudp' : 'off',
highLossHintOnly: tuning.highLossHintOnly !== false
}
}
function normalizeMtu(value: number): number {
const fallback = 1280
const n = Number(value)
if (!Number.isFinite(n)) return fallback
return Math.min(1500, Math.max(1200, Math.floor(n)))
}
function buildUdpPreferenceRule(
tuning: SessionNetworkTuningOptions,
hasTunInbound: boolean
): Record<string, any> | null {
if (!tuning.enabled || tuning.udpMode === 'auto') return null
const rule: Record<string, any> = {
protocol: 'udp',
outbound: tuning.udpMode === 'prefer_udp' ? 'proxy' : 'direct'
}
if (hasTunInbound) {
rule.inbound = ['tun-in']
}
return rule
}
+265 -48
View File
@@ -47,7 +47,7 @@
<div v-else class="i-material-symbols-sports-esports text-xl text-primary/60"></div>
</div>
<div>
<h1 class="text-lg font-bold leading-tight text-on-surface">{{ game.name }}</h1>
<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"
@@ -69,49 +69,126 @@
<div class="w-1.5 h-1.5 rounded-full bg-success animate-pulse"></div>
<span class="text-[10px] font-black uppercase tracking-widest text-success">Live</span>
</div>
<div class="flex flex-row gap-1">
<div class="flex items-center p-1 rounded-xl border border-border/60 bg-surface-overlay/60">
<button @click="onNetworkModeChange('tun')" :disabled="isActionPending"
class="px-3 py-1 text-xs rounded-lg transition font-bold disabled:opacity-60 disabled:cursor-not-allowed"
:class="settingsStore.accelNetworkMode === 'tun'
? 'bg-surface text-primary shadow-sm'
: 'text-on-surface-muted hover:text-on-surface'">
TUN
</button>
<button @click="onNetworkModeChange('system_proxy')" :disabled="isActionPending"
class="px-3 py-1 text-xs rounded-lg transition font-bold disabled:opacity-60 disabled:cursor-not-allowed"
:class="settingsStore.accelNetworkMode === 'system_proxy'
? 'bg-surface text-primary shadow-sm'
: 'text-on-surface-muted hover:text-on-surface'">
{{ $t('dashboard.system_proxy_mode') }}
</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">
{{ $t('dashboard.session_tuning') }}
</button>
</div>
</div>
</header>
<div v-if="showSessionTuningPanel"
class="mx-6 mt-4 p-4 rounded-2xl border border-border/60 bg-surface-panel/60 backdrop-blur-sm">
<div class="flex items-center justify-between mb-3">
<div class="text-xs font-black uppercase tracking-widest text-on-surface-muted">
{{ $t('settings.game_network_tuning') }}
</div>
<button @click="resetCurrentGameSessionTuning()"
class="px-2 py-1 text-[11px] rounded-md border border-border/50 text-on-surface-muted hover:text-on-surface transition">
{{ $t('settings.reset_session_network_tuning') }}
</button>
</div>
<p class="text-xs text-on-surface-muted mb-3">{{ $t('dashboard.current_game_tuning_hint') }}</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" />
</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 }">
<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'
: 'text-on-surface-muted hover:text-on-surface'">
{{ $t('settings.network_profile_stable') }}
</button>
<button @click="applyProfilePreset('aggressive')"
class="flex-1 py-2 text-xs rounded-lg transition-all font-bold" :class="currentGameSessionTuning.profile === 'aggressive'
? 'bg-surface shadow-sm text-primary'
: 'text-on-surface-muted hover:text-on-surface'">
{{ $t('settings.network_profile_aggressive') }}
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3"
:class="{ 'opacity-50 pointer-events-none': !currentGameSessionTuning.enabled }">
<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" />
<div class="space-y-1">
<n-select v-model:value="currentGameSessionTuning.vlessPacketEncodingOverride"
:disabled="!currentNodeIsVless" :options="vlessEncodingOptions" size="small" />
<p v-if="!currentNodeIsVless" class="text-[11px] text-warning">
{{ $t('settings.vless_only_hint') }}
</p>
</div>
<div class="flex items-center justify-between rounded-lg border border-border/50 px-3 py-2">
<span class="text-xs text-on-surface-muted">{{ $t('settings.strict_route') }}</span>
<n-switch v-model:value="currentGameSessionTuning.strictRoute" size="small" />
</div>
</div>
</div>
<div class="flex-1 flex overflow-hidden">
<!-- Main Dashboard Area (Left) -->
<main class="flex-1 overflow-y-auto custom-scrollbar p-6 lg:p-10 flex flex-col gap-8 relative">
<main
class="flex-1 overflow-y-auto custom-scrollbar p-6 lg:p-10 flex flex-col justify-evenly items-center gap-8 relative">
<!-- 1. Hero Section: The Status Core -->
<section class="flex flex-col items-center justify-center min-h-[500px] ">
<div v-if="isRunning" class=" w-full max-w-2xl flex justify-center">
<div
class="flex items-center gap-8 md:gap-12 px-8 py-4 bg-surface-panel/40 backdrop-blur-lg border border-border/50 rounded-2xl shadow-sm">
<section class="flex flex-col items-center justify-center">
<!-- Live Stats (Level 1: Latency & Loss) -->
<div v-if="isRunning" class="mb-10 w-full flex justify-center animate-scale-in">
<div class="flex items-center gap-12 md:gap-20">
<!-- Latency -->
<div class="flex flex-col items-center">
<span class="text-[10px] uppercase font-bold text-on-surface-muted tracking-widest mb-1">{{
$t('games.packet_loss') }}</span>
<span class="text-2xl font-mono font-bold text-on-surface tabular-nums">{{ currentLoss
}}%</span>
<div class="flex items-baseline justify-center gap-2 relative">
<span
class="text-7xl md:text-8xl font-black tabular-nums tracking-tighter drop-shadow-sm transition-colors duration-300"
:class="getLatencyTextColor(currentLatency)">
{{ currentLatency > 0 ? currentLatency : '---' }}
</span>
<span
class="text-xl font-bold text-on-surface-muted uppercase tracking-widest absolute -right-10 bottom-3">ms</span>
</div>
<span class="text-xs uppercase font-bold text-on-surface-muted tracking-widest mt-2">{{
$t('common.latency') || '实时延迟' }}</span>
</div>
<div class="w-px h-8 bg-border"></div>
<div class="w-px h-16 md:h-20 bg-border/60"></div>
<!-- Packet Loss -->
<div class="flex flex-col items-center">
<span class="text-[10px] uppercase font-bold text-on-surface-muted tracking-widest mb-1">{{
$t('games.duration') }}</span>
<span class="text-2xl font-mono font-bold text-on-surface tabular-nums">{{ durationFormatted
}}</span>
<div class="flex items-baseline justify-center gap-2 relative">
<span
class="text-7xl md:text-8xl font-black tabular-nums tracking-tighter drop-shadow-sm transition-colors duration-300"
:class="currentLoss > 0 ? 'text-warning' : 'text-on-surface'">
{{ currentLoss }}
</span>
<span class="text-xl font-bold text-on-surface-muted absolute -right-6 bottom-3">%</span>
</div>
<span class="text-xs uppercase font-bold text-on-surface-muted tracking-widest mt-2">{{
$t('games.packet_loss') }}</span>
</div>
</div>
</div>
<div class="relative z-10 flex flex-col items-center">
<!-- Latency Big Display -->
<div v-if="isRunning" class="mb-12 text-center animate-scale-in">
<div class="flex items-baseline justify-center gap-3 relative">
<span
class="text-8xl md:text-9xl font-black tabular-nums tracking-tighter drop-shadow-sm transition-colors duration-300"
:class="getLatencyTextColor(currentLatency)">
{{ currentLatency > 0 ? currentLatency : '---' }}
</span>
<span
class="text-xl font-bold text-on-surface-muted uppercase tracking-widest absolute -right-10 bottom-5">ms</span>
</div>
</div>
<!-- Main Action Button -->
<div class="relative group">
<!-- Outer Glow Ring -->
@@ -152,7 +229,7 @@
? '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)]')">
</div>
<span class="text-xs font-black uppercase tracking-[0.25em] transition-colors"
<span class=" text-xs font-black uppercase tracking-[0.25em] transition-colors"
:class="isRunning ? 'text-error' : 'text-primary'">
{{ actionLabel }}
</span>
@@ -160,10 +237,22 @@
</button>
</div>
<p class="mt-10 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') }}
</p>
<!-- Status Description & Duration (Level 2) -->
<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') }}
</p>
<!-- Duration Badge -->
<div v-if="isRunning"
class="flex items-center gap-2 px-3 py-1 bg-surface-overlay/50 border border-border/50 rounded-full text-[11px] font-medium text-on-surface-muted animate-fade-in-up mt-1 shadow-sm">
<div class="i-carbon-time text-sm"></div>
<span>{{ $t('games.duration') }}: <span
class="font-mono font-bold text-on-surface tracking-wide ml-1">{{ durationFormatted
}}</span></span>
</div>
</div>
</div>
</section>
@@ -179,15 +268,15 @@
</section>
<!-- Expert Tip (Bottom) -->
<div v-else
class="mt-auto mx-auto max-w-lg w-full p-5 rounded-2xl bg-surface-panel/40 border border-border flex items-start gap-4 shadow-sm backdrop-blur-sm">
<div v-if="!isRunning"
class="max-w-lg w-full p-5 rounded-2xl bg-surface-panel/40 border border-border flex items-start gap-4 shadow-sm backdrop-blur-sm">
<div
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0 text-primary">
<div class="i-material-symbols-lightbulb text-lg"></div>
</div>
<div>
<h4 class="text-xs font-bold text-on-surface mb-1">{{ $t('dashboard.did_you_know') }}</h4>
<p class="text-xs text-on-surface-muted leading-relaxed">{{ $t('dashboard.hint') }}</p>
<h4 class="text-xs font-bold text-on-surface mb-1 ">{{ $t('dashboard.did_you_know') }}</h4>
<p class="text-xs text-on-surface-muted leading-relaxed">{{ currentDashboardHint }}</p>
</div>
</div>
</main>
@@ -195,16 +284,11 @@
<!-- Sidebar: Configuration & Node Selector (Right) -->
<aside
class="w-80 lg:w-96 flex-none bg-surface-panel/60 border-l border-border/50 backdrop-blur-xl flex flex-col z-10 shadow-[-5px_0_30px_-5px_rgba(0,0,0,0.05)]">
<!-- Sidebar Header -->
<div class="p-5 border-b border-border/50 flex items-center justify-between">
<span class="text-xs font-black uppercase tracking-widest text-on-surface-muted">{{
$t('games.configuration') }}</span>
</div>
<!-- 1. Node Selector (Takes most space) -->
<div class="flex-1 overflow-hidden flex flex-col p-5 gap-4">
<div class="flex items-center justify-between">
<span class="text-sm font-bold text-on-surface">{{ $t('nodes.select_node') }}</span>
<span class="text-sm font-bold text-on-surface ">{{ $t('nodes.select_node') }}</span>
<button @click="$router.push('/nodes')"
class="text-xs font-medium text-primary hover:text-primary-hover hover:underline">{{
$t('common.more') }}</button>
@@ -221,8 +305,11 @@
<label class="text-[10px] font-black uppercase tracking-widest text-on-surface-muted mb-3 block">
{{ $t('games.mode') }}
</label>
<div class="rounded-xl border border-border bg-surface-overlay px-3 py-2 text-xs font-bold flex items-center gap-2">
<div :class="activeProxyMode === 'process' ? 'i-carbon-application-web' : 'i-carbon-network-overlay'"></div>
<div
class="rounded-xl border border-border bg-surface-overlay px-3 py-2 text-xs font-bold flex items-center gap-2">
<div
:class="activeProxyMode === 'process' ? 'i-carbon-application-web' : 'i-carbon-network-overlay'">
</div>
<span>
{{ activeProxyMode === 'process' ? $t('games.mode_process') : $t('games.mode_routing') }}
</span>
@@ -247,7 +334,8 @@ import LatencyChart from '@/components/dashboard/LatencyChart.vue'
import NodeSelector from '@/components/dashboard/NodeSelector.vue'
import { useIntervalFn } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { useMessage } from 'naive-ui'
import { useMessage, NInputNumber, NSelect, NSwitch } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import i18n from '@/i18n'
import { electronApi } from '@/api'
@@ -258,12 +346,14 @@ const nodeStore = useNodeStore()
const settingsStore = useSettingsStore()
const { checkInterval } = storeToRefs(settingsStore)
const message = useMessage()
const { t } = useI18n()
// State
const game = computed(() => gameStore.currentGame)
const isRunning = computed(() => game.value?.status === 'accelerating')
const isSwitchingNode = ref(false)
const isActionPending = computed(() => isSwitchingNode.value || gameStore.operationState !== 'idle')
const isModeSwitching = ref(false)
const isActionPending = computed(() => isSwitchingNode.value || isModeSwitching.value || gameStore.operationState !== 'idle')
const actionLabel = computed(() => {
if (gameStore.operationState === 'starting') return '启动中'
if (gameStore.operationState === 'stopping') return '暂停中'
@@ -277,6 +367,31 @@ const lastConnection = ref('')
const pendingNodeRestart = ref(false)
const totalSamples = ref(0)
const lostSamples = ref(0)
const showSessionTuningPanel = ref(false)
const highLossHintShown = ref(false)
const applyingSessionTuning = ref(false)
const udpModeOptions = computed(() => ([
{ label: String(t('settings.udp_mode_auto')), value: 'auto' },
{ label: String(t('settings.udp_mode_prefer_udp')), value: 'prefer_udp' },
{ label: String(t('settings.udp_mode_prefer_tcp')), value: 'prefer_tcp' }
]))
const tunStackOptions = [
{ label: 'system', value: 'system' },
{ label: 'mixed', value: 'mixed' }
]
const vlessEncodingOptions = computed(() => ([
{ label: String(t('settings.vless_packet_encoding_off')), value: 'off' },
{ label: 'xudp', value: 'xudp' }
]))
function applyProfilePreset(profile: 'stable' | 'aggressive') {
const gameId = String(game.value?.id || '').trim()
if (!gameId) return
gameStore.applyGameSessionNetworkProfilePreset(gameId, profile, {
isCurrentNodeVless: currentNodeIsVless.value
})
}
// Computed Helpers
const categoryLabel = computed(() => {
@@ -286,8 +401,33 @@ const categoryLabel = computed(() => {
})
const selectedNode = computed(() => game.value?.nodeId || null)
const currentGameSessionTuning = computed(() => {
const gameId = String(game.value?.id || '').trim()
if (!gameId) return settingsStore.sessionNetworkTuning
return gameStore.ensureGameSessionNetworkTuning(gameId)
})
const currentNodeIsVless = computed(() => {
const selectedNodeId = String(selectedNode.value || '').trim()
if (!selectedNodeId) return false
const node = nodeStore.nodes.find(n => String(n.id || n.tag) === selectedNodeId)
return String(node?.type || '').toLowerCase() === 'vless'
})
function resetCurrentGameSessionTuning() {
const gameId = String(game.value?.id || '').trim()
if (!gameId) return
gameStore.resetGameSessionNetworkTuning(gameId)
}
const activeProxyMode = computed<'process' | 'routing'>(() => game.value?.proxyMode === 'routing' ? 'routing' : 'process')
const dashboardHints = computed(() => ([
String(i18n.global.t('dashboard.hint_1')),
String(i18n.global.t('dashboard.hint_2')),
String(i18n.global.t('dashboard.hint_3')),
String(i18n.global.t('dashboard.hint_4'))
]))
const currentHintIndex = ref(0)
const currentDashboardHint = computed(() => dashboardHints.value[currentHintIndex.value] || '')
const durationFormatted = computed(() => {
const s = durationSeconds.value
@@ -333,6 +473,40 @@ async function onNodeChange(val: string) {
}
}
function modeLabelOf(mode: 'tun' | 'system_proxy') {
return mode === 'system_proxy'
? String(i18n.global.t('dashboard.system_proxy_mode'))
: 'TUN'
}
async function onNetworkModeChange(nextMode: 'tun' | 'system_proxy') {
if (!game.value) return
if (isActionPending.value) return
const prevMode = settingsStore.accelNetworkMode
if (prevMode === nextMode) return
settingsStore.accelNetworkMode = nextMode
if (!isRunning.value || !game.value.id) return
isModeSwitching.value = true
try {
await gameStore.stopGame(game.value.id)
await gameStore.startGame(game.value.id)
message.success(i18n.global.t('dashboard.mode_switch_success', { mode: modeLabelOf(nextMode) }))
} catch (e: any) {
settingsStore.accelNetworkMode = prevMode
try {
await gameStore.stopGame(game.value.id)
await gameStore.startGame(game.value.id)
} catch (restoreError) {
console.error('Failed to restore previous acceleration mode after rollback', restoreError)
}
message.error(String(e?.message || e || i18n.global.t('dashboard.mode_switch_failed')))
} finally {
isModeSwitching.value = false
}
}
function refreshDuration() {
if (!isRunning.value || startTime.value <= 0) {
durationSeconds.value = 0
@@ -347,6 +521,16 @@ function refreshSessionLossRate() {
return
}
currentLoss.value = Math.round((lostSamples.value / totalSamples.value) * 100)
if (
isRunning.value
&& currentGameSessionTuning.value.highLossHintOnly
&& !highLossHintShown.value
&& totalSamples.value >= 20
&& currentLoss.value >= 15
) {
highLossHintShown.value = true
message.warning(String(i18n.global.t('dashboard.high_loss_hint')))
}
}
async function syncSessionStateFromStore() {
@@ -398,6 +582,11 @@ const { pause: pauseDuration, resume: resumeDuration } = useIntervalFn(() => {
refreshDuration()
}, 1000, { immediate: false })
const { pause: pauseHintRotation, resume: resumeHintRotation } = useIntervalFn(() => {
if (dashboardHints.value.length <= 1) return
currentHintIndex.value = (currentHintIndex.value + 1) % dashboardHints.value.length
}, 5000, { immediate: false })
async function toggleAccelerator() {
if (!game.value) return
if (isActionPending.value) return
@@ -485,10 +674,14 @@ onActivated(() => {
watch(isRunning, (running) => {
if (running) {
highLossHintShown.value = false
pauseHintRotation()
void syncSessionStateFromStore().then(() => sampleLatencyAndLoss(true))
resumeSampling()
resumeDuration()
} else {
currentHintIndex.value = 0
resumeHintRotation()
pauseSampling()
pauseDuration()
startTime.value = 0
@@ -501,6 +694,7 @@ watch(isRunning, (running) => {
}, { immediate: true })
onUnmounted(() => {
pauseHintRotation()
pauseSampling()
pauseDuration()
try {
@@ -512,6 +706,29 @@ onUnmounted(() => {
}
} catch (e) { }
})
watch(
() => JSON.stringify(currentGameSessionTuning.value),
async (next, prev) => {
if (next === prev) return
if (applyingSessionTuning.value) return
if (!isRunning.value) return
if (gameStore.operationState !== 'idle') return
applyingSessionTuning.value = true
try {
message.info(String(i18n.global.t('settings.session_network_tuning_restarting')))
const restarted = await gameStore.applySessionNetworkTuningChange()
if (restarted) {
message.success(String(i18n.global.t('settings.session_network_tuning_applied')))
}
} catch (e: any) {
message.error(String(e?.message || e || i18n.global.t('settings.session_network_tuning_apply_failed')))
} finally {
applyingSessionTuning.value = false
}
}
)
</script>
+70 -33
View File
@@ -4,9 +4,9 @@
<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 ">{{ $t('games.library') }}</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"
<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'">
<div class="i-carbon-grid text-sm md:text-base"></div>
</button>
@@ -28,7 +28,7 @@
<template #icon>
<div class="i-carbon-radar"></div>
</template>
<span class="hidden xs:inline">{{ isScanning ? $t('games.scanning') : $t('games.scan_local') }}</span>
<span class="hidden xs:inline ">{{ isScanning ? $t('games.scanning') : $t('games.scan_local') }}</span>
<span class="xs:hidden">{{ isScanning ? '...' : '扫描' }}</span>
</n-button>
@@ -36,7 +36,7 @@
<template #icon>
<div class="i-carbon-add"></div>
</template>
<span class="hidden xs:inline">{{ $t('games.add_game') }}</span>
<span class="hidden xs:inline ">{{ $t('games.add_game') }}</span>
<span class="xs:hidden">添加</span>
</n-button>
@@ -52,8 +52,8 @@
<!-- Filters -->
<div class="flex gap-2 mb-6 overflow-x-auto pb-2 no-scrollbar">
<button v-for="cat in categories" :key="cat.value" @click="activeCategory = cat.value"
class="px-4 py-1.5 rounded-full text-sm border transition whitespace-nowrap font-medium"
:class="activeCategory === cat.value ? 'bg-primary border-primary text-on-primary shadow-md shadow-primary/20' : 'bg-surface border-border text-on-surface-muted hover:border-primary/50 hover:text-on-surface'">
class="px-4 py-1.5 rounded-full text-sm border transition whitespace-nowrap font-medium"
:class="activeCategory === cat.value ? 'bg-primary border-primary text-on-primary shadow-md shadow-primary/20 ' : 'bg-surface border-border text-on-surface-muted hover:border-primary/50 hover:text-on-surface '">
{{ cat.label }}
</button>
</div>
@@ -66,10 +66,10 @@
<div class="i-carbon-rocket text-xl animate-pulse"></div>
</div>
<div>
<div class="text-xs text-success/80 font-bold uppercase tracking-wider mb-0.5">当前正在加速</div>
<div class="text-lg font-bold text-on-surface flex items-center gap-2">
<div class="text-xs text-success/80 font-bold uppercase tracking-wider mb-0.5 ">当前正在加速</div>
<div class="text-lg font-bold text-on-surface flex items-center gap-2 ">
{{ acceleratingGame.name }}
<n-tag size="small" type="success" :bordered="false" class="text-xs">
<n-tag size="small" type="success" :bordered="false" class="text-xs ">
{{ acceleratingGame.latency ? acceleratingGame.latency + ' ms' : '已连接' }}
</n-tag>
</div>
@@ -86,25 +86,31 @@
<div v-if="filteredGames.length === 0"
class="h-full flex flex-col items-center justify-center text-on-surface-muted">
<div class="i-carbon-game-console text-6xl mb-4 opacity-50"></div>
<p class="text-lg">{{ $t('games.no_games_found') }}</p>
<p class="text-lg ">{{ $t('games.no_games_found') }}</p>
</div>
<div v-else class="mt-1"
:class="viewMode === 'grid' ? 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4' : 'flex flex-col gap-2'">
:class="viewMode === 'grid' ? 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 ' : 'flex flex-col gap-2'">
<div v-for="game in filteredGames" :key="game.id" @click="game.id && handleGameClick(game)"
class="group relative bg-surface border rounded-xl transition-all duration-300 overflow-hidden" :class="[
isAccelerationLockedFor(game) ? 'opacity-50 grayscale cursor-not-allowed border-border' : 'cursor-pointer hover:-translate-y-1 hover:shadow-xl hover:border-primary/50',
class="group relative bg-surface border rounded-xl transition-all duration-300 overflow-hidden hover:shadow-[0_12px_28px_-12px_rgba(var(--rgb-primary),0.4)] "
:class="[
isAccelerationLockedFor(game) ? 'opacity-50 grayscale cursor-not-allowed border-border' : 'cursor-pointer hover:border-primary/50',
game.status === 'accelerating' ? 'border-success shadow-[0_0_15px_rgba(var(--rgb-success),0.2)] ring-1 ring-success/30' : 'border-border/50',
viewMode === 'grid' ? 'flex flex-col' : 'flex items-center p-3 gap-4'
]">
<!-- Grid View Image Area -->
<div v-if="viewMode === 'grid'" class="aspect-[16/9] relative bg-surface-variant/50 overflow-hidden">
<div v-if="viewMode === 'grid'" class="aspect-[16/9] relative bg-surface-variant/50 overflow-hidden ">
<!-- Background Image/Icon -->
<div class="absolute inset-0 flex items-center justify-center">
<img v-if="game.iconUrl" :src="game.iconUrl"
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110" />
<div v-else class="i-carbon-game-console text-5xl text-on-surface-muted/30"></div>
<div
class="absolute inset-0 flex items-center justify-center transition-transform duration-700 group-hover:scale-110"
:style="!game.iconUrl ? getFallbackStyle(game.name) : {}">
<div v-if="!game.iconUrl" class="absolute inset-0 bg-overlay/20 pointer-events-none"></div>
<img v-if="game.iconUrl" :src="game.iconUrl" class="w-full h-full object-cover relative " />
<div v-else
class="relative z-10 text-2xl font-black text-white/90 drop-shadow-md tracking-wider px-2 text-center">
{{ getFallbackText(game.name) }}</div>
</div>
<!-- Gradient Overlay -->
@@ -112,7 +118,7 @@
<!-- Hover Overlay with Actions -->
<div
class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-all duration-300 flex items-center justify-center gap-3 backdrop-blur-[2px]"
class="absolute inset-0 bg-overlay/55 opacity-0 group-hover:opacity-100 transition-all duration-300 flex items-center justify-center gap-3 backdrop-blur-[2px]"
v-if="!isAccelerationLockedFor(game)">
<template v-if="game.status !== 'accelerating'">
<button
@@ -132,11 +138,10 @@
</button>
</template>
<template v-else>
<button
class="px-4 py-1.5 rounded-full bg-error text-on-error font-bold text-sm hover:bg-error-hover transition shadow-lg"
@click.stop="stopAcceleration(game.id!)" :disabled="isActionPending">
<n-button type="error" round class="shadow-lg font-bold px-6" @click.stop="stopAcceleration(game.id!)"
:disabled="isActionPending" :loading="gameStore.operationState === 'stopping'">
停止加速
</button>
</n-button>
</template>
</div>
@@ -144,7 +149,7 @@
<div class="absolute top-2 right-2 flex gap-1">
<div v-if="game.status === 'accelerating'"
class="px-2 py-0.5 rounded text-[10px] font-bold bg-success text-on-success shadow-sm flex items-center gap-1">
<div class="w-1.5 h-1.5 bg-white rounded-full animate-pulse"></div>
<div class="w-1.5 h-1.5 bg-white rounded-full animate-pulse "></div>
加速中
</div>
<div class="px-2 py-0.5 rounded text-[10px] font-bold shadow-sm uppercase backdrop-blur-md"
@@ -156,17 +161,19 @@
<!-- List View Image -->
<div v-else
class="w-12 h-12 rounded-lg bg-surface-variant/50 flex-shrink-0 overflow-hidden relative group-hover:ring-2 ring-primary/20">
<img v-if="game.iconUrl" :src="game.iconUrl" class="w-full h-full object-cover" />
<div v-else class="w-full h-full flex items-center justify-center">
<div class="i-carbon-game-console text-xl text-on-surface-muted"></div>
</div>
class="w-12 h-12 rounded-lg bg-surface-variant/50 flex-shrink-0 overflow-hidden relative group-hover:ring-2 ring-primary/20 flex items-center justify-center"
:style="!game.iconUrl ? getFallbackStyle(game.name) : {}">
<div v-if="!game.iconUrl" class="absolute inset-0 bg-overlay/20 pointer-events-none"></div>
<img v-if="game.iconUrl" :src="game.iconUrl" class="w-full h-full object-cover absolute inset-0 z-10" />
<div v-else class="relative z-10 text-white font-bold text-xl drop-shadow-sm">{{ getFallbackText(game.name,
true) }}</div>
</div>
<!-- Content -->
<div class="flex-1 min-w-0" :class="viewMode === 'grid' ? 'p-3' : ''">
<div class="flex items-center justify-between mb-1">
<h3 class="font-bold text-on-surface truncate" :class="viewMode === 'grid' ? 'text-base' : 'text-sm'">
<h3 class="font-bold text-on-surface truncate " :class="viewMode === 'grid' ? 'text-base' : 'text-sm'">
{{ game.name }}
</h3>
<!-- List View Badges -->
@@ -183,13 +190,13 @@
<div class="flex items-center justify-between text-xs text-on-surface-muted">
<div class="flex items-center gap-2">
<span class="bg-surface-variant px-1.5 py-0.5 rounded text-[10px]">{{ getCategoryLabel(game.category)
<span class="bg-surface-variant px-1.5 py-0.5 rounded text-[10px] ">{{ getCategoryLabel(game.category)
}}</span>
<span v-if="game.lastPlayed">{{ formatTime(game.lastPlayed) }}</span>
<span v-if="game.lastPlayed" class="">{{ formatTime(game.lastPlayed) }}</span>
</div>
<div v-if="game.status === 'accelerating' && game.latency !== undefined"
class="font-mono text-success font-bold">
class="font-mono text-success font-bold ">
{{ game.latency }} ms
</div>
</div>
@@ -243,6 +250,36 @@ import AdvancedConfigEditor from '@/components/library/AdvancedConfigEditor.vue'
import CategoryManager from '@/components/library/CategoryManager.vue'
import { useGameScanner } from '@/composables/useGameScanner'
const fallbackGradients = [
'linear-gradient(135deg, #ff416c, #ff4b2b)',
'linear-gradient(135deg, #f12711, #f5af19)',
'linear-gradient(135deg, #f7971e, #ffd200)',
'linear-gradient(135deg, #11998e, #38ef7d)',
'linear-gradient(135deg, #4facfe, #00f2fe)',
'linear-gradient(135deg, #667eea, #764ba2)',
'linear-gradient(135deg, #a18cd1, #fbc2eb)',
'linear-gradient(135deg, #8a4fff, #733dd9)'
]
function getFallbackStyle(name: string) {
if (!name) return { background: fallbackGradients[0], color: 'white' }
let hash = 0
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash)
}
const index = Math.abs(hash) % fallbackGradients.length
return {
background: fallbackGradients[index],
color: 'white'
}
}
function getFallbackText(name: string, isList = false) {
if (!name) return '?'
if (isList) return name.substring(0, 1).toUpperCase()
return name.length > 8 ? name.substring(0, 8) + '...' : name
}
const { t } = useI18n()
const message = useMessage()
const dialog = useDialog()
+151 -13
View File
@@ -1,29 +1,45 @@
<template>
<div class="p-6 h-full flex flex-col">
<div class="p-6 h-full flex flex-col bg-background">
<!-- Header -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<h1 class="text-xl md:text-2xl font-bold text-on-surface">{{ $t('common.nodes') }}</h1>
<div class="flex flex-wrap items-center gap-2 w-full sm:w-auto">
<div class="flex flex-col sm:flex-row justify-between items-end gap-4 mb-6 pb-4 border-b border-border/50">
<div>
<h1 class="text-2xl md:text-3xl font-bold text-on-surface tracking-tight flex items-center gap-3">
{{ $t('common.nodes') }}
<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">{{ nodeStore.nodes.length }}</span>
</h1>
<p class="text-xs text-on-surface-muted mt-1 uppercase tracking-widest font-medium opacity-70">Global Server Network</p>
</div>
<div class="flex flex-wrap items-center gap-3 w-full sm:w-auto">
<button @click="checkAllNodes" :disabled="isChecking"
class="flex-1 sm:flex-none flex items-center justify-center gap-2 px-3 md:px-4 py-1.5 bg-surface border border-border hover:bg-surface-overlay rounded-full text-xs md:text-sm text-on-surface transition">
class="flex-1 sm:flex-none flex items-center justify-center gap-2 px-4 py-2 bg-surface border border-border hover:border-primary/50 hover:bg-surface-overlay hover:text-primary rounded-lg text-xs md:text-sm text-on-surface transition shadow-sm font-medium">
<div class="i-material-symbols-network-check" :class="{ 'animate-pulse': isChecking }"></div>
<span class="hidden xs:inline">{{ isChecking ? $t('common.checking') : $t('nodes.check_latency') }}</span>
<span class="hidden xs:inline ">{{ isChecking ? $t('common.checking') : $t('nodes.check_latency') }}</span>
<span class="xs:hidden">{{ isChecking ? '...' : $t('nodes.check_latency') }}</span>
</button>
<div class="w-px h-6 bg-border mx-1 hidden sm:block"></div>
<button @click="showImportModal = true"
class="flex-1 sm:flex-none flex items-center justify-center gap-2 px-3 md:px-4 py-1.5 bg-surface border border-border hover:bg-surface-overlay rounded-full text-xs md:text-sm text-on-surface transition">
class="flex-1 sm:flex-none flex items-center justify-center gap-2 px-4 py-2 bg-surface border border-border hover:border-primary/50 hover:bg-surface-overlay rounded-lg text-xs md:text-sm text-on-surface transition shadow-sm font-medium">
<div class="i-material-symbols-download"></div>
<span class="hidden xs:inline">{{ $t('common.import') }}</span>
<span class="xs:hidden">{{ $t('common.import') }}</span>
</button>
<button @click="exportNodes"
class="flex-1 sm:flex-none flex items-center justify-center gap-2 px-3 md:px-4 py-1.5 bg-surface border border-border hover:bg-surface-overlay rounded-full text-xs md:text-sm text-on-surface transition">
class="flex-1 sm:flex-none flex items-center justify-center gap-2 px-4 py-2 bg-surface border border-border hover:border-primary/50 hover:bg-surface-overlay rounded-lg text-xs md:text-sm text-on-surface transition shadow-sm font-medium">
<div class="i-material-symbols-upload"></div>
<span class="hidden xs:inline">{{ $t('common.export') }}</span>
<span class="xs:hidden">{{ $t('common.export') }}</span>
</button>
<button @click="showSubscriptionModal = true"
class="flex-1 sm:flex-none flex items-center justify-center gap-2 px-4 py-2 bg-surface border border-border hover:border-primary/50 hover:bg-surface-overlay rounded-lg text-xs md:text-sm text-on-surface transition shadow-sm font-medium">
<div class="i-material-symbols-rss-feed"></div>
<span class="hidden xs:inline">{{ $t('nodes.subscription_manage') }}</span>
</button>
<button @click="openAddModal"
class="flex-1 sm:flex-none flex items-center justify-center gap-2 px-3 md:px-4 py-1.5 bg-primary text-on-primary hover:bg-primary-hover rounded-full text-xs md:text-sm transition shadow-lg shadow-primary/20">
class="flex-1 sm:flex-none flex items-center justify-center gap-2 px-5 py-2 bg-primary text-on-primary hover:bg-primary-hover hover:shadow-[0_0_15px_rgba(var(--rgb-primary),0.4)] rounded-lg text-xs md:text-sm transition shadow-lg shadow-primary/20 font-bold tracking-wide">
<div class="i-material-symbols-add"></div>
<span class="hidden xs:inline">{{ $t('common.add') }}</span>
<span class="xs:hidden">{{ $t('common.add') }}</span>
@@ -31,24 +47,55 @@
</div>
</div>
<div class="mb-4 flex flex-col md:flex-row gap-3">
<div class="flex-1 grid grid-cols-1 md:grid-cols-[1.5fr_1fr_1fr] gap-3 ">
<n-input
v-model:value="nodeStore.searchQuery"
:placeholder="$t('common.search')"
clearable
/>
<n-select
v-model:value="nodeStore.activeTypeFilter"
:options="typeFilterOptions"
/>
<n-select
v-model:value="nodeStore.activeGroupFilter"
:options="groupFilterOptions"
/>
</div>
<n-popselect v-model:value="nodeStore.activeSortType" :options="sortOptions" trigger="click">
<n-button class="w-full md:w-auto px-4">
<template #icon>
<div class="i-material-symbols-sort"></div>
</template>
{{ currentSortLabel }}
</n-button>
</n-popselect>
</div>
<!-- Node Manager (List Only) -->
<div class="flex-1 bg-surface-panel rounded-xl border border-border overflow-hidden flex flex-col">
<div class="flex-1 bg-surface-panel/50 rounded-xl border border-border/50 overflow-hidden flex flex-col shadow-inner backdrop-blur-sm">
<NodeManager />
</div>
<NodeEditModal v-model="showEditModal" :editing-node="editingNode" @save="handleSaveNode" />
<NodeImportModal v-model="showImportModal" @imported="handleImported" />
<SubscriptionManagerModal v-model="showSubscriptionModal" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useMessage } from 'naive-ui'
import NodeManager from '@/components/node/NodeManager.vue'
import NodeEditModal from '@/components/node/NodeEditModal.vue'
import NodeImportModal from '@/components/node/NodeImportModal.vue'
import SubscriptionManagerModal from '@/components/node/SubscriptionManagerModal.vue'
import type { NodeConfig } from '@/utils/protocol'
import { DEFAULT_NODE_GROUP } from '@/stores/nodes'
import { useNodeStore } from '@/stores/nodes'
import { generateBatchLinks } from '@/utils/protocol'
import { useClipboard } from '@vueuse/core'
@@ -59,9 +106,34 @@ const message = useMessage()
const { copy } = useClipboard()
const showEditModal = ref(false)
const showImportModal = ref(false)
const showSubscriptionModal = ref(false)
const editingNode = ref<NodeConfig | null>(null)
const isChecking = ref(false)
const typeFilterOptions = computed(() => [
{ label: i18n.global.t('nodes.filter_all_types'), value: 'all' },
...nodeStore.availableNodeTypes.map((type) => ({ label: type.toUpperCase(), value: type }))
])
const groupFilterOptions = computed(() => [
{ label: i18n.global.t('nodes.filter_all_groups'), value: 'all' },
...nodeStore.availableGroups.map((group) => ({
label: group === DEFAULT_NODE_GROUP ? i18n.global.t('nodes.default_group') : group,
value: group
}))
])
const sortOptions = computed(() => [
{ label: i18n.global.t('common.creation'), value: 'default' },
{ label: i18n.global.t('common.latency_short'), value: 'latency' },
{ label: i18n.global.t('common.alphabetical'), value: 'alphabetical' }
])
const currentSortLabel = computed(() => {
const found = sortOptions.value.find(o => o.value === nodeStore.activeSortType)
return found ? found.label : ''
})
function openAddModal() {
editingNode.value = null
showEditModal.value = true
@@ -102,4 +174,70 @@ async function exportNodes() {
message.error(i18n.global.t('nodes.export_failed'))
}
}
function isEditableTarget(target: EventTarget | null): boolean {
const el = target as HTMLElement | null
if (!el) return false
if (el.isContentEditable) return true
const tag = el.tagName
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
}
async function importFromText(content: string) {
const trimmed = content.trim()
if (!trimmed) return
const count = await nodeStore.addNodes(trimmed)
if (count > 0) {
message.success(i18n.global.t('common.import_success', { count }))
} else {
message.error(i18n.global.t('common.import_fail'))
}
}
async function exportSelectedNodes() {
const targets = nodeStore.selectedNodes
if (targets.length === 0) {
message.warning(i18n.global.t('nodes.no_selected_nodes_to_export'))
return
}
const content = generateBatchLinks(targets)
if (content) {
await copy(content)
message.success(i18n.global.t('nodes.export_success', { count: targets.length }))
} else {
message.error(i18n.global.t('nodes.export_failed'))
}
}
async function onDocumentPaste(event: ClipboardEvent) {
if (isEditableTarget(event.target)) return
const text = event.clipboardData?.getData('text/plain') || ''
if (!text.trim()) return
event.preventDefault()
await importFromText(text)
}
async function onDocumentKeydown(event: KeyboardEvent) {
if (isEditableTarget(event.target)) return
const withModifier = event.ctrlKey || event.metaKey
if (!withModifier || event.altKey) return
if (event.key.toLowerCase() === 'c') {
event.preventDefault()
await exportSelectedNodes()
}
}
onMounted(() => {
window.addEventListener('paste', onDocumentPaste)
window.addEventListener('keydown', onDocumentKeydown)
void nodeStore.runScheduledSubscriptions('startup')
})
onUnmounted(() => {
window.removeEventListener('paste', onDocumentPaste)
window.removeEventListener('keydown', onDocumentKeydown)
})
</script>
-11
View File
@@ -1,15 +1,5 @@
<template>
<div class="h-full relative overflow-hidden bg-background text-on-surface flex transition-colors duration-300">
<!-- Ambient Background Effects -->
<div class="absolute inset-0 pointer-events-none overflow-hidden">
<div
class="absolute top-[-10%] right-[-5%] w-[40%] h-[40%] bg-primary/5 rounded-full blur-[100px] animate-pulse-slow">
</div>
<div
class="absolute bottom-[-10%] left-[-5%] w-[30%] h-[30%] bg-secondary/5 rounded-full blur-[80px] animate-pulse-slow"
style="animation-delay: 2s;"></div>
</div>
<!-- Main Layout Container -->
<div class="relative z-10 flex w-full h-full max-w-7xl mx-auto p-4 md:p-8 gap-8">
@@ -55,4 +45,3 @@ onMounted(async () => {
await getVersion()
})
</script>
+43
View File
@@ -37,6 +37,49 @@ describe('generateSingboxConfig', () => {
expect(config.inbounds[0].type).toBe('tun')
})
it('should disable tun and add system proxy inbounds in system_proxy mode', () => {
const config = JSON.parse(generateSingboxConfig(mockGame, mockNode, {
accelNetworkMode: 'system_proxy',
systemProxy: {
enabled: true,
port: 10808
}
}))
const tunInbound = config.inbounds.find((i: any) => i.tag === 'tun-in')
expect(tunInbound).toBeUndefined()
const httpInbound = config.inbounds.find((i: any) => i.tag === 'system-http-in')
expect(httpInbound).toBeDefined()
expect(httpInbound.listen_port).toBe(10808)
const socksInbound = config.inbounds.find((i: any) => i.tag === 'system-socks-in')
expect(socksInbound).toBeUndefined()
})
it('should keep process+cidr constraint in routing mode under system_proxy mode', () => {
const routingGame = {
...mockGame,
proxyMode: 'routing' as const,
processName: ['chrome.exe'],
routingRules: ['1.1.1.1']
}
const config = JSON.parse(generateSingboxConfig(routingGame, mockNode, {
accelNetworkMode: 'system_proxy',
systemProxy: {
enabled: true,
port: 10808
}
}))
const cidrRule = config.route.rules.find((r: any) =>
Array.isArray(r?.ip_cidr) && r.ip_cidr.includes('1.1.1.1/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 configure process mode correctly', () => {
const config = JSON.parse(generateSingboxConfig(mockGame, mockNode))
// Process mode: default final should be 'direct' (implied by not being 'proxy' or explicit final)
+1
View File
@@ -54,6 +54,7 @@ export default defineConfig({
surface: 'rgba(var(--rgb-surface), <alpha-value>)',
'surface-panel': 'rgba(var(--rgb-surface-panel), <alpha-value>)',
'surface-overlay': 'rgba(var(--rgb-surface-overlay), <alpha-value>)',
'surface-variant': 'rgba(var(--rgb-surface-variant), <alpha-value>)',
// Semantic Text Colors
'on-surface': 'rgba(var(--rgb-on-surface), <alpha-value>)',