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

uE%|#Flti< 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 literal 41086 zcmeHNL2~0Z5JXb0wQtHPZ^$!z+?&3zCwp(sX>)`QDV1;j!al{1N_lltrU#@%07KQX z3hAjz4Q*2rM6-cz_B22#rC;PHrQhT)rLW&p`duFXzza10iibPAO6j}qjol$7`AvsI zd62z4mA^xJe}9)Z&ib3qXFQWX`9TwQu>}LcKrj#tY-8ZhZHEs(`Z)ZI+fMkp?eO78 zXJ-NO$M7L-I$=I-I(+!S`?IeAyYoN8$F${y4`*Ki9?rf3+@E~~5PVkw1m9JFJHh{Z z%LP07Si%1>`kY(N_vS1B@DE!$Jown`ECBF59qh(UxDuu9&f*3Ob2pgWW1$bNFXLNup0Pv4G`pE%g0Wjtn-$Q^3 zA1}xP0I~$2h@d5iyAa$j@g3G7JHf{asBB<^E5IUvqzftlbsAjb^95K2kPv`!aQ;G^ z1S|u{Lx2XKF8~NH7r*XRfJdSL0GtC*dhmk&9LV^`D*?~IXWom-g15k@F9chBwgAAt z1d!;!7GU4tmuWE10XxCJ29OY-0`O*G2!INoEI<=LLcp8!h_@0)0iIT1f{l$UK##AJ z09pwG{xSfB02P2$f|n~WrJpST#=i(4>4OSDTZe${<23T=fhvH3pTP;M1OcoWKUDzW zF9Qe~NE2-(ZsDViCBROg+VL|5co6(5fMx+mB@p;^07wV2G?E1913>Uq0M1I>#3!yp zi~te>P?b0W7y&d3z*&c2q;&uZK2ZQr-Q%kOlxZ-Kju>*asRJkiWE1~cS~&|r8^1V! zApr2(01zo&qFiNcnjz$nR@)JQl-zr-FMh0O69iZpy=Ndim)S?0mT#0WM0OKDhfR;eB z)~!&WgpC$p10~dmyLBO%^ zfC`QGtZm>*eAR&#?xk&@1J+95121{_#tZs6|)sm~x7VBl2i2ELs^YQP)+HSjH40I)(aV9J2%N+23J z`0BuqwgiBc!$6kTPi0TTv*2?KSOjRt&of{$OCZmH$t;0H1KI+J{v-n=vjh_H%@W8n zU@|i(3jrhpCYY@)fkXoq2Q(#+XuxDm!GKgz{ej3ijR|i%fy>boMmq4xo`x3}CV4p!`19l~lY`|UsvH|XZw=98V z{H_F&4cL{yEClcpFs1ebLAC)~0oVp?r{Qb^cGGaS0oqw09e)qR>&;lU0oxLor8i6i zwi!e>V3R>~12!d)ZoqaLj?0m@2Go1>xa6+0K)m4MKLGM_CxULkrUYi`4A+45G@Nh1 zdK%6*U|jJsRHuMV8=MDPt*m%uCpkPTSP0v!xkWsrjbs}ks8z)AoI z15_o@1Alod$iaYB3G^^v5ui-NJq%bJaMEvqf`868VHRJJ&<_N027-ZLAQ%V+f`MQl7zhS}fnXpQ2nK?IU?3O>27-ZLAQ%V+f`MQl7zhS}fnXpQ U2nK?IU?3O>27-ZL;QzwFzx0ZurvLx| diff --git a/public/logo.png b/public/logo.png index b4b9d28016c2b4ea7a2a5f96fb41c5a1320ab79b..28c1ed80fa7dc713065f2dd590f9de46c8147d77 100644 GIT binary patch literal 64082 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 a6mo0KF9$np#t;4na#PpfYR(m_=l>5|I{!!j literal 6446 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 - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + Layer 1 + + + + \ No newline at end of file diff --git a/src/App.vue b/src/App.vue index b9ff701..faf2cf6 100644 --- a/src/App.vue +++ b/src/App.vue @@ -5,6 +5,7 @@ + @@ -16,6 +17,7 @@ import { computed } from 'vue' import { darkTheme, type GlobalThemeOverrides } from 'naive-ui' import { useTheme, type ThemeColor } from './composables/useTheme' +import SingboxInstallerGuard from '@/components/singbox/SingboxInstallerGuard.vue' const { isDark, themeColor } = useTheme() diff --git a/src/api/app.ts b/src/api/app.ts index 188cee5..625ad3d 100644 --- a/src/api/app.ts +++ b/src/api/app.ts @@ -4,6 +4,9 @@ export const appApi = { getVersion: (): Promise => window.app.getVersion(), checkUpdate: () => window.app.checkUpdate(), openUrl: (url: string) => window.app.openUrl(url), + openDir: (dir: string) => window.app.openDir(dir), + restart: () => window.app.restart(), + reset: () => window.app.reset(), } export const logsApi = { diff --git a/src/api/singbox.ts b/src/api/singbox.ts index 55a3699..19bc6c6 100644 --- a/src/api/singbox.ts +++ b/src/api/singbox.ts @@ -2,4 +2,6 @@ export const singboxApi = { start: (config: string) => window.singbox.start(config), stop: () => window.singbox.stop(), restart: (config: string) => window.singbox.restart(config), + ensureCoreInstalled: () => window.singbox.ensureCoreInstalled(), + getInstallInfo: () => window.singbox.getInstallInfo(), } diff --git a/src/api/system.ts b/src/api/system.ts index b0e4f66..cf44001 100644 --- a/src/api/system.ts +++ b/src/api/system.ts @@ -52,4 +52,16 @@ export const systemApi = { } return fn() }, + onScanProgress: (callback: (data: { status: string, details?: string }) => void) => { + const fn = (window.system as any)?.onScanProgress + if (typeof fn === 'function') { + fn(callback) + } + }, + offScanProgress: (callback: (data: { status: string, details?: string }) => void) => { + const fn = (window.system as any)?.offScanProgress + if (typeof fn === 'function') { + fn(callback) + } + } } diff --git a/src/autoImport/components.d.ts b/src/autoImport/components.d.ts index aaa1dda..dcbfd89 100644 --- a/src/autoImport/components.d.ts +++ b/src/autoImport/components.d.ts @@ -12,7 +12,6 @@ export {} declare module 'vue' { export interface GlobalComponents { AboutSettings: typeof import('./../components/settings/AboutSettings.vue')['default'] - AdvancedConfigEditor: typeof import('./../components/library/AdvancedConfigEditor.vue')['default'] CategoryManager: typeof import('./../components/library/CategoryManager.vue')['default'] GameEditModal: typeof import('./../components/library/GameEditModal.vue')['default'] GeneralSettings: typeof import('./../components/settings/GeneralSettings.vue')['default'] @@ -21,6 +20,7 @@ declare module 'vue' { LatencyChart: typeof import('./../components/dashboard/LatencyChart.vue')['default'] LogsSettings: typeof import('./../components/settings/LogsSettings.vue')['default'] NButton: typeof import('naive-ui')['NButton'] + NCard: typeof import('naive-ui')['NCard'] NCheckbox: typeof import('naive-ui')['NCheckbox'] NConfigProvider: typeof import('naive-ui')['NConfigProvider'] NDialogProvider: typeof import('naive-ui')['NDialogProvider'] @@ -29,6 +29,7 @@ declare module 'vue' { NForm: typeof import('naive-ui')['NForm'] NFormItem: typeof import('naive-ui')['NFormItem'] NInput: typeof import('naive-ui')['NInput'] + NInputGroup: typeof import('naive-ui')['NInputGroup'] NInputNumber: typeof import('naive-ui')['NInputNumber'] NMessageProvider: typeof import('naive-ui')['NMessageProvider'] NModal: typeof import('naive-ui')['NModal'] @@ -41,10 +42,9 @@ declare module 'vue' { NodeSelector: typeof import('./../components/dashboard/NodeSelector.vue')['default'] NPopover: typeof import('naive-ui')['NPopover'] NPopselect: typeof import('naive-ui')['NPopselect'] - NRadio: typeof import('naive-ui')['NRadio'] - NRadioGroup: typeof import('naive-ui')['NRadioGroup'] + NProgress: typeof import('naive-ui')['NProgress'] NSelect: typeof import('naive-ui')['NSelect'] - NSpace: typeof import('naive-ui')['NSpace'] + NSpin: typeof import('naive-ui')['NSpin'] NSwitch: typeof import('naive-ui')['NSwitch'] NTag: typeof import('naive-ui')['NTag'] NTime: typeof import('naive-ui')['NTime'] @@ -52,6 +52,7 @@ declare module 'vue' { RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] SettingsSidebar: typeof import('./../components/settings/SettingsSidebar.vue')['default'] + SingboxInstallerGuard: typeof import('./../components/singbox/SingboxInstallerGuard.vue')['default'] SubscriptionManagerModal: typeof import('./../components/node/SubscriptionManagerModal.vue')['default'] ThemeToggle: typeof import('./../components/common/ThemeToggle.vue')['default'] } diff --git a/src/components/common/IconSelector.vue b/src/components/common/IconSelector.vue index 23b0bb9..2f75c9c 100644 --- a/src/components/common/IconSelector.vue +++ b/src/components/common/IconSelector.vue @@ -1,12 +1,66 @@ @@ -29,4 +83,4 @@ async function handlePickImage() { emit('update:modelValue', url) } } - + \ No newline at end of file diff --git a/src/components/library/AdvancedConfigEditor.vue b/src/components/library/AdvancedConfigEditor.vue deleted file mode 100644 index 9257588..0000000 --- a/src/components/library/AdvancedConfigEditor.vue +++ /dev/null @@ -1,193 +0,0 @@ - - - diff --git a/src/components/library/GameEditModal.vue b/src/components/library/GameEditModal.vue index 634b7fd..0f209d1 100644 --- a/src/components/library/GameEditModal.vue +++ b/src/components/library/GameEditModal.vue @@ -1,43 +1,114 @@ diff --git a/src/components/settings/AboutSettings.vue b/src/components/settings/AboutSettings.vue index 6975957..4ff4bc1 100644 --- a/src/components/settings/AboutSettings.vue +++ b/src/components/settings/AboutSettings.vue @@ -1,80 +1,82 @@