From c2bb7e6ed8fe70567aef4ebbe5a49e9518e18a26 Mon Sep 17 00:00:00 2001 From: ZenEcho Date: Thu, 19 Feb 2026 20:16:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E7=BB=93=E6=9E=84=E5=B9=B6=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 API 模块统一封装所有 IPC 调用,提高代码可维护性 - 创建 `src/types/index.ts` 集中定义核心业务类型(Game, NodeConfig 等) - 新增组合式函数 `useFilePicker`、`useGameScanner` 和 `useAppUpdater` 以复用逻辑 - 改进设置页面,新增侧边栏导航和多个设置模块(通用、网络、日志、关于) - 优化本地代理逻辑,增加节点列表变化监听和自动重启机制 - 扩展协议解析测试用例,覆盖更多 VMess、VLESS Reality 等场景 - 更新项目结构文档 `docs/src-structure.md` 说明模块职责 - 修复 Electron 主进程预加载路径兼容性问题 --- .agent/tmp-write-test.txt | 1 + docs/src-structure.md | 92 +++ electron/main/index.ts | 11 +- electron/services/database.ts | 2 +- electron/services/system.ts | 76 +- electron/utils/process-helper.ts | 21 +- package.json | 6 +- src/api/app.ts | 16 + src/api/categories.ts | 7 + src/api/electron.ts | 10 + src/api/games.ts | 7 + src/api/index.ts | 8 + src/api/nodes.ts | 8 + src/api/proxy-monitor.ts | 4 + src/api/singbox.ts | 5 + src/api/system.ts | 16 + src/assets/main.css | 395 +++++++++ src/autoImport/components.d.ts | 8 + src/components/common/IconSelector.vue | 32 + src/components/common/ProcessSelector.vue | 138 ++++ src/components/dashboard/NodeSelector.vue | 48 +- .../library/AdvancedConfigEditor.vue | 162 +--- src/components/library/GameEditModal.vue | 104 +-- src/components/settings/AboutSettings.vue | 91 ++ src/components/settings/GeneralSettings.vue | 75 ++ src/components/settings/LogsSettings.vue | 183 ++++ src/components/settings/NetworkSettings.vue | 291 +++++++ src/components/settings/SettingsSidebar.vue | 68 ++ src/components/settings/settings.css | 0 src/composables/useAppUpdater.ts | 101 +++ src/composables/useFilePicker.ts | 69 ++ src/composables/useGameScanner.ts | 78 ++ src/composables/useTheme.ts | 4 +- src/constants/index.ts | 11 + src/layouts/MainLayout.vue | 109 ++- src/locales/en-US.json | 23 + src/locales/zh-CN.json | 25 +- src/main.ts | 53 +- src/stores/categories.ts | 13 +- src/stores/games.ts | 142 +++- src/stores/local-proxy.ts | 430 +++++++++- src/stores/nodes.ts | 73 +- src/stores/settings.ts | 10 +- src/style.css | 159 ---- src/styles/themes.css | 125 --- src/types/electron.d.ts | 109 +++ src/types/index.ts | 127 +++ src/utils/latency-session.ts | 14 +- src/utils/protocol.test.ts | 98 +++ src/utils/protocol.ts | 165 ++-- src/utils/runtime-logger.ts | 12 +- src/utils/singbox-config.ts | 274 ++++-- src/views/dashboard/index.vue | 84 +- src/views/library/index.vue | 304 ++++--- src/views/settings/index.vue | 779 +----------------- src/vite-env.d.ts | 101 --- tests/unit/singbox-config.spec.ts | 201 ++++- 57 files changed, 3661 insertions(+), 1917 deletions(-) create mode 100644 .agent/tmp-write-test.txt create mode 100644 docs/src-structure.md create mode 100644 src/api/app.ts create mode 100644 src/api/categories.ts create mode 100644 src/api/electron.ts create mode 100644 src/api/games.ts create mode 100644 src/api/index.ts create mode 100644 src/api/nodes.ts create mode 100644 src/api/proxy-monitor.ts create mode 100644 src/api/singbox.ts create mode 100644 src/api/system.ts create mode 100644 src/assets/main.css create mode 100644 src/components/common/IconSelector.vue create mode 100644 src/components/common/ProcessSelector.vue create mode 100644 src/components/settings/AboutSettings.vue create mode 100644 src/components/settings/GeneralSettings.vue create mode 100644 src/components/settings/LogsSettings.vue create mode 100644 src/components/settings/NetworkSettings.vue create mode 100644 src/components/settings/SettingsSidebar.vue create mode 100644 src/components/settings/settings.css create mode 100644 src/composables/useAppUpdater.ts create mode 100644 src/composables/useFilePicker.ts create mode 100644 src/composables/useGameScanner.ts create mode 100644 src/constants/index.ts delete mode 100644 src/style.css delete mode 100644 src/styles/themes.css create mode 100644 src/types/electron.d.ts diff --git a/.agent/tmp-write-test.txt b/.agent/tmp-write-test.txt new file mode 100644 index 0000000..a13c399 --- /dev/null +++ b/.agent/tmp-write-test.txt @@ -0,0 +1 @@ +test diff --git a/docs/src-structure.md b/docs/src-structure.md new file mode 100644 index 0000000..dc9c13f --- /dev/null +++ b/docs/src-structure.md @@ -0,0 +1,92 @@ +# Frontend (Vue) 项目结构说明 + +本文档详细描述了 `src/` 目录下的代码结构与模块功能。本项目采用 **Vue 3 + TypeScript + Vite** 技术栈,使用 **Composition API** 和 **Pinia** 进行状态管理。 + +## 目录树 + +``` +src/ +├── api/ # [接口层] 后端通信接口 (IPC 封装) +│ ├── index.ts # 统一导出所有 API 模块 +│ └── *.ts # 各业务模块 API (如 games.ts, nodes.ts, singbox.ts) +│ +├── assets/ # [资源层] 静态资源文件 +│ ├── main.css # 全局样式入口 +│ └── style.css # 基础样式定义 +│ +├── autoImport/ # [自动导入] 自动生成的类型声明 +│ ├── auto-imports.d.ts # 自动导入函数的类型声明 +│ └── components.d.ts # 自动导入组件的类型声明 +│ +├── components/ # [组件层] Vue 组件 +│ ├── common/ # 通用基础组件 (图标选择器、主题切换等) +│ ├── dashboard/ # 仪表盘相关组件 (图表、节点选择器) +│ ├── library/ # 游戏库相关组件 (分类管理、编辑弹窗) +│ ├── node/ # 节点管理相关组件 (节点卡片、编辑/导入弹窗) +│ └── settings/ # 设置页面相关组件 (侧边栏、各设置模块) +│ +├── composables/ # [逻辑层] 组合式函数 (Hooks) +│ ├── useAppUpdater.ts # 应用更新逻辑 +│ ├── useFilePicker.ts # 文件选择逻辑 +│ ├── useGameScanner.ts # 游戏扫描逻辑 +│ └── useTheme.ts # 主题切换逻辑 +│ +├── constants/ # [常量层] 全局常量定义 +│ └── index.ts # 常量统一导出 +│ +├── layouts/ # [布局层] 页面布局组件 +│ └── MainLayout.vue # 主布局 (侧边栏 + 内容区) +│ +├── locales/ # [国际化] i18n 语言包 +│ ├── en-US.json # 英文翻译 +│ └── zh-CN.json # 中文翻译 +│ +├── router/ # [路由层] 路由配置 +│ └── index.ts # Vue Router 配置 (定义路由表) +│ +├── stores/ # [状态层] Pinia 状态管理 +│ ├── index.ts # Pinia 实例导出 +│ ├── games.ts # 游戏数据状态 +│ ├── nodes.ts # 节点数据状态 +│ ├── settings.ts # 应用设置状态 +│ └── local-proxy.ts # 本地代理状态 +│ +├── types/ # [类型层] TypeScript 类型定义 +│ ├── electron.d.ts # Electron API 类型扩展 +│ └── index.ts # 核心业务类型定义 (Game, NodeConfig 等) +│ +├── utils/ # [工具层] 通用工具函数 +│ ├── latency-session.ts # 延迟测试会话管理 +│ ├── protocol.ts # 代理协议解析工具 (VMess, SS, VLESS 等) +│ ├── runtime-logger.ts # 运行时日志捕获工具 +│ └── singbox-config.ts # sing-box 配置文件生成器 +│ +├── views/ # [视图层] 页面级组件 +│ ├── dashboard/ # 仪表盘页面 +│ ├── library/ # 游戏库页面 +│ ├── nodes/ # 节点列表页面 +│ └── settings/ # 设置中心页面 +│ +├── App.vue # 应用根组件 +├── main.ts # 应用入口文件 (挂载 Vue, Pinia, Router) +└── vite-env.d.ts # Vite 环境类型声明 +``` + +## 模块详细说明 + +### 1. 接口层 (`api/`) +- **IPC 封装**: 所有与 Electron 主进程的通信都通过 `window.electron` 等预加载脚本暴露的对象进行。 +- **模块化**: 每个文件对应后端的 Service 模块,例如 `singbox.ts` 对应后端的 `SingboxService`。 + +### 2. 状态层 (`stores/`) +- **`games.ts`**: 管理本地游戏列表、当前选中游戏、加速状态。 +- **`nodes.ts`**: 管理节点列表、节点延迟测试结果。 +- **`settings.ts`**: 持久化存储用户设置(语言、主题、DNS 模式等)。 + +### 3. 工具层 (`utils/`) +- **`protocol.ts`**: 核心协议解析逻辑,负责将分享链接 (vmess://, ss://) 解析为标准节点配置对象。 +- **`singbox-config.ts`**: 根据当前游戏规则和选中的节点,动态生成 sing-box 的 JSON 配置文件。 + +### 4. 逻辑层 (`composables/`) +- **Composition API**: 封装可复用的业务逻辑,例如 `useTheme.ts` 处理深色模式切换和主题色应用。 +- **`useGameScanner.ts`**: 协调前端 UI 和后端扫描接口,处理扫描进度和结果展示。 diff --git a/electron/main/index.ts b/electron/main/index.ts index c56fcd2..095e5ec 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -25,7 +25,7 @@ global.__filename = __filename // @ts-ignore global.__dirname = __dirname -process.env.APP_ROOT = path.join(__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'] @@ -147,6 +147,12 @@ function createTray(icon: NativeImage | null) { 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, @@ -155,7 +161,8 @@ function createWindow() { minHeight: 620, frame: false, webPreferences: { - preload: path.join(__dirname, '../preload/index.mjs'), // Adjusted path + // Compatible with both nested and flat dist-electron outputs. + preload: preloadPath, nodeIntegration: false, contextIsolation: true, webSecurity: false, diff --git a/electron/services/database.ts b/electron/services/database.ts index c17f9e2..d8cf1e3 100644 --- a/electron/services/database.ts +++ b/electron/services/database.ts @@ -244,7 +244,7 @@ export class DatabaseService { process_name: '[]', category_id: otherCategoryId, proxy_mode: 'routing', - routing_rules: JSON.stringify([]), + routing_rules: JSON.stringify(['global']), status: 'idle', latency: 0 } diff --git a/electron/services/system.ts b/electron/services/system.ts index 6aa1669..7e4be7f 100644 --- a/electron/services/system.ts +++ b/electron/services/system.ts @@ -3,6 +3,7 @@ import { runCommand } from '../utils/command' import { findAvailablePort } from '../utils/port' import { ping, tcpPing } from '../utils/ping' import nodeNet from 'node:net' +import nodeTls from 'node:tls' type SystemCommandResult = { ok: boolean @@ -76,12 +77,16 @@ export class SystemService { async testHttpProxyConnect(proxyPort: number, targetHost: string, targetPort: number = 443, timeoutMs: number = 5000) { return new Promise<{ ok: boolean, statusLine: string, error?: string }>((resolve) => { const socket = nodeNet.createConnection({ host: '127.0.0.1', port: proxyPort }) + let tlsSocket: nodeTls.TLSSocket | null = null let settled = false - let buf = '' + let connectBuf = '' + let responseBuf = '' + const requestPath = targetHost.includes('google') ? '/generate_204' : '/' const done = (payload: { ok: boolean, statusLine: string, error?: string }) => { if (settled) return settled = true + try { tlsSocket?.destroy() } catch { } socket.destroy() resolve(payload) } @@ -94,11 +99,54 @@ export class SystemService { socket.write(req) }) socket.on('data', (chunk: Buffer) => { - buf += chunk.toString('utf8') - const lineEnd = buf.indexOf('\r\n') - if (lineEnd < 0) return - const statusLine = buf.slice(0, lineEnd).trim() - done({ ok: /^HTTP\/1\.[01]\s+200\b/i.test(statusLine), statusLine }) + connectBuf += chunk.toString('utf8') + const headerEnd = connectBuf.indexOf('\r\n\r\n') + if (headerEnd < 0) return + + const lineEnd = connectBuf.indexOf('\r\n') + const connectStatusLine = (lineEnd >= 0 ? connectBuf.slice(0, lineEnd) : '').trim() + if (!/^HTTP\/1\.[01]\s+200\b/i.test(connectStatusLine)) { + done({ ok: false, statusLine: connectStatusLine, error: 'connect-not-200' }) + return + } + + socket.removeAllListeners('data') + socket.setTimeout(0) + + tlsSocket = nodeTls.connect({ + socket, + servername: targetHost, + rejectUnauthorized: false + }, () => { + const getReq = [ + `GET ${requestPath} HTTP/1.1`, + `Host: ${targetHost}`, + 'User-Agent: LagZero/1.0', + 'Accept: */*', + 'Connection: close', + '', + '' + ].join('\r\n') + tlsSocket?.write(getReq) + }) + + tlsSocket.setTimeout(Math.max(1000, timeoutMs)) + tlsSocket.on('timeout', () => done({ ok: false, statusLine: '', error: 'https-timeout' })) + tlsSocket.on('error', (err: any) => done({ ok: false, statusLine: '', error: `https:${String(err?.message || err)}` })) + tlsSocket.on('data', (data: Buffer) => { + responseBuf += data.toString('utf8') + const responseHeaderEnd = responseBuf.indexOf('\r\n\r\n') + if (responseHeaderEnd < 0) return + + const responseLineEnd = responseBuf.indexOf('\r\n') + const responseStatusLine = (responseLineEnd >= 0 ? responseBuf.slice(0, responseLineEnd) : '').trim() + const ok = /^HTTP\/1\.[01]\s+(2\d\d|3\d\d)\b/i.test(responseStatusLine) + done({ + ok, + statusLine: responseStatusLine, + ...(ok ? {} : { error: 'http-not-2xx-3xx' }) + }) + }) }) }) } @@ -114,12 +162,22 @@ export class SystemService { ipcMain.handle('system:test-http-proxy-connect', async (_, proxyPort: number, targetHost: string, targetPort: number = 443, timeoutMs: number = 5000) => { return this.testHttpProxyConnect(proxyPort, targetHost, targetPort, timeoutMs) }) - ipcMain.handle('system:ping', async (_, host: string) => ping(host)) + ipcMain.handle('system:ping', async (_, host: string) => { + try { + const result = await ping(host) + return typeof result?.latency === 'number' ? result.latency : -1 + } catch (e: any) { + console.warn('[SystemService] system:ping failed', host, e) + return -1 + } + }) ipcMain.handle('system:tcp-ping', async (_, host: string, port: number) => { try { - return await tcpPing(host, port) + const result = await tcpPing(host, port) + return typeof result?.latency === 'number' ? result.latency : -1 } catch (e: any) { - return { latency: -1, loss: 100 } + console.warn('[SystemService] system:tcp-ping failed', host, port, e) + return -1 } }) } diff --git a/electron/utils/process-helper.ts b/electron/utils/process-helper.ts index 66e90fc..c223132 100644 --- a/electron/utils/process-helper.ts +++ b/electron/utils/process-helper.ts @@ -26,13 +26,28 @@ export function normalizeProcessNames(processNames: string[]): string[] { for (const item of processNames) { const normalized = normalizeProcessName(item) if (!normalized) continue - set.add(normalized) - const lower = normalized.toLowerCase() - if (lower !== normalized) set.add(lower) + for (const alias of expandProcessAliases(normalized)) { + set.add(alias) + const lower = alias.toLowerCase() + if (lower !== alias) set.add(lower) + } } return Array.from(set) } +function expandProcessAliases(processName: string): string[] { + const value = String(processName || '').trim() + if (!value) return [] + const aliases = new Set([value]) + + // Tolerate users entering "chrome" instead of "chrome.exe" on Windows. + if (!value.includes('.') && !value.includes('/') && !value.includes('\\')) { + aliases.add(`${value}.exe`) + } + + return Array.from(aliases) +} + /** * 比较两个字符串数组是否相等 (忽略顺序) * diff --git a/package.json b/package.json index 7ffda4f..2d5bf6f 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,10 @@ "productName": "LagZero", "private": true, "version": "0.1.0", - "main": "dist-electron/main/index.js", + "main": "dist-electron/index.js", "type": "module", "author": "LagZero Team", - "description": "Cross-platform Game Accelerator", + "description": "是一款专为极致游戏体验打造的、高性能游戏加速器桌面客户端", "repository": { "type": "git", "url": "https://github.com/ZenEcho/LagZero.git" @@ -153,4 +153,4 @@ "vuedraggable": "^4.1.0", "vue-tsc": "^3.1.4" } -} \ No newline at end of file +} diff --git a/src/api/app.ts b/src/api/app.ts new file mode 100644 index 0000000..3de1126 --- /dev/null +++ b/src/api/app.ts @@ -0,0 +1,16 @@ +import type { LogEntry } from '@/types/electron' + +export const appApi = { + getVersion: (): Promise => window.app.getVersion(), + checkUpdate: () => window.app.checkUpdate(), + openUrl: (url: string) => window.app.openUrl(url), +} + +export const logsApi = { + getAll: () => window.logs.getAll(), + clear: () => window.logs.clear(), + getFilePath: () => window.logs.getFilePath(), + pushFrontend: (entry: Partial) => window.logs.pushFrontend(entry), + onNew: (callback: (entry: LogEntry) => void) => window.logs.onNew(callback), + offNew: (callback: (entry: LogEntry) => void) => window.logs.offNew(callback), +} diff --git a/src/api/categories.ts b/src/api/categories.ts new file mode 100644 index 0000000..25625ef --- /dev/null +++ b/src/api/categories.ts @@ -0,0 +1,7 @@ +import type { Category } from '@/types' + +export const categoryApi = { + getAll: () => window.categories.getAll(), + save: (category: Category) => window.categories.save(category), + delete: (id: string) => window.categories.delete(id), +} diff --git a/src/api/electron.ts b/src/api/electron.ts new file mode 100644 index 0000000..23a80a8 --- /dev/null +++ b/src/api/electron.ts @@ -0,0 +1,10 @@ +export const electronApi = { + minimize: () => window.electron.minimize(), + maximize: () => window.electron.maximize(), + close: () => window.electron.close(), + pickImage: () => window.electron.pickImage(), + pickProcess: () => window.electron.pickProcess(), + pickProcessFolder: (maxDepth?: number) => window.electron.pickProcessFolder(maxDepth), + on: (channel: string, callback: (...args: any[]) => void) => window.electron.on(channel, callback), + off: (channel: string, callback: (...args: any[]) => void) => window.electron.off(channel, callback), +} diff --git a/src/api/games.ts b/src/api/games.ts new file mode 100644 index 0000000..a0a16c6 --- /dev/null +++ b/src/api/games.ts @@ -0,0 +1,7 @@ +import type { Game } from '@/types' + +export const gameApi = { + getAll: () => window.games.getAll(), + save: (game: Game) => window.games.save(game), + delete: (id: string) => window.games.delete(id), +} diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..3695353 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,8 @@ +export * from './games' +export * from './nodes' +export * from './categories' +export * from './system' +export * from './singbox' +export * from './proxy-monitor' +export * from './app' +export * from './electron' diff --git a/src/api/nodes.ts b/src/api/nodes.ts new file mode 100644 index 0000000..a430d4b --- /dev/null +++ b/src/api/nodes.ts @@ -0,0 +1,8 @@ +import type { NodeConfig } from '@/types' + +export const nodeApi = { + getAll: () => window.nodes.getAll(), + save: (node: NodeConfig) => window.nodes.save(node), + delete: (id: string) => window.nodes.delete(id), + import: (nodes: NodeConfig[]) => window.nodes.import(nodes), +} diff --git a/src/api/proxy-monitor.ts b/src/api/proxy-monitor.ts new file mode 100644 index 0000000..6a000ea --- /dev/null +++ b/src/api/proxy-monitor.ts @@ -0,0 +1,4 @@ +export const proxyMonitorApi = { + start: (gameId: string, processNames: string[]) => window.proxyMonitor.start(gameId, processNames), + stop: () => window.proxyMonitor.stop(), +} diff --git a/src/api/singbox.ts b/src/api/singbox.ts new file mode 100644 index 0000000..55a3699 --- /dev/null +++ b/src/api/singbox.ts @@ -0,0 +1,5 @@ +export const singboxApi = { + start: (config: string) => window.singbox.start(config), + stop: () => window.singbox.stop(), + restart: (config: string) => window.singbox.restart(config), +} diff --git a/src/api/system.ts b/src/api/system.ts new file mode 100644 index 0000000..3ed0cc3 --- /dev/null +++ b/src/api/system.ts @@ -0,0 +1,16 @@ +export const systemApi = { + scanProcesses: () => window.system.scanProcesses(), + scanLocalGames: () => window.system.scanLocalGames(), + getProcessTree: () => window.system.getProcessTree(), + ping: (host: string) => window.system.ping(host), + tcpPing: (host: string, port: number) => window.system.tcpPing(host, port), + flushDnsCache: () => window.system.flushDnsCache(), + reinstallTunAdapter: (interfaceName?: string) => window.system.reinstallTunAdapter(interfaceName), + findAvailablePort: (port: number, count?: number) => window.system.findAvailablePort(port, count), + testHttpProxyConnect: ( + proxyPort: number, + targetHost: string, + targetPort?: number, + timeoutMs?: number + ) => window.system.testHttpProxyConnect(proxyPort, targetHost, targetPort, timeoutMs), +} diff --git a/src/assets/main.css b/src/assets/main.css new file mode 100644 index 0000000..b8dfbb2 --- /dev/null +++ b/src/assets/main.css @@ -0,0 +1,395 @@ +/* 全局样式调整 */ +:root { + /* Primitive Colors (Semantic Base) */ + --rgb-white: 255, 255, 255; + --rgb-black: 0, 0, 0; + + /* Status Colors (Modern Zinc/Tailwind Palette) */ + --rgb-success: 22, 163, 74; /* Green-600 */ + --rgb-success-bg: 220, 252, 231; /* Green-100 */ + --rgb-on-success: 255, 255, 255; /* White */ + + --rgb-warning: 202, 138, 4; /* Yellow-600 */ + --rgb-warning-bg: 254, 249, 195; /* Yellow-100 */ + --rgb-on-warning: 255, 255, 255; /* White */ + + --rgb-error: 225, 29, 72; /* Rose-600 */ + --rgb-error-bg: 255, 228, 230; /* Rose-100 */ + --rgb-on-error: 255, 255, 255; /* White */ + + --rgb-info: 37, 99, 235; /* Blue-600 */ + --rgb-info-bg: 219, 234, 254; /* Blue-100 */ + --rgb-on-info: 255, 255, 255; /* White */ + + /* Theme: Light (Sophisticated Modern) */ + --rgb-primary: 16, 185, 129; /* Emerald-500 */ + --rgb-primary-hover: 5, 150, 105; /* Emerald-600 */ + --rgb-on-primary: 255, 255, 255; + + --rgb-secondary: 100, 116, 139; /* Slate-500 */ + --rgb-secondary-hover: 71, 85, 105; /* Slate-600 */ + --rgb-on-secondary: 255, 255, 255; + + /* Backgrounds - Refined Off-white */ + --rgb-background: 250, 250, 251; /* Softer than pure white */ + --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 */ + + /* Text - Slate Palette for better legibility */ + --rgb-on-surface: 15, 23, 42; /* Slate-900 */ + --rgb-on-surface-variant: 71, 85, 105; /* Slate-600 */ + --rgb-on-surface-muted: 148, 163, 184; /* Slate-400 */ + + /* Borders - Soft Slate */ + --rgb-border: 226, 232, 240; /* Slate-200 */ + --rgb-border-hover: 203, 213, 225; /* Slate-300 */ + + --rgb-overlay: 0, 0, 0; + + /* CSS Variables for direct usage */ + --color-primary: rgb(var(--rgb-primary)); + --color-background: rgb(var(--rgb-background)); + --color-surface: rgb(var(--rgb-surface)); + --color-on-surface: rgb(var(--rgb-on-surface)); + --color-border: rgb(var(--rgb-border)); + + /* Font Base */ + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +html, +body, +#app { + height: 100%; + margin: 0; + overflow: hidden; /* Prevent body scroll, use inner containers */ +} + +/* Reset buttons */ +button { + border: none; + background: none; +} + +body { + min-width: 320px; + min-height: 100vh; + background-color: var(--color-background); + color: var(--color-on-surface); +} + +/* Theme Colors Override */ +html.theme-green { + --rgb-primary: 16, 185, 129; /* Emerald-500 */ + --rgb-primary-hover: 5, 150, 105; /* Emerald-600 */ +} +html.theme-blue { + --rgb-primary: 59, 130, 246; /* Blue-500 */ + --rgb-primary-hover: 37, 99, 235; /* Blue-600 */ +} +html.theme-purple { + --rgb-primary: 139, 92, 246; /* Violet-500 */ + --rgb-primary-hover: 124, 58, 237; /* Violet-600 */ +} +html.theme-orange { + --rgb-primary: 249, 115, 22; /* Orange-500 */ + --rgb-primary-hover: 234, 88, 12; /* Orange-600 */ +} +html.theme-red { + --rgb-primary: 239, 68, 68; /* Red-500 */ + --rgb-primary-hover: 220, 38, 38; /* Red-600 */ +} + +html.dark { + /* Theme: Dark (Deep Midnight Noir) */ + --rgb-on-primary: 255, 255, 255; + + --rgb-secondary: 148, 163, 184; /* Slate-400 */ + --rgb-secondary-hover: 226, 232, 240; /* Slate-200 */ + --rgb-on-secondary: 15, 23, 42; /* Slate-900 */ + + /* Backgrounds - DEEP NOIR */ + --rgb-background: 10, 10, 12; /* Midnight Black */ + --rgb-surface: 18, 18, 22; /* Ebony surface */ + --rgb-surface-panel: 28, 28, 32; /* Obsidian panel */ + --rgb-surface-overlay: 38, 38, 44; /* Hover state */ + + /* 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 */ + + /* Borders - Subdued Obsidian */ + --rgb-border: 38, 38, 44; /* Quiet borders */ + --rgb-border-hover: 63, 63, 70; /* Visible hover borders */ + + /* Adjusted Status Colors for Dark Mode (Vibrant but readable) */ + --rgb-success: 52, 211, 153; /* Emerald-400 */ + --rgb-warning: 251, 191, 36; /* Amber-400 */ + --rgb-error: 251, 113, 133; /* Rose-400 */ + --rgb-info: 56, 189, 248; /* Sky-400 */ +} + +/* Transitions */ +*, +*::before, +*::after { + transition: + background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1), + border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1), + color 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + transition: none !important; + } +} + +/* Animations */ +@keyframes pulse-slow { + 0%, + 100% { + opacity: 0.3; + transform: scale(1); + } + 50% { + opacity: 0.5; + transform: scale(1.1); + } +} + +.animate-pulse-slow { + animation: pulse-slow 8s infinite ease-in-out; +} + +@keyframes fade-in-up { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-fade-in-up { + animation: fade-in-up 0.5s ease-out; +} + +@keyframes scale-in { + from { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.animate-scale-in { + animation: scale-in 0.4s cubic-bezier(0.16, 1, 0.3, 1); +} + +/* Transitions */ +.fade-slide-enter-active, +.fade-slide-leave-active { + transition: all 0.3s ease; +} + +.fade-slide-enter-from { + opacity: 0; + transform: translateX(10px); +} + +.fade-slide-leave-to { + opacity: 0; + transform: translateX(-10px); +} + +/* Custom Scrollbar */ +.custom-scrollbar::-webkit-scrollbar, +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.custom-scrollbar::-webkit-scrollbar-track, +::-webkit-scrollbar-track { + background: transparent; +} + +.custom-scrollbar::-webkit-scrollbar-thumb, +::-webkit-scrollbar-thumb { + background: rgba(var(--rgb-border), 0.5); + border-radius: 10px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover, +::-webkit-scrollbar-thumb:hover { + background: rgba(var(--rgb-border), 0.8); +} + +/* Glass overrides */ +.glass-input :deep(.n-input) { + background-color: rgba(255, 255, 255, 0.03); + backdrop-filter: blur(10px); +} + +.glass-select :deep(.n-base-selection) { + background-color: rgba(255, 255, 255, 0.03) !important; + backdrop-filter: blur(10px); +} + +.glass-button, +.glass-button-primary { + background-color: rgba(255, 255, 255, 0.05); + backdrop-filter: blur(5px); + border: 1px solid rgba(255, 255, 255, 0.1); + transition: all 0.3s ease; +} + +.glass-button:hover, +.glass-button-primary:hover { + background-color: rgba(255, 255, 255, 0.1); + transform: translateY(-1px); +} + +.glass-button-primary { + background-color: rgba(var(--rgb-primary), 0.1); + border-color: rgba(var(--rgb-primary), 0.3); + color: rgb(var(--rgb-primary)); +} + +.glass-button-primary:hover { + background-color: rgba(var(--rgb-primary), 0.2); + box-shadow: 0 4px 12px rgba(var(--rgb-primary), 0.2); +} + +/* + Fix for Naive UI Primary Button transparency issue in Light Mode + caused by Tailwind Preflight or style conflicts. +*/ + +/* Green Theme (Default) */ +html.theme-green .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; +} +html.theme-green .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; +} + +/* Fix for Error and Warning buttons transparency issue */ +.n-button.n-button--error-type:not(.n-button--quaternary):not( + .n-button--tertiary + ):not(.n-button--secondary):not(.n-button--ghost) { + background-color: rgb(var(--rgb-error)) !important; + color: #ffffff !important; + border: 1px solid rgb(var(--rgb-error)) !important; +} +.n-button.n-button--error-type:not(.n-button--quaternary):not( + .n-button--tertiary + ):not(.n-button--secondary):not(.n-button--ghost):hover { + filter: brightness(0.9); +} + +.n-button.n-button--warning-type:not(.n-button--quaternary):not( + .n-button--tertiary + ):not(.n-button--secondary):not(.n-button--ghost) { + background-color: rgb(var(--rgb-warning)) !important; + color: #ffffff !important; + border: 1px solid rgb(var(--rgb-warning)) !important; +} +.n-button.n-button--warning-type:not(.n-button--quaternary):not( + .n-button--tertiary + ):not(.n-button--secondary):not(.n-button--ghost):hover { + filter: brightness(0.9); +} + +/* Electron Drag Region Support */ +.app-drag-region { + -webkit-app-region: drag; +} + +.no-drag { + -webkit-app-region: no-drag; +} + +/* Ensure Naive UI modals and overlays are not draggable and thus clickable */ +.n-modal-container, +.n-modal-mask, +.n-modal-body-wrapper, +.n-dialog, +.n-notification-container, +.n-message-container, +.n-dropdown-menu, +.n-popover { + -webkit-app-region: no-drag; +} + +/* Disabled state for Primary Button */ +.n-button.n-button--primary-type.n-button--disabled { + opacity: 0.6 !important; + cursor: not-allowed !important; +} diff --git a/src/autoImport/components.d.ts b/src/autoImport/components.d.ts index 27fab30..2ff0402 100644 --- a/src/autoImport/components.d.ts +++ b/src/autoImport/components.d.ts @@ -11,14 +11,19 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { + AboutSettings: typeof import('./../components/settings/AboutSettings.vue')['default'] AdvancedConfigEditor: typeof import('./../components/library/AdvancedConfigEditor.vue')['default'] CategoryManager: typeof import('./../components/library/CategoryManager.vue')['default'] GameEditModal: typeof import('./../components/library/GameEditModal.vue')['default'] + GeneralSettings: typeof import('./../components/settings/GeneralSettings.vue')['default'] + IconSelector: typeof import('./../components/common/IconSelector.vue')['default'] LatencyChart: typeof import('./../components/dashboard/LatencyChart.vue')['default'] + LogsSettings: typeof import('./../components/settings/LogsSettings.vue')['default'] NButton: typeof import('naive-ui')['NButton'] NConfigProvider: typeof import('naive-ui')['NConfigProvider'] NDialogProvider: typeof import('naive-ui')['NDialogProvider'] NDivider: typeof import('naive-ui')['NDivider'] + NetworkSettings: typeof import('./../components/settings/NetworkSettings.vue')['default'] NForm: typeof import('naive-ui')['NForm'] NFormItem: typeof import('naive-ui')['NFormItem'] NInput: typeof import('naive-ui')['NInput'] @@ -37,8 +42,11 @@ declare module 'vue' { NSelect: typeof import('naive-ui')['NSelect'] NSpace: typeof import('naive-ui')['NSpace'] NSwitch: typeof import('naive-ui')['NSwitch'] + NTag: typeof import('naive-ui')['NTag'] + ProcessSelector: typeof import('./../components/common/ProcessSelector.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] + SettingsSidebar: typeof import('./../components/settings/SettingsSidebar.vue')['default'] ThemeToggle: typeof import('./../components/common/ThemeToggle.vue')['default'] } } diff --git a/src/components/common/IconSelector.vue b/src/components/common/IconSelector.vue new file mode 100644 index 0000000..23b0bb9 --- /dev/null +++ b/src/components/common/IconSelector.vue @@ -0,0 +1,32 @@ + + + diff --git a/src/components/common/ProcessSelector.vue b/src/components/common/ProcessSelector.vue new file mode 100644 index 0000000..0154c46 --- /dev/null +++ b/src/components/common/ProcessSelector.vue @@ -0,0 +1,138 @@ + + + diff --git a/src/components/dashboard/NodeSelector.vue b/src/components/dashboard/NodeSelector.vue index fb31f00..4c98f67 100644 --- a/src/components/dashboard/NodeSelector.vue +++ b/src/components/dashboard/NodeSelector.vue @@ -21,9 +21,8 @@ @@ -108,6 +107,7 @@ + + diff --git a/src/components/settings/GeneralSettings.vue b/src/components/settings/GeneralSettings.vue new file mode 100644 index 0000000..8a96ebe --- /dev/null +++ b/src/components/settings/GeneralSettings.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/src/components/settings/LogsSettings.vue b/src/components/settings/LogsSettings.vue new file mode 100644 index 0000000..0b03c3f --- /dev/null +++ b/src/components/settings/LogsSettings.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/src/components/settings/NetworkSettings.vue b/src/components/settings/NetworkSettings.vue new file mode 100644 index 0000000..862368b --- /dev/null +++ b/src/components/settings/NetworkSettings.vue @@ -0,0 +1,291 @@ + + + diff --git a/src/components/settings/SettingsSidebar.vue b/src/components/settings/SettingsSidebar.vue new file mode 100644 index 0000000..05a6439 --- /dev/null +++ b/src/components/settings/SettingsSidebar.vue @@ -0,0 +1,68 @@ + + + diff --git a/src/components/settings/settings.css b/src/components/settings/settings.css new file mode 100644 index 0000000..e69de29 diff --git a/src/composables/useAppUpdater.ts b/src/composables/useAppUpdater.ts new file mode 100644 index 0000000..474ecbf --- /dev/null +++ b/src/composables/useAppUpdater.ts @@ -0,0 +1,101 @@ +import { ref } from 'vue' +import { useNotification, NButton } from 'naive-ui' +import { h } from 'vue' +import { useI18n } from 'vue-i18n' +import { appApi } from '@/api' + +export function useAppUpdater() { + const appVersion = ref('0.0.0') + const checkingUpdate = ref(false) + const updateInfo = ref<{ + available: boolean, + version?: string, + date?: string, + note?: string, + error?: string + } | null>(null) + + const notification = useNotification() + const { t } = useI18n() + + async function getVersion() { + try { + appVersion.value = await appApi.getVersion() + return appVersion.value + } catch (e) { + console.warn('App API not available', e) + return '0.0.0' + } + } + + async function checkUpdate(silent = false) { + if (checkingUpdate.value) return + checkingUpdate.value = true + updateInfo.value = null + + try { + const res = await appApi.checkUpdate() + + if (res.error) { + updateInfo.value = { available: false, error: res.error } + } else { + updateInfo.value = { + available: res.updateAvailable, + version: res.version, + date: res.releaseDate, + note: res.releaseNotes + } + + if (res.updateAvailable && !silent) { + showUpdateNotification(res) + } + } + return updateInfo.value + } catch (e: any) { + updateInfo.value = { available: false, error: e.message } + return updateInfo.value + } finally { + checkingUpdate.value = false + } + } + + function showUpdateNotification(res: { version?: string, releaseNotes?: string, releaseDate?: string }) { + const n = notification.info({ + title: t('settings.update_available', { version: res.version || '' }), + content: res.releaseNotes || t('settings.tagline'), + meta: res.releaseDate, + action: () => + h( + NButton, + { + secondary: true, + type: 'primary', + size: 'small', + onClick: () => { + openReleasesUrl() + n.destroy() + } + }, + { default: () => t('settings.download_update') } + ) + }) + } + + function openReleasesUrl() { + appApi.openUrl('https://github.com/ZenEcho/LagZero/releases') + } + + function openProjectUrl() { + appApi.openUrl('https://github.com/ZenEcho/LagZero') + } + + return { + appVersion, + checkingUpdate, + updateInfo, + getVersion, + checkUpdate, + openReleasesUrl, + openProjectUrl + } +} diff --git a/src/composables/useFilePicker.ts b/src/composables/useFilePicker.ts new file mode 100644 index 0000000..f99efc7 --- /dev/null +++ b/src/composables/useFilePicker.ts @@ -0,0 +1,69 @@ +import { ref } from 'vue' +import { useMessage, useDialog, NInputNumber } from 'naive-ui' +import { h } from 'vue' +import { electronApi } from '@/api' + +export function useFilePicker() { + const message = useMessage() + const dialog = useDialog() + + async function pickImage() { + try { + const url = await electronApi.pickImage() + return url || null + } catch (e) { + message.error('当前环境不支持选择文件') + return null + } + } + + async function pickProcess() { + try { + const files = await electronApi.pickProcess() + if (files && files.length > 0) { + return files + } + return [] + } catch (e) { + message.error('当前环境不支持选择文件') + return [] + } + } + + function pickProcessFolderWithDialog(callback: (files: string[]) => void) { + const depth = ref(1) + + dialog.create({ + title: '选择目录扫描深度', + content: () => h('div', { class: 'flex flex-col gap-2' }, [ + h('span', '请输入扫描深度(1 表示仅当前目录,-1 表示无限递归):'), + h(NInputNumber, { + value: depth.value, + onUpdateValue: (v: number | null) => { if (v !== null) depth.value = v }, + min: -1 + }) + ]), + positiveText: '确定选择目录', + negativeText: '取消', + onPositiveClick: async () => { + try { + const files = await electronApi.pickProcessFolder(depth.value) + if (files && files.length > 0) { + callback(files) + message.success(`已添加 ${files.length} 个可执行文件`) + } else if (files) { + message.info('该目录下未找到 exe 文件') + } + } catch (e) { + message.error('当前环境不支持选择目录') + } + } + }) + } + + return { + pickImage, + pickProcess, + pickProcessFolderWithDialog + } +} diff --git a/src/composables/useGameScanner.ts b/src/composables/useGameScanner.ts new file mode 100644 index 0000000..f7f9b16 --- /dev/null +++ b/src/composables/useGameScanner.ts @@ -0,0 +1,78 @@ +import { ref } from 'vue' +import { useMessage } from 'naive-ui' +import { systemApi } from '@/api' +import { useGameStore } from '@/stores/games' +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' + + async function scanGames() { + isScanning.value = true + try { + const localGames = await systemApi.scanLocalGames() + const added = await autoAddGamesFromLibraryScan(localGames) + const processes = await systemApi.scanProcesses() + const matched = gameStore.matchRunningGames(processes) + + if (added > 0) { + message.success(`扫描完成:新增 ${added} 个游戏,当前运行匹配 ${matched.length} 个。`) + } else if (matched.length > 0) { + message.info(`扫描完成:未新增游戏,当前运行匹配 ${matched.length} 个。`) + } else { + message.info('扫描完成:未发现可新增的本地游戏。') + } + } catch (error) { + console.error('Failed to scan games:', error) + message.error('扫描失败,请重试。') + } finally { + setTimeout(() => { isScanning.value = false }, 500) + } + } + + async function autoAddGamesFromLibraryScan(localGames: LocalScanGame[]) { + const existingGameKeys = new Set( + gameStore.gameLibrary.flatMap((game: Game) => { + const targets = Array.isArray(game.processName) ? game.processName : [game.processName] + return targets.map(name => `${String(game.name).toLowerCase()}|${String(name).toLowerCase()}`) + }) + ) + + const seen = new Set() + const candidates = localGames + .filter(g => !!g.name && !!g.processName) + .filter(g => { + const key = `${g.name.toLowerCase()}|${g.processName.toLowerCase()}` + if (seen.has(key)) return false + seen.add(key) + return !existingGameKeys.has(key) + }) + + let added = 0 + + for (const item of candidates) { + await gameStore.addGame({ + name: item.name, + processName: [item.processName], + category: fallbackCategoryId, + status: 'idle', + latency: 0, + proxyMode: 'process', + chainProxy: true + }) + added += 1 + } + + return added + } + + return { + isScanning, + scanGames + } +} diff --git a/src/composables/useTheme.ts b/src/composables/useTheme.ts index db51de4..555ebe7 100644 --- a/src/composables/useTheme.ts +++ b/src/composables/useTheme.ts @@ -1,7 +1,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue' +import type { Theme, ThemeColor } from '@/types' -export type Theme = 'light' | 'dark' | 'auto' -export type ThemeColor = 'green' | 'blue' | 'purple' | 'orange' | 'red' +export type { Theme, ThemeColor } const theme = ref('auto') const resolvedTheme = ref<'light' | 'dark'>('dark') // Default to dark diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 0000000..02dfa8c --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1,11 @@ +export const LOCAL_PROXY_HOSTS = ['www.google.com', 'www.apple.com'] +export const LOCAL_PROXY_NODE_CHECK_RETRIES = 3 +export const LOCAL_PROXY_NODE_CHECK_RETRY_DELAY_MS = 1200 +export const LOCAL_PROXY_NODE_APPLY_WARMUP_MS = 800 +export const LOCAL_PROXY_NODE_CHECK_TIMEOUT_MS = 5000 +export const LOCAL_PROXY_RECHECK_INTERVAL_MS = 60000 + +export const GAME_CATEGORIES = ['fps', 'moba', 'rpg', 'other'] as const + +export const DEFAULT_DNS_PRIMARY = 'https://dns.google/dns-query' +export const DEFAULT_DNS_SECONDARY = 'https://1.1.1.1/dns-query' diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index f2100dc..3f5cc81 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -6,7 +6,7 @@
log {{ pkg.name }} -
v{{ appVersion }} @@ -116,66 +116,68 @@ diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 6c7dca2..323c78a 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -5,104 +5,3 @@ declare module '*.vue' { const component: DefineComponent<{}, {}, any> export default component } - -interface Window { - logs: { - getAll: () => Promise> - clear: () => Promise - getFilePath: () => Promise - pushFrontend: (entry: { - level?: 'debug' | 'info' | 'warn' | 'error' - source?: string - message?: string - detail?: string - }) => Promise - onNew: (callback: (entry: { - id: string - timestamp: number - level: 'debug' | 'info' | 'warn' | 'error' - category: 'frontend' | 'backend' | 'core' - source: string - message: string - detail?: string - }) => void) => void - offNew: (callback: (entry: { - id: string - timestamp: number - level: 'debug' | 'info' | 'warn' | 'error' - category: 'frontend' | 'backend' | 'core' - source: string - message: string - detail?: string - }) => void) => void - } - electron: { - minimize: () => Promise - maximize: () => Promise - close: () => Promise - pickImage: () => Promise - pickProcess: () => Promise - pickProcessFolder: (maxDepth?: number) => Promise - on: (channel: string, callback: (...args: any[]) => void) => void - off: (channel: string, callback: (...args: any[]) => void) => void - } - singbox: { - start: (config: string) => Promise - stop: () => Promise - restart: (config: string) => Promise - } - system: { - scanProcesses: () => Promise - scanLocalGames: () => Promise> - getProcessTree: () => Promise - ping: (host: string) => Promise - tcpPing: (host: string, port: number) => Promise - flushDnsCache: () => Promise<{ ok: boolean, code: number, output: string, message: string }> - reinstallTunAdapter: (interfaceName?: string) => Promise<{ ok: boolean, code: number, output: string, message: string }> - findAvailablePort: (port: number, count?: number) => Promise - testHttpProxyConnect: ( - proxyPort: number, - targetHost: string, - targetPort?: number, - timeoutMs?: number - ) => Promise<{ ok: boolean; statusLine: string; error?: string }> - } - proxyMonitor: { - start: (gameId: string, processNames: string[]) => Promise - stop: () => Promise - } - nodes: { - getAll: () => Promise - save: (node: any) => Promise - delete: (id: string) => Promise - import: (nodes: any[]) => Promise - } - games: { - getAll: () => Promise - save: (game: any) => Promise - delete: (id: string) => Promise - } - categories: { - getAll: () => Promise - save: (category: any) => Promise - delete: (id: string) => Promise - } - app: { - getVersion: () => Promise - checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string; releaseDate?: string; releaseNotes?: string; error?: string }> - openUrl: (url: string) => Promise - } -} diff --git a/tests/unit/singbox-config.spec.ts b/tests/unit/singbox-config.spec.ts index 9804f70..3d6573a 100644 --- a/tests/unit/singbox-config.spec.ts +++ b/tests/unit/singbox-config.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest' import { generateSingboxConfig } from '../../src/utils/singbox-config' import type { Game } from '../../src/types' -import type { NodeConfig } from '../../src/utils/protocol' +import { parseShareLink, type NodeConfig } from '../../src/utils/protocol' describe('generateSingboxConfig', () => { const mockGame: Game = { @@ -53,15 +53,151 @@ describe('generateSingboxConfig', () => { const routingGame = { ...mockGame, proxyMode: 'routing' as const } const config = JSON.parse(generateSingboxConfig(routingGame, mockNode)) - expect(config.route.final).toBe('proxy') + expect(config.route.final).toBe('direct') - expect(config.dns.final).toBe('google') + expect(config.dns.final).toBe('remote-primary') const tunInbound = config.inbounds.find((i: any) => i.tag === 'tun-in') expect(tunInbound).toBeDefined() expect(tunInbound.inet4_address).toBeDefined() }) + it('should only proxy configured CIDR rules in routing mode', () => { + const routingGame = { + ...mockGame, + proxyMode: 'routing' as const, + routingRules: ['216.58.220.1/24'] + } + const config = JSON.parse(generateSingboxConfig(routingGame, mockNode)) + + expect(config.route.final).toBe('direct') + const cidrRule = config.route.rules.find((r: any) => + Array.isArray(r?.ip_cidr) && r.ip_cidr.includes('216.58.220.1/24') + ) + expect(cidrRule).toBeDefined() + expect(cidrRule.outbound).toBe('proxy') + }) + + it('should only proxy configured CIDR for specified processes in routing mode', () => { + const routingGame = { + ...mockGame, + proxyMode: 'routing' as const, + processName: ['chrome.exe', 'msedge.exe'], + routingRules: ['216.58.220.1/24'] + } + const config = JSON.parse(generateSingboxConfig(routingGame, mockNode)) + + expect(config.route.final).toBe('direct') + const cidrRule = config.route.rules.find((r: any) => + Array.isArray(r?.ip_cidr) && r.ip_cidr.includes('216.58.220.1/24') + ) + expect(cidrRule).toBeDefined() + expect(cidrRule.outbound).toBe('proxy') + expect(Array.isArray(cidrRule.process_name)).toBe(true) + expect(cidrRule.process_name).toContain('chrome.exe') + expect(cidrRule.process_name).toContain('msedge.exe') + }) + + it('should include exe alias for bare process names in routing mode', () => { + const routingGame = { + ...mockGame, + proxyMode: 'routing' as const, + processName: ['chrome'], + routingRules: ['1.1.1.1'] + } + const config = JSON.parse(generateSingboxConfig(routingGame, mockNode)) + 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(Array.isArray(cidrRule.process_name)).toBe(true) + expect(cidrRule.process_name).toContain('chrome') + expect(cidrRule.process_name).toContain('chrome.exe') + }) + + it('should use remote geoip-cn and geosite-cn rulesets for bypass_cn mode', () => { + const routingGame = { + ...mockGame, + proxyMode: 'routing' as const, + routingRules: ['bypass_cn'] + } + const config = JSON.parse(generateSingboxConfig(routingGame, mockNode)) + + expect(config.route.final).toBe('direct') + + const geoipRuleSet = config.route.rule_set?.find((r: any) => r?.tag === 'geoip-cn') + expect(geoipRuleSet).toBeDefined() + expect(geoipRuleSet.type).toBe('remote') + expect(geoipRuleSet.format).toBe('binary') + expect(String(geoipRuleSet.url)).toContain('Loyalsoldier/geoip') + expect(geoipRuleSet.download_detour).toBe('proxy') + const geositeRuleSet = config.route.rule_set?.find((r: any) => r?.tag === 'geosite-cn') + expect(geositeRuleSet).toBeDefined() + expect(geositeRuleSet.type).toBe('remote') + expect(geositeRuleSet.format).toBe('binary') + expect(String(geositeRuleSet.url)).toContain('sing-geosite') + expect(geositeRuleSet.download_detour).toBe('proxy') + + const geoipRouteRule = config.route.rules.find((r: any) => + Array.isArray(r?.rule_set) && r.rule_set.includes('geoip-cn') + ) + expect(geoipRouteRule).toBeDefined() + expect(geoipRouteRule.outbound).toBe('direct') + const geositeRouteRule = config.route.rules.find((r: any) => + Array.isArray(r?.rule_set) && r.rule_set.includes('geosite-cn') + ) + expect(geositeRouteRule).toBeDefined() + expect(geositeRouteRule.outbound).toBe('direct') + }) + + it('should keep bypass_cn scoped to selected processes when process names are provided', () => { + const routingGame = { + ...mockGame, + proxyMode: 'routing' as const, + processName: ['chrome.exe'], + routingRules: ['bypass_cn'] + } + const config = JSON.parse(generateSingboxConfig(routingGame, mockNode)) + + expect(config.route.final).toBe('direct') + const geoipDirectRule = config.route.rules.find((r: any) => + Array.isArray(r?.rule_set) && r.rule_set.includes('geoip-cn') + ) + expect(geoipDirectRule).toBeDefined() + expect(geoipDirectRule.process_name).toContain('chrome.exe') + const processProxyRule = config.route.rules.find((r: any) => + Array.isArray(r?.process_name) && r.process_name.includes('chrome.exe') && r.outbound === 'proxy' + ) + expect(processProxyRule).toBeDefined() + }) + + it('should scope global routing to selected processes when process names are provided', () => { + const routingGame = { + ...mockGame, + proxyMode: 'routing' as const, + processName: ['chrome.exe'], + routingRules: ['global'] + } + const config = JSON.parse(generateSingboxConfig(routingGame, mockNode)) + + expect(config.route.final).toBe('direct') + const processProxyRule = config.route.rules.find((r: any) => + Array.isArray(r?.process_name) && r.process_name.includes('chrome.exe') && r.outbound === 'proxy' + ) + expect(processProxyRule).toBeDefined() + }) + + it('should keep global routing as full proxy when no process names are provided', () => { + const routingGame = { + ...mockGame, + proxyMode: 'routing' as const, + processName: [], + routingRules: ['global'] + } + const config = JSON.parse(generateSingboxConfig(routingGame, mockNode)) + expect(config.route.final).toBe('proxy') + }) + it('should include node outbound', () => { const config = JSON.parse(generateSingboxConfig(mockGame, mockNode)) const nodeOut = config.outbounds.find((o: any) => o.tag === 'node-out') @@ -69,4 +205,63 @@ describe('generateSingboxConfig', () => { expect(nodeOut.type).toBe('vmess') expect(nodeOut.server).toBe('example.com') }) + + it('should not use IP as TLS server_name fallback', () => { + const ipNode: NodeConfig = { + ...mockNode, + server: '1.2.3.4', + host: undefined, + tls: { + enabled: true, + server_name: '' + } + } + const config = JSON.parse(generateSingboxConfig(mockGame, ipNode)) + const nodeOut = config.outbounds.find((o: any) => o.tag === 'node-out') + expect(nodeOut?.tls?.server_name).toBeUndefined() + }) + + it('should keep domain TLS server_name fallback', () => { + const domainNode: NodeConfig = { + ...mockNode, + server: 'node.example.com', + host: undefined, + tls: { + enabled: true, + server_name: '' + } + } + const config = JSON.parse(generateSingboxConfig(mockGame, domainNode)) + const nodeOut = config.outbounds.find((o: any) => o.tag === 'node-out') + expect(nodeOut?.tls?.server_name).toBe('node.example.com') + }) + + it('should generate VLESS reality outbound from share link', () => { + const link = 'vless://13a3abd8-6315-4735-b3a2-39f38a2e4f3d@156.246.93.26:38073?encryption=none&security=reality&flow=xtls-rprx-vision&type=tcp&sni=www.paypal.com&pbk=DrpIgSOtaEHqJywmydYjljWB9FD_1PlFjQIAlbHiOgk&fp=chrome#233boy-reality-156.246.93.26' + const node = parseShareLink(link) + expect(node).not.toBeNull() + const config = JSON.parse(generateSingboxConfig(mockGame, node as NodeConfig)) + const nodeOut = config.outbounds.find((o: any) => o.tag === 'node-out') + expect(nodeOut?.type).toBe('vless') + expect(nodeOut?.flow).toBe('xtls-rprx-vision') + expect(nodeOut?.tls?.server_name).toBe('www.paypal.com') + expect(nodeOut?.tls?.reality?.enabled).toBe(true) + expect(nodeOut?.tls?.reality?.public_key).toBe('DrpIgSOtaEHqJywmydYjljWB9FD_1PlFjQIAlbHiOgk') + }) + + it('should add bootstrap DNS rule for domain node in secure mode', () => { + const domainNode: NodeConfig = { + ...mockNode, + server: 'm.cnmjin.net', + tls: { + enabled: false + } + } + const config = JSON.parse(generateSingboxConfig(mockGame, domainNode, { mode: 'secure' })) + const bootstrapRule = config.dns.rules.find((r: any) => + Array.isArray(r?.domain) && r.domain.includes('m.cnmjin.net') + ) + expect(bootstrapRule).toBeDefined() + expect(bootstrapRule.server).toBe('local') + }) })