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 3fdb0ca..90b72cc 100644 Binary files a/public/logo.ico and b/public/logo.ico differ diff --git a/public/logo.png b/public/logo.png index b4b9d28..28c1ed8 100644 Binary files a/public/logo.png and b/public/logo.png differ diff --git a/public/logo.svg b/public/logo.svg index 77a5e9b..2aaa703 100644 --- a/public/logo.svg +++ b/public/logo.svg @@ -1,35 +1,16 @@ - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + 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 @@