feat: 重构游戏扫描模块并新增系统代理支持
- 重构游戏扫描模块,按平台拆分扫描逻辑(Steam、Microsoft Store、Epic/EA) - 新增系统代理模式支持,可在系统代理和TUN模式间切换 - 新增语言切换功能,支持中英文界面切换 - 改进主题系统,将主题配置统一管理至store - 新增系统托盘功能,支持窗口最小化到托盘 - 优化网络连接性能,为TCP ping添加noDelay选项 - 新增游戏分类匹配功能,扫描时自动匹配游戏分类 - 改进日志系统,自动捕获前端提示层日志 - 新增会话网络调优选项,支持MTU、UDP模式等高级配置 - 修复多处UI样式问题,统一颜色变量命名 - 更新项目文档,反映新的Electron架构设计
This commit is contained in:
1 parent
c2bb7e6ed8
commit
a20e070059
66 files changed
+4663
-1708
No files matched your search
+40
-16
@@ -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/`)
|
||||
提炼了纯函数工具,不依赖具体业务逻辑,易于测试和复用。
|
||||
|
||||
@@ -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
@@ -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
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -26,6 +26,9 @@ export class ProcessService {
|
||||
this.setupIPC()
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册 IPC 监听器
|
||||
*/
|
||||
private setupIPC() {
|
||||
ipcMain.handle('process-scan', async () => {
|
||||
return await this.scanProcesses()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
])
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import path from 'path'
|
||||
import { app } from 'electron'
|
||||
|
||||
/**
|
||||
* 二进制文件存储目录
|
||||
* 位于用户数据目录下的 bin 文件夹中
|
||||
*/
|
||||
export const BIN_DIR = path.join(app.getPath('userData'), 'bin')
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}) // 获取系统代理状态
|
||||
}
|
||||
}
|
||||
@@ -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> => {
|
||||
|
||||
@@ -89,6 +89,7 @@ export function tcpPing(host: string, port: number, timeout = 2000): Promise<Pin
|
||||
return new Promise((resolve) => {
|
||||
const start = Date.now()
|
||||
const socket = new net.Socket()
|
||||
socket.setNoDelay(true)
|
||||
|
||||
let resolved = false
|
||||
|
||||
|
||||
+1
-1
@@ -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' },
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
+18
-55
@@ -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));
|
||||
@@ -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 */
|
||||
|
||||
Vendored
+4
@@ -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']
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
+32
-26
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
// Sync with store changes
|
||||
watch(
|
||||
() => [settingsStore.theme, settingsStore.themeColor],
|
||||
() => {
|
||||
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
|
||||
}
|
||||
|
||||
// Initial apply
|
||||
applyTheme()
|
||||
mediaQuery.addEventListener('change', handleSystemChange)
|
||||
})
|
||||
|
||||
+4
-1
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
+83
-11
@@ -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"
|
||||
|
||||
+74
-5
@@ -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 发生错误"
|
||||
|
||||
+168
-3
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+482
-4
@@ -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,
|
||||
|
||||
+81
-12
@@ -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')
|
||||
// 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')
|
||||
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')
|
||||
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
|
||||
}
|
||||
})
|
||||
Vendored
+7
-3
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>)',
|
||||
|
||||
Reference in new issue
Block a user