feat(main): 增强主进程日志与游戏扫描稳定性与性能
- 主进程新增 CrashReporter 捕获崩溃信息,提升诊断能力 - 优化 ipcMain 游戏扫描进度事件,加入节流防止 IPC 风暴 - 扫描平台分工重构,支持并发执行,添加执行日志和错误捕获 - 完善扫描结果去重逻辑,优先基于标准化安装目录合并游戏条目 - 增加未捕获异常、渲染进程退出和子进程退出日志,提升稳定性监控 - 更新日志模块,错误级日志立即同步落盘,降低闪退日志丢失风险 - 延长日志落盘监听事件,兼容多种退出信号安全清理日志 - 主进程窗口配置修改,始终保留调试工具,提高开发调试便捷性 - 优化扫描服务代码注释和参数说明,提升可维护性和易读性
This commit is contained in:
1 parent
78d6271882
commit
3cf582e71b
19 files changed
+1907
-596
No files matched your search
@@ -0,0 +1,579 @@
|
||||
# electron/services/scanners 重解读(功能增删与优化决策版)
|
||||
|
||||
更新时间:2026-03-05
|
||||
适用代码:`electron/services/scanners/*` + `electron/services/game-scanner.ts` 及其联动链路
|
||||
|
||||
## 0. 这份文档解决什么问题
|
||||
|
||||
你要做的是:
|
||||
|
||||
1. 功能增删(新增/移除平台、改扫描策略、改字段)
|
||||
2. 再决定优化(性能、稳定性、维护成本)
|
||||
|
||||
这份文档按“先看清系统,再做改造”组织,重点回答:
|
||||
|
||||
- 当前模块到底做了哪些功能
|
||||
- 每个功能用什么技术实现
|
||||
- 扫描结果会联动哪些模块
|
||||
- 哪些点改起来风险高/耦合重
|
||||
- 你应该先改哪里,后改哪里
|
||||
|
||||
## 1. 模块总览
|
||||
|
||||
### 1.1 目录结构与职责
|
||||
|
||||
`electron/services/scanners` 目录当前包含:
|
||||
|
||||
- `types.ts`: 扫描统一类型与过滤常量
|
||||
- `utils.ts`: 通用工具层(并发、目录扫描、exe 评分、图标提取、路径标准化)
|
||||
- `steam.ts`: Steam 扫描器
|
||||
- `microsoft.ts`: Microsoft Store / Xbox 扫描器
|
||||
- `epic.ts`: Epic 扫描器
|
||||
- `ea.ts`: EA 扫描器
|
||||
- `battlenet.ts`: Battle.net 扫描器
|
||||
- `wegame.ts`: WeGame 扫描器
|
||||
- `local.ts`: 本地快捷方式扫描器
|
||||
- `flat.ts`: 通用“平铺目录扫描器”(Epic/EA/Microsoft 复用)
|
||||
|
||||
聚合入口不在该目录,而在:
|
||||
|
||||
- `electron/services/game-scanner.ts`
|
||||
|
||||
### 1.2 统一结果模型(所有扫描器必须遵守)
|
||||
|
||||
```ts
|
||||
type LocalGameScanResult = {
|
||||
name: string;
|
||||
processName: string[];
|
||||
source:
|
||||
| "Steam"
|
||||
| "Microsoft"
|
||||
| "Epic"
|
||||
| "EA"
|
||||
| "BattleNet"
|
||||
| "WeGame"
|
||||
| "Local"
|
||||
| "GOG";
|
||||
installDir: string;
|
||||
iconUrl?: string;
|
||||
};
|
||||
```
|
||||
|
||||
这 5 个字段是该系统最核心的“数据契约”。
|
||||
|
||||
## 2. 端到端链路(扫描结果如何流动)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Renderer useGameScanner.scanGames()"] --> B["Preload system.scanLocalGames()"]
|
||||
B --> C["Main IPC: system:scan-local-games"]
|
||||
C --> D["GameScannerService.scanLocalGamesFromPlatforms()"]
|
||||
D --> E["各平台 scanner"]
|
||||
E --> F["LocalGameScanResult[]"]
|
||||
F --> G["autoAddGamesFromLibraryScan() 自动入库"]
|
||||
G --> H["Database games.process_name 保存"]
|
||||
H --> I["ProxyMonitor 读取 processName"]
|
||||
I --> J["SingBoxConfigManager.updateProcessNames()"]
|
||||
```
|
||||
|
||||
进度事件协议:
|
||||
|
||||
- `system:scan-progress` 事件
|
||||
- `status` 目前约定值:`scanning_platform` / `scanning_dir`
|
||||
|
||||
对应联动代码:
|
||||
|
||||
- Main IPC: `electron/main/index.ts`
|
||||
- Preload 暴露: `electron/preload/index.ts`
|
||||
- 前端消费: `src/composables/useGameScanner.ts`
|
||||
|
||||
## 3. 技术实现清单(扫描到底用了什么技术)
|
||||
|
||||
### 3.1 文件系统与路径
|
||||
|
||||
- `fs-extra`: `readdir/stat/pathExists/readFile/readJSON`
|
||||
- `path`: 目录拼接、标准化
|
||||
- `os`: CPU 核数/用户目录
|
||||
|
||||
### 3.2 系统信息采集(Windows)
|
||||
|
||||
- PowerShell + 注册表查询(通过 `runCommand('powershell', ...)`)
|
||||
- 查询来源:
|
||||
- Steam 注册表
|
||||
- 卸载项注册表(EA/BattleNet/Microsoft 提示)
|
||||
- WeGame 注册表
|
||||
- `Get-AppxPackage`(Microsoft)
|
||||
|
||||
### 3.3 Electron 原生能力
|
||||
|
||||
- `nativeImage.createFromPath()`:从 exe 抽图标 data URL
|
||||
- `shell.readShortcutLink()`:解析 `.lnk` 目标路径
|
||||
|
||||
### 3.4 并发控制(核心)
|
||||
|
||||
`mapWithConcurrency()` 是全模块通用执行器:
|
||||
|
||||
- 固定 worker 数量
|
||||
- 任务索引共享
|
||||
- 避免 `Promise.all` 一次性打满 I/O
|
||||
|
||||
并发参数分布(当前代码):
|
||||
|
||||
- 平台聚合并发:4(`GameScannerService`)
|
||||
- 多数目录层并发:4~8
|
||||
- 盘符探测并发:8
|
||||
|
||||
### 3.5 启发式识别(主进程挑选)
|
||||
|
||||
核心算法:`pickRelatedExecutables()`
|
||||
|
||||
- 分阶段深度扫描:2 -> 4 -> 6
|
||||
- 快速评分:
|
||||
- 文件名与目标名匹配度
|
||||
- 路径中 `shipping/win64/binaries` 等特征
|
||||
- `launcher` 降噪
|
||||
- 软过滤关键词降权
|
||||
- 二次评分:文件体积补分(`stat`)
|
||||
- 结果截断:最多取 4 个
|
||||
|
||||
## 4. 通用层深解(types.ts + utils.ts)
|
||||
|
||||
### 4.1 `types.ts` 的过滤体系
|
||||
|
||||
过滤分 3 层:
|
||||
|
||||
1. 目录硬过滤:`GAME_SCAN_IGNORE_DIR_NAMES`
|
||||
2. 可执行文件硬过滤:`GAME_SCAN_IGNORE_EXE_KEYWORDS`
|
||||
3. 文件名硬排除:`GAME_SCAN_EXE_HARD_EXCLUDE`
|
||||
|
||||
再加一层软过滤:
|
||||
|
||||
- `GAME_SCAN_SOFT_IGNORE_EXE_KEYWORDS`(只降权,不直接丢弃)
|
||||
|
||||
这套设计兼顾了“减少误报”和“避免误杀”。
|
||||
|
||||
### 4.2 `normalizeFsPath()` 的作用
|
||||
|
||||
- 路径 normalize
|
||||
- 去尾部分隔符
|
||||
- 转小写
|
||||
|
||||
这是跨平台扫描器最终去重的关键函数。
|
||||
|
||||
### 4.3 `getWindowsDriveRoots()`(当前实现)
|
||||
|
||||
- 每次调用都重新并发探测 `C:` 到 `Z:`
|
||||
- 不做盘符结果缓存
|
||||
|
||||
优点:实时性高(插拔盘符后能即时反映)
|
||||
代价:不同扫描器会重复探测盘符。
|
||||
|
||||
### 4.4 `pickGameIconDataUrl()`(当前实现)
|
||||
|
||||
- 图标结果不缓存
|
||||
- 只缓存 `nativeImage` API 句柄(模块级)
|
||||
- 候选 exe 只看前 4 个
|
||||
|
||||
优点:逻辑简单、不会有旧缓存污染
|
||||
代价:跨平台/跨轮扫描时会重复读取同一 exe 图标。
|
||||
|
||||
### 4.5 `collectExePaths()`:已从递归改为迭代任务队列
|
||||
|
||||
当前行为:
|
||||
|
||||
- 维护 `pending` 队列(`{dir, depth}`)
|
||||
- 按深度限制推进
|
||||
- 大目录优先级裁剪子目录(最多 120)
|
||||
- 目录去重 `visited`
|
||||
|
||||
你需要特别注意的现实情况:
|
||||
|
||||
- 当前 `workerCount` 初始化受 `pending.length` 影响,初始基本为 1,等价于单 worker 扫描。
|
||||
这意味着虽然代码形态是“并发 worker”,实际常常是串行队列。
|
||||
|
||||
这个点在你后续做性能优化时值得优先评估。
|
||||
|
||||
## 5. 各平台扫描器详解
|
||||
|
||||
## 5.1 Steam(`steam.ts`)
|
||||
|
||||
实现策略:清单优先 + 目录兜底
|
||||
|
||||
流程:
|
||||
|
||||
1. 盘符推导候选 Steam 根目录
|
||||
2. 限制性嗅探 `steamapps`,避免全盘暴扫
|
||||
3. 读注册表补充 Steam 安装根目录
|
||||
4. 解析 `steamapps/libraryfolders.vdf`(兼容新旧格式)
|
||||
5. 扫 `appmanifest_*.acf`:
|
||||
- 读取 `name` / `installdir` / `StateFlags`
|
||||
- 若 `StateFlags` 表示未安装则跳过
|
||||
6. 对安装目录执行 `pickRelatedExecutables`
|
||||
7. 若该库存在 manifest,则不再深扫 `steamapps/common`
|
||||
|
||||
技术亮点:
|
||||
|
||||
- `libraryfolders.vdf` 新旧格式兼容
|
||||
- 基于 manifest 的 I/O 节流策略
|
||||
|
||||
## 5.2 Microsoft(`microsoft.ts`)
|
||||
|
||||
实现策略:目录扫描 + Appx 包扫描 + 名称提示修正
|
||||
|
||||
流程:
|
||||
|
||||
1. `XboxGames` 根目录平铺扫描(`scanFlatPlatformFolder`)
|
||||
2. `Get-AppxPackage` 获取安装位置
|
||||
3. 读取 `AppxManifest.xml` 判断是否“像游戏”
|
||||
4. 通过 `DisplayName/Identity Name` 解析名称
|
||||
5. 合并 base 与 appx 结果(按 installDir)
|
||||
6. 再用注册表 hints + manifest hints 做名称修正
|
||||
|
||||
关键技术:
|
||||
|
||||
- XML 文本正则解析
|
||||
- 多来源结果融合 + 最长路径提示优先
|
||||
|
||||
## 5.3 Epic(`epic.ts`)
|
||||
|
||||
实现策略:manifest 优先,平铺目录兜底
|
||||
|
||||
流程:
|
||||
|
||||
1. 读取 `%ProgramData%/.../Manifests/*.item`
|
||||
2. 用 `InstallLocation + DisplayName/AppName`
|
||||
3. 若有 `LaunchExecutable`,优先作为进程名候选
|
||||
4. 再跑 `pickRelatedExecutables` 追加进程名
|
||||
5. 平铺目录兜底扫描(`scanFlatPlatformFolder('Epic', roots)`)
|
||||
|
||||
## 5.4 EA(`ea.ts`)
|
||||
|
||||
实现策略:注册表双通道 + 平铺兜底 + 启动器排除
|
||||
|
||||
流程:
|
||||
|
||||
1. 专有 EA 注册表读取 `Install Dir`
|
||||
2. 卸载项注册表读取 `DisplayName + InstallLocation`
|
||||
3. 合并 hints,统一扫描函数 `scanEAHintInstall`
|
||||
4. 排除 `EA app / EA desktop / Origin` 启动器
|
||||
5. 平铺目录兜底,且附加目录过滤钩子
|
||||
|
||||
## 5.5 BattleNet(`battlenet.ts`)
|
||||
|
||||
实现策略:注册表 hints + 一级目录可疑项扫描
|
||||
|
||||
流程:
|
||||
|
||||
1. 卸载项注册表收集候选
|
||||
2. 过滤 launcher/agent/updater
|
||||
3. 判断游戏目录特征:
|
||||
- `.build.info` 存在
|
||||
- 或目录名命中游戏关键词
|
||||
4. fallback:在 `Games/Game/Program Files` 一级扫可疑目录
|
||||
|
||||
特征:
|
||||
|
||||
- BattleNet 识别更依赖“目录特征+关键词”,准确率受目录命名影响较大。
|
||||
|
||||
## 5.6 WeGame(`wegame.ts`)
|
||||
|
||||
实现策略:注册表根目录 + 容器目录展开
|
||||
|
||||
流程:
|
||||
|
||||
1. 读腾讯 WeGame 注册表路径
|
||||
2. 组合常见安装根
|
||||
3. 对 `rail_apps/common_apps/apps/games` 容器做二层展开
|
||||
4. 跳过 cache/log/temp/patch 等非游戏目录
|
||||
|
||||
## 5.7 Local(`local.ts`)
|
||||
|
||||
实现策略:快捷方式反查 exe
|
||||
|
||||
流程:
|
||||
|
||||
1. 递归扫描桌面和开始菜单(深度 <= 3)找 `.lnk`
|
||||
2. `shell.readShortcutLink()` 拿 target
|
||||
3. 排除浏览器/系统目录/Office 等路径
|
||||
4. 基于 target 所在目录再执行 `pickRelatedExecutables`
|
||||
|
||||
说明:
|
||||
|
||||
- 这里仍是递归 `collectLnks()`,并未迭代化。
|
||||
|
||||
## 5.8 Flat(`flat.ts`)
|
||||
|
||||
这是“平台复用模板”,做三件事:
|
||||
|
||||
1. 扫 root 的一级目录
|
||||
2. 可选目录过滤(`ignoreDirNames`, `shouldIncludeDir`)
|
||||
3. 每个目录执行 `pickRelatedExecutables`
|
||||
|
||||
这个文件是后续做“平台插件化”最好的抽象基础。
|
||||
|
||||
## 6. 聚合器(`game-scanner.ts`)
|
||||
|
||||
### 6.1 聚合流程
|
||||
|
||||
- 同时运行 7 个扫描器(并发上限 4)
|
||||
- 将各平台结果拼接后统一去重
|
||||
|
||||
### 6.2 去重策略
|
||||
|
||||
第一优先级:`installDir` 归一化后作为 key
|
||||
第二优先级:无 installDir 时,`name + sorted(processName)`
|
||||
|
||||
冲突合并策略:
|
||||
|
||||
- `name` 选长度更长的
|
||||
- `processName` 合并去重
|
||||
- `iconUrl` 取已有优先
|
||||
|
||||
### 6.3 额外接口
|
||||
|
||||
`scanDir()` 用于“手动选目录扫描 exe”,当前仍使用递归实现。
|
||||
|
||||
## 7. 联动与耦合(必须清楚)
|
||||
|
||||
## 7.1 IPC 协议耦合
|
||||
|
||||
- 主进程 channel:`system:scan-local-games`
|
||||
- 进度事件:`system:scan-progress`
|
||||
- 前端写死了 `scanning_platform/scanning_dir` 两个状态解释逻辑
|
||||
|
||||
改这些名字或状态值,会直接影响 UI 展示。
|
||||
|
||||
## 7.2 前端自动入库耦合
|
||||
|
||||
`useGameScanner.autoAddGamesFromLibraryScan()` 会:
|
||||
|
||||
- 按 `source` 自动创建平台分类(使用 `PLATFORMS`)
|
||||
- 用 `name+process` 去重
|
||||
- 调 `categoryApi.match` 自动匹配分类
|
||||
|
||||
所以 `source` 与 `processName` 的语义变化会影响入库行为。
|
||||
|
||||
## 7.3 数据库耦合
|
||||
|
||||
`DatabaseService.saveGame()`:
|
||||
|
||||
- `processName` 最终写入 `games.process_name`(JSON 字符串)
|
||||
- `getAllGames()` 再解析为数组返回
|
||||
|
||||
字段兼容性要求:
|
||||
|
||||
- 扫描层始终保证 `processName: string[]`
|
||||
- 避免输出路径形式(最好仅文件名)
|
||||
|
||||
## 7.4 代理链路耦合(最关键)
|
||||
|
||||
`proxy-monitor` + `singbox/config` 直接依赖扫描得到的进程名:
|
||||
|
||||
1. `ProxyMonitorService.startMonitoring(gameId, processNames)` 接收初始进程
|
||||
2. 归一化后监控进程树,每 3 秒轮询
|
||||
3. 发现子进程后更新 monitored set
|
||||
4. `SingBoxConfigManager.updateProcessNames()` 更新 route/dns 规则中的 `process_name`
|
||||
|
||||
如果扫描器输出噪声进程,会导致代理规则扩大,带来副作用。
|
||||
|
||||
## 8. 当前功能清单(你可以据此做增删)
|
||||
|
||||
模块当前已经具备:
|
||||
|
||||
1. 多平台自动发现(Steam/Microsoft/Epic/EA/BattleNet/WeGame/Local)
|
||||
2. 平台级并发扫描
|
||||
3. 游戏进程多候选输出(不是单 exe)
|
||||
4. 图标提取(可选)
|
||||
5. 统一去重与结果合并
|
||||
6. 扫描进度实时推送给前端
|
||||
7. 自动入库与分类关联
|
||||
8. 与运行时代理规则联动
|
||||
|
||||
## 9. 性能与复杂度观察(基于当前实现)
|
||||
|
||||
## 9.1 已做的控制
|
||||
|
||||
- 多层并发限流(避免 I/O 风暴)
|
||||
- 大目录子目录截断(3000+ 条目)
|
||||
- 分阶段深度探测(2/4/6)
|
||||
- Steam manifest 优先,减少 common 深扫
|
||||
- 图标提取最多探测 4 个 exe
|
||||
|
||||
## 9.2 当前热点
|
||||
|
||||
1. 多平台重复调用 `getWindowsDriveRoots()`(每次全探测)
|
||||
2. PowerShell 调用较多(冷启动明显)
|
||||
3. `pickRelatedExecutables` 仍是最重路径(目录遍历/评分/stat)
|
||||
4. 本地快捷方式扫描仍递归
|
||||
5. `collectExePaths` 迭代队列当前 worker 并发度偏低(常为 1)
|
||||
|
||||
## 9.3 稳定性风险点
|
||||
|
||||
- 大量 `catch { return null }` 吞错,问题定位依赖人工加日志
|
||||
- 缺少取消机制,重复触发扫描会叠加资源占用
|
||||
- 平台关键词策略(EA/BN/WeGame)易受安装路径命名差异影响
|
||||
|
||||
## 10. 功能增删时的改动清单
|
||||
|
||||
## 10.1 新增平台 scanner
|
||||
|
||||
建议步骤:
|
||||
|
||||
1. 新建 `electron/services/scanners/<platform>.ts`
|
||||
2. 复用 `pickRelatedExecutables + pickGameIconDataUrl`
|
||||
3. 在 `game-scanner.ts` 注册扫描器
|
||||
4. 同步 `Platform`(`types.ts`)与前端 `PLATFORMS`(`src/constants/index.ts`)
|
||||
5. 增加至少 2 类测试:命中样例 + 误报防护
|
||||
|
||||
## 10.2 移除平台 scanner
|
||||
|
||||
必须同步:
|
||||
|
||||
1. `game-scanner.ts` scanners 列表
|
||||
2. `Platform` 类型
|
||||
3. 前端 `PLATFORMS`(避免继续自动建分类)
|
||||
4. 文案与 UI 过滤逻辑(如有)
|
||||
|
||||
## 10.3 改扫描结果字段
|
||||
|
||||
高风险字段:
|
||||
|
||||
- `processName`: 影响入库 + proxy-monitor + sing-box
|
||||
- `source`: 影响分类与 UI
|
||||
- `installDir`: 影响全局去重正确性
|
||||
|
||||
## 11. 你做优化前建议先定的决策
|
||||
|
||||
建议先回答这 5 个问题,再动代码:
|
||||
|
||||
1. 目标是“首扫更快”还是“重复扫描更快”?
|
||||
2. 可接受的漏报率上限是多少?
|
||||
3. 是否允许增加缓存(盘符/图标/平台查询结果)?
|
||||
4. 是否需要中断扫描(Abort)?
|
||||
5. 优先优化哪个平台(Steam 还是 Microsoft/EA/BN)?
|
||||
|
||||
这会直接决定是做:
|
||||
|
||||
- 扫描算法优化
|
||||
- 数据源策略优化
|
||||
- 缓存策略优化
|
||||
- 架构改造(平台插件化)
|
||||
|
||||
## 12. 测试覆盖现状
|
||||
|
||||
当前与扫描强相关的单测集中在 `tests/unit`:
|
||||
|
||||
- `scanner-utils.spec.ts`
|
||||
- 大目录根 exe 不丢失
|
||||
- 深度边界正确
|
||||
- 明显误报 exe 被过滤
|
||||
- `steam-scanner.spec.ts`
|
||||
- 深层目录 exe 可被选出
|
||||
|
||||
缺口:
|
||||
|
||||
1. 平台扫描器端到端测试基本没有
|
||||
2. IPC 进度事件协议无测试
|
||||
3. 去重冲突策略无回归测试
|
||||
4. 性能基准测试无自动化
|
||||
|
||||
## 13. 给你的落地建议(按顺序)
|
||||
|
||||
1. 先做“观测”:加每平台耗时/命中率/失败率日志
|
||||
2. 修 `collectExePaths` 并发度(当前常为 1 的问题)
|
||||
3. 再决定是否上缓存(你现在倾向不用缓存,可先不加)
|
||||
4. 把 `local.ts` 的递归 `collectLnks` 迭代化(与 utils 保持一致)
|
||||
5. 最后再做平台适配器抽象,降低后续功能增删成本
|
||||
|
||||
---
|
||||
|
||||
如果你下一步要开始“功能增删”,推荐先从 `flat.ts + game-scanner.ts` 的接口层入手,先稳住契约,再改各平台内部策略。
|
||||
|
||||
## 14. 附录:核心控制常量详解 (utils.ts)
|
||||
|
||||
这部分解释了 `electron/services/scanners/utils.ts` 中的核心常量,这些常量直接决定了整个游戏扫描器的性能(CPU/IO占用)、准确率和极端情况下的兜底保护行为。如果要修改这些值,请务必参考以下实例。
|
||||
|
||||
### I/O 与并发控制
|
||||
|
||||
#### `UV_THREADPOOL_SIZE`
|
||||
|
||||
- **当前设置**: `String(Math.max(4, os.cpus().length * 2))`
|
||||
- **含义**: Node.js 底层 libuv 异步 I/O 线程池大小。
|
||||
- **影响与示例**:
|
||||
- **调大**(例如 `os.cpus().length * 4`):如果在 NVMe 固态硬盘上,可以并发读取更多文件状态,大幅缩短全盘探测或高深度目录的扫描时间;但在机械硬盘(HDD)上,过高的并发会导致磁头频繁寻道,反而会让扫描龟速进行。
|
||||
- **调小**(例如固定为 `4`,Node默认值):对于深层文件夹,文件系统的 API (`fs.stat`, `fs.readdir`) 会因为排队严重饥饿,扫描整个机器所需时间呈倍数级增长。
|
||||
|
||||
#### `DEFAULT_IO_CONCURRENCY`
|
||||
|
||||
- **当前设置**: `Math.max(4, Math.min(16, os.cpus().length))`
|
||||
- **含义**: 基于 Worker 队列的任务并发度(常用于目录级别的异步 map 操作),最高卡在 16。
|
||||
- **影响与示例**:
|
||||
- **调大**(例如 `32`):理论上瞬间能并行处理更多的子目录解析,但如果配合前面的 `UV_THREADPOOL_SIZE` 扩大,极易造成瞬间内存峰值(堆积大量 pending I/O 操作),导致 Electron 进程内存爆炸被系统 kill。
|
||||
- **调小**(例如 `2`):安全但慢。每轮只探测两个目录,适合极其脆弱的存储设备,但整体扫描会肉眼可见地拖沓。
|
||||
|
||||
#### `DRIVE_DETECT_CONCURRENCY`
|
||||
|
||||
- **当前设置**: `8`
|
||||
- **含义**: 找根目录时,向系统探测 `C:` ~ `Z:` 盘符并发请求数。
|
||||
- **影响与示例**:
|
||||
- **调大**(例如 `24`):会同时试图访问 A-Z 全部的盘符。在一些老旧机器或者带有“休眠的外接机械硬盘/局域网断网的网络映射盘”上,会导致防病毒软件频繁拦截扫描,或者引发较长时间的操作系 I/O 卡死休眠。
|
||||
- **调小**(例如 `1`):必须等 `C:` 响应完才能访问 `D:`。如果 `D:` 恰好是一个断连的网络盘(Windows可能要等 15 秒才抛错),那扫描器的最初启动就会白白浪费这 15 秒甚至更多,用户体验极差。
|
||||
|
||||
### 启发式执行文件探测 (`pickRelatedExecutables`)
|
||||
|
||||
#### `PICK_EXE_DEPTH_STAGES`
|
||||
|
||||
- **当前设置**: `[2, 4, 6]`
|
||||
- **含义**: 渐进式的目录探索深度。第一次在深度 2 找,没有或不满意再到 4 找,再没有就深入到 6。这是因为游戏主程序总是相对较浅,而插件、日志、引擎附属文件往往很深。
|
||||
- **影响与示例**:
|
||||
- **增加**(例如 `[2, 5, 8]`):如果一个游戏的目录极大,扫到第 8 层,文件数量会呈指数级爆炸。不但消耗大量算力和耗时,还容易把位于 `Game/Engine/Plugins/Media/ThirdParty/bin/test.exe` 这种无关甚至冲突的 exe 给翻出来。
|
||||
- **减少**(例如 `[2, 3]`):扫描会变得极度敏捷,但这绝对会导致部分现代 3A 大作(常常嵌套很深,如 `[安装盘]/SteamLibrary/steamapps/common/GameName/EngineName/Binaries/Win64/game.exe` 往往涉及 4~6 层深度)的主执行文件被彻底遗漏,因为深度不够而提早放弃了。
|
||||
|
||||
#### `PICK_EXE_STAT_SAMPLE_LIMIT`
|
||||
|
||||
- **当前设置**: `40`
|
||||
- **含义**: 如果浅层过滤出来的“疑似主程序 exe”非常多,我们最多只取前 40 个去做真正的 `fs.stat`(用来取文件大小补分),超出的直接放弃处理。
|
||||
- **影响与示例**:
|
||||
- **调大**(例如 `200`):假设遇到了一个包含了成百上千小工具的 SDK 目录,这会产生 200 次真实的文件系统 `stat` 请求。速度会被拖慢。
|
||||
- **调小**(例如 `5`):假设有 10 个看似匹配的 exe(包含各种 debug版、shipping版),由于限制,部分主程序在按字符串排序时落在了第 6 位,导致直接没有进入 `stat` 阶段没法获得“大文件加分”,最终挑出了一个 50KB 的 `Game_crash_reporter.exe` 而丢掉了 100MB 的 `Game.exe`。
|
||||
|
||||
#### `PICK_EXE_EARLY_ACCEPT_SCORE` 与 `PICK_EXE_EARLY_ACCEPT_COUNT`
|
||||
|
||||
- **当前设置**: 积分 `8`,数量 `4`
|
||||
- **含义**: “见好就收”机制。如果目前深度已经发现至少 4 个得分 >= 8,立刻终止后续深度的进一步探索。这样可以迅速跳过已明确找到目标的游戏。
|
||||
- **影响与示例**:
|
||||
- **调高阈值**(如分数为 `15` 或 数量 `10`):基本上永远也触发不到这个提前结束机制。扫描器会对每个游戏文件夹都会死磕到最大深度 `6`,消耗的时间将是当前的几倍。
|
||||
- **调低阈值**(如分数 `4`,数量 `1`):只要在根目录遇到随便一个满足低分阈值(比如名叫 `Launcher.exe` 积分刚好4分),它就停止进入真实的 `Binaries` 文件夹寻找 `Game.exe`。误报率会极速上升,捡了芝麻丢了西瓜。
|
||||
|
||||
#### `PICK_EXE_DEEP_FALLBACK_DEPTH`
|
||||
|
||||
- **当前设置**: `6`
|
||||
- **含义**: 深度退避限制的兜底。防止意外或者递归逃逸死循环跑得过深。
|
||||
- **影响与示例**:
|
||||
- 和 `PICK_EXE_DEPTH_STAGES` 对应,如果将其放宽,碰到畸形的套娃快捷方式或无限重定向的符号链接目录结构时,可能导致系统崩溃或爆栈。
|
||||
|
||||
### 大目录保护机制
|
||||
|
||||
#### `LARGE_DIR_ENTRY_THRESHOLD`
|
||||
|
||||
- **当前设置**: `3000`
|
||||
- **含义**: 当某个目录下的直接文件+子目录条目数大于 3000 时,判定该目录过于庞杂(比如游戏截图堆、模型材质堆),进而启动子目录修剪防范机制。
|
||||
- **影响与示例**:
|
||||
- **调大**(例如 `10000`):对包含海量零散碎文件(比如《Garry's Mod》大量的 addons)的复杂软件放行会把队列堵死,导致一个游戏的耗时能占平时的 90%。
|
||||
- **调小**(例如 `200`):非常容易导致误伤。比如一个普通的网游带有 200多个补丁文件在根目录,瞬间判定为“过于庞杂目录”,触发“放弃大部分子路径”的硬修剪,导致漏扫。
|
||||
|
||||
#### `LARGE_DIR_SUBDIR_SCAN_LIMIT`
|
||||
|
||||
- **当前设置**: `120`
|
||||
- **含义**: 如果触发了上述 `3000` 的极度庞杂限制,该目录将被保护:只挑前 120 个具有潜力的子目录继续向下探索,其它所有的多余子目录原地绝育。
|
||||
- **影响与示例**:
|
||||
- **调大**(例如 `1000`):大目录修剪保护形同虚设,还是容易被一万个子文件夹打爆内存堆积。
|
||||
- **调小**(例如 `10`):修剪得过于激进。如果真实的 `Binaries` 文件夹因为按字母排(或者没有包含在高优关键词里)排在第 15 个,那么这块游戏引擎就直接无了,扫描变瞎子。
|
||||
|
||||
#### `LARGE_DIR_PRIORITY_KEYWORDS`
|
||||
|
||||
- **当前设置**: `['bin', 'binaries', 'win64', 'win32', 'x64', 'x86', 'game', 'client', 'release', 'shipping', 'launcher']`
|
||||
- **含义**: 配合 `LARGE_DIR_SUBDIR_SCAN_LIMIT` 用的“免死金牌”。大目录下挑出来的 120 个名额里,包含这些关键词的文件夹拥有插队绝对优先权。
|
||||
- **影响与示例**:
|
||||
- **增加**(例如加上 `build` 或 `out`):能让一些特殊的独立游戏或带有开发版本的客户端,在大目录修剪时依然活下来,提高对某些特别打包结构的识别精度。
|
||||
- **减少**(例如删掉 `win32`或`x86`):那些仍然在使用十年前引擎的老游戏,一旦其目录变得庞大(累计了几千个文件)并触发了大目录保护,其 `win32` 目录可能会被随机丢弃,从而彻底丧失被扫描到的机会。
|
||||
+74
-9
@@ -1,4 +1,4 @@
|
||||
import { app, BrowserWindow, ipcMain, dialog, shell, session } from 'electron'
|
||||
import { app, BrowserWindow, ipcMain, dialog, shell, session, crashReporter } from 'electron'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import fs from 'fs-extra'
|
||||
@@ -130,7 +130,24 @@ function configureChromiumLogging() {
|
||||
}
|
||||
}
|
||||
|
||||
function setupCrashReporter() {
|
||||
try {
|
||||
crashReporter.start({
|
||||
productName: pkg.productName || pkg.name,
|
||||
companyName: pkg.name,
|
||||
submitURL: 'https://lagzero.invalid/crash',
|
||||
uploadToServer: false,
|
||||
compress: true,
|
||||
ignoreSystemCrashHandler: false
|
||||
})
|
||||
console.info(`[Main] CrashReporter 已启用,dump 目录: ${app.getPath('crashDumps')}`)
|
||||
} catch (e) {
|
||||
console.warn('[Main] 启用 CrashReporter 失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
configureChromiumLogging()
|
||||
setupCrashReporter()
|
||||
|
||||
// 初始化日志系统
|
||||
setupLogger(() => windowManager.get())
|
||||
@@ -331,6 +348,20 @@ function relaunchAppSafely(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close app without relaunch
|
||||
* @returns whether close was triggered
|
||||
*/
|
||||
function closeAppSafely(): boolean {
|
||||
try {
|
||||
app.quit()
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('[Main] Failed to close app:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化所有业务服务并注册 IPC 监听
|
||||
* @param win 主窗口实例
|
||||
@@ -354,14 +385,30 @@ function initServices(win: BrowserWindow) {
|
||||
|
||||
// 注册扫描服务 IPC
|
||||
ipcMain.handle('system:scan-local-games', async (event) => {
|
||||
const startedAt = Date.now()
|
||||
let lastScanDirProgressAt = 0
|
||||
let lastScanDir = ''
|
||||
console.info('[GameScan] 收到扫描请求')
|
||||
try {
|
||||
return await gameScannerService?.scanLocalGamesFromPlatforms((status, details) => {
|
||||
if (!event.sender.isDestroyed()) {
|
||||
event.sender.send('system:scan-progress', { status, details })
|
||||
const result = await gameScannerService?.scanLocalGamesFromPlatforms((status, details) => {
|
||||
if (event.sender.isDestroyed()) return
|
||||
|
||||
if (status === 'scanning_dir') {
|
||||
const now = Date.now()
|
||||
const dir = String(details || '')
|
||||
// 高频目录进度做节流,避免 IPC 风暴拖慢扫描。
|
||||
if (dir === lastScanDir && now - lastScanDirProgressAt < 1000) return
|
||||
if (now - lastScanDirProgressAt < 120) return
|
||||
lastScanDirProgressAt = now
|
||||
lastScanDir = dir
|
||||
}
|
||||
|
||||
event.sender.send('system:scan-progress', { status, details })
|
||||
}) || []
|
||||
console.info(`[GameScan] 扫描请求完成 | 返回=${result.length} | 耗时=${Date.now() - startedAt}ms`)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('[GameScan] 扫描本地游戏失败:', error)
|
||||
console.error(`[GameScan] 扫描本地游戏失败 | 耗时=${Date.now() - startedAt}ms`, error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
@@ -390,8 +437,8 @@ function initServices(win: BrowserWindow) {
|
||||
})
|
||||
ipcMain.handle('app:restart', async () => {
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
console.info('[Main] 开发模式下跳过应用重启。')
|
||||
return false
|
||||
console.info('[Main] Dev mode restart request: close app without relaunch.')
|
||||
return closeAppSafely()
|
||||
}
|
||||
return relaunchAppSafely()
|
||||
})
|
||||
@@ -434,6 +481,11 @@ function initServices(win: BrowserWindow) {
|
||||
console.warn('[Main] 写入重置标记失败:', e)
|
||||
}
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
console.info('[Main] Dev mode reset request: close app after cleanup.')
|
||||
return closeAppSafely()
|
||||
}
|
||||
|
||||
return relaunchAppSafely()
|
||||
})
|
||||
|
||||
@@ -547,8 +599,19 @@ app.whenReady()
|
||||
|
||||
// 全局未捕获异常处理
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
const message = String(reason)
|
||||
console.error('[Main] 未捕获的 Promise 拒绝:', message)
|
||||
console.error('[Main] 未捕获的 Promise 拒绝:', reason)
|
||||
})
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('[Main] 未捕获异常:', error)
|
||||
})
|
||||
|
||||
app.on('render-process-gone', (_event, _webContents, details) => {
|
||||
console.error('[Main] 渲染进程退出:', details)
|
||||
})
|
||||
|
||||
app.on('child-process-gone', (_event, details) => {
|
||||
console.error('[Main] 子进程退出:', details)
|
||||
})
|
||||
|
||||
// 应用退出前清理
|
||||
@@ -573,3 +636,5 @@ app.on('before-quit', (event) => {
|
||||
app.exit(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+41
-3
@@ -318,6 +318,28 @@ function flushPendingLogLines() {
|
||||
})
|
||||
}
|
||||
|
||||
function flushPendingLogLinesSync() {
|
||||
if (logFlushTimer) {
|
||||
clearTimeout(logFlushTimer)
|
||||
logFlushTimer = null
|
||||
}
|
||||
if (pendingLogLines.length === 0) return
|
||||
|
||||
const chunk = pendingLogLines.join('')
|
||||
pendingLogLines = []
|
||||
const logFilePath = getAppLogFilePath()
|
||||
|
||||
try {
|
||||
fs.ensureDirSync(path.dirname(logFilePath))
|
||||
fs.appendFileSync(logFilePath, chunk, 'utf8')
|
||||
const bytes = Buffer.byteLength(chunk, 'utf8')
|
||||
sessionLogFileSizeBytes += bytes
|
||||
sessionLogFileSizeKnown = true
|
||||
} catch {
|
||||
// ignore synchronous flush failures
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleLogFlush(immediate = false) {
|
||||
if (logFlushTimer) return
|
||||
logFlushTimer = setTimeout(() => {
|
||||
@@ -327,6 +349,12 @@ function scheduleLogFlush(immediate = false) {
|
||||
|
||||
function appendLogToFile(row: AppLogEntry) {
|
||||
pendingLogLines.push(formatLogLine(row))
|
||||
|
||||
// 错误级日志优先立即落盘,减少闪退时日志丢失。
|
||||
if (row.level === 'error') {
|
||||
scheduleLogFlush(true)
|
||||
return
|
||||
}
|
||||
if (pendingLogLines.length >= LOG_WRITE_FLUSH_MAX_LINES) {
|
||||
scheduleLogFlush(true)
|
||||
return
|
||||
@@ -419,9 +447,16 @@ export function setupLogger(getWin: () => BrowserWindow | null) {
|
||||
install('log', 'info')
|
||||
|
||||
queueLogWrite(writeStartupSection)
|
||||
const flushNow = () => flushPendingLogLines()
|
||||
app.on('before-quit', flushNow)
|
||||
process.on('exit', flushNow)
|
||||
const flushAsync = () => flushPendingLogLines()
|
||||
const flushSync = () => flushPendingLogLinesSync()
|
||||
app.on('before-quit', flushSync)
|
||||
process.on('beforeExit', flushSync)
|
||||
process.on('exit', flushSync)
|
||||
process.on('uncaughtExceptionMonitor', flushSync)
|
||||
process.on('SIGINT', flushSync)
|
||||
process.on('SIGTERM', flushSync)
|
||||
// 正常运行阶段仍保留异步刷盘,降低同步 I/O 频率。
|
||||
setInterval(flushAsync, Math.max(LOG_WRITE_FLUSH_INTERVAL_MS, 500)).unref()
|
||||
|
||||
pushAppLog({
|
||||
level: 'info',
|
||||
@@ -467,3 +502,6 @@ export function setupLogger(getWin: () => BrowserWindow | null) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -59,7 +59,8 @@ export class WindowManager {
|
||||
contextIsolation: true,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false,
|
||||
devTools: Boolean(VITE_DEV_SERVER_URL),
|
||||
// Keep DevTools available in packaged builds; UI action controls when to open it.
|
||||
devTools: true,
|
||||
spellcheck: false,
|
||||
backgroundThrottling: true,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import { LocalGameScanResult, ScanProgressCallback } from './scanners/types'
|
||||
import { mapWithConcurrency } from './scanners/utils'
|
||||
import { dedupeProcessNames, mapWithConcurrency, normalizeFsPath } from './scanners/utils'
|
||||
import { scanSteamGames } from './scanners/steam'
|
||||
import { scanMicrosoftGames } from './scanners/microsoft'
|
||||
import { scanEpicGames } from './scanners/epic'
|
||||
@@ -12,9 +12,6 @@ import { scanLocalShortcuts } from './scanners/local'
|
||||
|
||||
/**
|
||||
* 游戏扫描服务
|
||||
*
|
||||
* 负责扫描本地已安装的游戏,目前支持 Windows 平台。
|
||||
* 聚合了 Steam, Microsoft Store (Xbox), Epic Games, EA App 等多个平台的扫描逻辑。
|
||||
*/
|
||||
export class GameScannerService {
|
||||
/**
|
||||
@@ -24,35 +21,78 @@ export class GameScannerService {
|
||||
async scanLocalGamesFromPlatforms(progressCallback?: ScanProgressCallback): Promise<LocalGameScanResult[]> {
|
||||
if (process.platform !== 'win32') return []
|
||||
|
||||
const scanners = [
|
||||
scanSteamGames,
|
||||
scanMicrosoftGames,
|
||||
scanEpicGames,
|
||||
scanEAGames,
|
||||
scanBattleNetGames,
|
||||
scanWeGameGames,
|
||||
scanLocalShortcuts
|
||||
const scanners: Array<{ name: string, run: (progressCallback?: ScanProgressCallback) => Promise<LocalGameScanResult[]> }> = [
|
||||
{ name: 'Steam', run: scanSteamGames },
|
||||
{ name: 'Microsoft', run: scanMicrosoftGames },
|
||||
{ name: 'Epic', run: scanEpicGames },
|
||||
{ name: 'EA', run: scanEAGames },
|
||||
{ name: 'BattleNet', run: scanBattleNetGames },
|
||||
{ name: 'WeGame', run: scanWeGameGames },
|
||||
{ name: 'Local', run: scanLocalShortcuts }
|
||||
]
|
||||
const platformResults = await mapWithConcurrency(scanners, async (scan) => scan(progressCallback), 3)
|
||||
const [steam, microsoft, epic, ea, battlenet, wegame, local] = platformResults
|
||||
|
||||
// 去重逻辑:名称和进程名列表都相同的视为同一个游戏
|
||||
const dedup = new Map<string, LocalGameScanResult>()
|
||||
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)
|
||||
console.info(`[GameScan] 开始扫描平台,任务数=${scanners.length}`)
|
||||
|
||||
const platformResults = await mapWithConcurrency(scanners, async ({ name, run }) => {
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
console.info(`[GameScan] 平台扫描开始: ${name}`)
|
||||
const result = await run(progressCallback)
|
||||
const costMs = Date.now() - startedAt
|
||||
console.info(`[GameScan] 平台扫描完成: ${name} | 命中=${result.length} | 耗时=${costMs}ms`)
|
||||
return result
|
||||
} catch (error) {
|
||||
const costMs = Date.now() - startedAt
|
||||
console.error(`[GameScan] 平台扫描失败: ${name} | 耗时=${costMs}ms`, error)
|
||||
return []
|
||||
}
|
||||
}, 4)
|
||||
|
||||
const allResults = platformResults.flat()
|
||||
const rawTotal = allResults.length
|
||||
|
||||
const byInstallDir = new Map<string, LocalGameScanResult>()
|
||||
const fallback = new Map<string, LocalGameScanResult>()
|
||||
|
||||
for (const game of allResults) {
|
||||
const processNames = dedupeProcessNames(Array.isArray(game.processName) ? game.processName : [])
|
||||
const normalized: LocalGameScanResult = {
|
||||
...game,
|
||||
processName: processNames
|
||||
}
|
||||
|
||||
const installDirKey = normalizeFsPath(normalized.installDir)
|
||||
if (installDirKey) {
|
||||
const prev = byInstallDir.get(installDirKey)
|
||||
if (!prev) {
|
||||
byInstallDir.set(installDirKey, normalized)
|
||||
} else {
|
||||
byInstallDir.set(installDirKey, {
|
||||
...prev,
|
||||
name: prev.name.length >= normalized.name.length ? prev.name : normalized.name,
|
||||
processName: dedupeProcessNames([...prev.processName, ...normalized.processName]),
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const processKey = processNames.slice().sort((a, b) => a.localeCompare(b)).join(',').toLowerCase()
|
||||
const key = `${normalized.name.toLowerCase()}|${processKey}`
|
||||
if (!fallback.has(key)) fallback.set(key, normalized)
|
||||
}
|
||||
|
||||
return Array.from(dedup.values())
|
||||
const deduped = [...byInstallDir.values(), ...fallback.values()]
|
||||
console.info(`[GameScan] 扫描汇总完成 | 原始=${rawTotal} | 去重后=${deduped.length} | installDir键=${byInstallDir.size} | fallback键=${fallback.size}`)
|
||||
|
||||
return deduped
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描指定目录下的可执行文件
|
||||
* @param dir 目标目录
|
||||
* @param maxDepth 最大递归深度 (-1 为无限)
|
||||
* @param currentDepth 当前深度 (内部使用)
|
||||
* @returns 找到的 .exe 文件名列表
|
||||
* @param dir 要扫描的起始目录
|
||||
* @param maxDepth 最大扫描深度(-1表示无限深度)
|
||||
* @param currentDepth 内部递归使用的当前深度,默认从1开始
|
||||
* @returns 扫描到的 .exe 文件相对当前调用层的名称或相对子路径名称列表
|
||||
*/
|
||||
async scanDir(dir: string, maxDepth: number, currentDepth: number = 1): Promise<string[]> {
|
||||
let results: string[] = []
|
||||
@@ -74,3 +114,4 @@ export class GameScannerService {
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,193 @@
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import { LocalGameScanResult, ScanProgressCallback } from './types'
|
||||
import { getWindowsDriveRoots, normalizeFsPath } from './utils'
|
||||
import { scanFlatPlatformFolder } from './flat'
|
||||
import { dedupeProcessNames, getWindowsDriveRoots, mapWithConcurrency, normalizeDisplayName, normalizeFsPath, parsePowershellJson, pickRelatedExecutables, safeReadDir } from './utils'
|
||||
import { runCommand } from '../../utils/command'
|
||||
|
||||
/**
|
||||
* 战网安装提示项结构
|
||||
*/
|
||||
type RegistryHint = {
|
||||
DisplayName?: string
|
||||
InstallLocation?: string
|
||||
}
|
||||
|
||||
const BNET_LAUNCHER_DIR_KEYWORDS = ['battle.net', 'agent', 'launcher', 'updater', 'blizzard browser']
|
||||
const BNET_NAME_HINTS = [
|
||||
'world of warcraft',
|
||||
'warcraft',
|
||||
'overwatch',
|
||||
'diablo',
|
||||
'starcraft',
|
||||
'hearthstone',
|
||||
'heroes of the storm',
|
||||
'call of duty'
|
||||
]
|
||||
const BNET_FALLBACK_SKIP_DIR_NAMES = new Set([
|
||||
'common files',
|
||||
'modifiableswindowsapps',
|
||||
'windowsapps',
|
||||
'windows nt',
|
||||
'internet explorer',
|
||||
'package cache'
|
||||
])
|
||||
const BNET_FALLBACK_DIR_CHECK_CONCURRENCY = 12
|
||||
|
||||
/**
|
||||
* 判断指定名称或安装目录是否为战网启动器本身
|
||||
* @param name 游戏名称(或上层文件夹名)
|
||||
* @param installDir 安装目录路径
|
||||
* @returns 判断结果
|
||||
*/
|
||||
function isBattleNetLauncher(name: string, installDir: string) {
|
||||
const lowerName = String(name || '').toLowerCase()
|
||||
const lowerDir = String(installDir || '').toLowerCase()
|
||||
return BNET_LAUNCHER_DIR_KEYWORDS.some(k => lowerName.includes(k) || lowerDir.includes(`\\${k}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* 从系统注册表获取已安装的暴雪/战网游戏信息
|
||||
* @returns 提取到的安装目录及名称线索数组
|
||||
*/
|
||||
async function getBattleNetRegistryHints(): Promise<Array<{ name: string, installDir: string }>> {
|
||||
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)Blizzard|Activision') -or ($_.DisplayName -match '(?i)Battle\\.net|Blizzard|Diablo|Overwatch|Warcraft|Hearthstone|StarCraft|Call\\s*of\\s*Duty')) } |",
|
||||
" Select-Object DisplayName, InstallLocation",
|
||||
"}",
|
||||
"$items | ConvertTo-Json -Compress"
|
||||
].join('; ')
|
||||
|
||||
const { code, output } = await runCommand('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], 9000)
|
||||
if (code !== 0 || !output) return []
|
||||
|
||||
return parsePowershellJson<RegistryHint>(output)
|
||||
.map(row => ({
|
||||
name: String(row.DisplayName || '').trim(),
|
||||
installDir: path.normalize(String(row.InstallLocation || '').trim())
|
||||
}))
|
||||
.filter(row => row.name && row.installDir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据特征文件和文件夹命名探测是否符合战网游戏特征
|
||||
* @param installDir 探测目录
|
||||
* @returns 判断结果
|
||||
*/
|
||||
async function looksLikeBattleNetGameDir(installDir: string) {
|
||||
if (!await fs.pathExists(installDir)) return false
|
||||
const lower = path.basename(installDir).toLowerCase()
|
||||
if (isBattleNetLauncher(lower, installDir)) return false
|
||||
|
||||
const buildInfo = path.join(installDir, '.build.info')
|
||||
if (await fs.pathExists(buildInfo)) return true
|
||||
|
||||
if (BNET_NAME_HINTS.some(k => lower.includes(k))) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 对一条战网游戏线索做执行文件深度探查与组装
|
||||
* @param name 游戏标称
|
||||
* @param installDir 指定目录
|
||||
* @param progressCallback 进度反馈回调
|
||||
* @returns 返回标准化结构结果,丢弃失败或不符条件的
|
||||
*/
|
||||
async function scanBattleNetHint(name: string, installDir: string, progressCallback?: ScanProgressCallback): Promise<LocalGameScanResult | null> {
|
||||
if (isBattleNetLauncher(name, installDir)) return null
|
||||
if (!await looksLikeBattleNetGameDir(installDir)) return null
|
||||
|
||||
progressCallback?.('scanning_dir', installDir)
|
||||
const displayName = normalizeDisplayName(name || path.basename(installDir))
|
||||
const exes = await pickRelatedExecutables(installDir, displayName, progressCallback)
|
||||
if (!exes || exes.length === 0) return null
|
||||
|
||||
return {
|
||||
name: displayName,
|
||||
processName: dedupeProcessNames(exes.map(e => path.basename(e))),
|
||||
source: 'BattleNet',
|
||||
installDir
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描战网 Battle.net 游戏
|
||||
*/
|
||||
export async function scanBattleNetGames(progressCallback?: ScanProgressCallback): Promise<LocalGameScanResult[]> {
|
||||
progressCallback?.('scanning_platform', 'BattleNet')
|
||||
const results: LocalGameScanResult[] = []
|
||||
const dedup = new Map<string, LocalGameScanResult>()
|
||||
progressCallback?.('scanning_platform', 'BattleNet')
|
||||
const dedup = new Map<string, LocalGameScanResult>()
|
||||
|
||||
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')
|
||||
])))
|
||||
const registryHints = await getBattleNetRegistryHints()
|
||||
const scannedRegistry = await mapWithConcurrency(registryHints, async (hint) => {
|
||||
return await scanBattleNetHint(hint.name, hint.installDir, progressCallback)
|
||||
}, 6)
|
||||
|
||||
// 由于战网游戏通常直接放在 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 scannedRegistry) {
|
||||
if (!game) continue
|
||||
const key = normalizeFsPath(game.installDir)
|
||||
if (!dedup.has(key)) dedup.set(key, game)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
// 兜底:在常见根目录扫描一级可疑目录,不再截断前 N 项。
|
||||
const drives = await getWindowsDriveRoots()
|
||||
const candidateRoots = Array.from(new Set(drives.flatMap(root => [
|
||||
path.join(root, 'Games'),
|
||||
path.join(root, 'Game'),
|
||||
path.join(root, 'Program Files'),
|
||||
path.join(root, 'Program Files (x86)')
|
||||
])))
|
||||
|
||||
const fallbackBatches = await mapWithConcurrency(candidateRoots, async (root) => {
|
||||
if (!await fs.pathExists(root)) return []
|
||||
progressCallback?.('scanning_dir', root)
|
||||
|
||||
const entries = await safeReadDir(root)
|
||||
const dirs = entries.filter(e => e.isDirectory())
|
||||
|
||||
const candidates = await mapWithConcurrency(dirs, async (entry) => {
|
||||
const installDir = path.join(root, entry.name)
|
||||
const lower = entry.name.toLowerCase()
|
||||
if (isBattleNetLauncher(lower, installDir)) return null
|
||||
if (BNET_FALLBACK_SKIP_DIR_NAMES.has(lower)) return null
|
||||
if (BNET_NAME_HINTS.some(k => lower.includes(k))) {
|
||||
return { name: entry.name, installDir }
|
||||
}
|
||||
|
||||
if (await fs.pathExists(path.join(installDir, '.build.info'))) {
|
||||
return { name: entry.name, installDir }
|
||||
}
|
||||
|
||||
return null
|
||||
}, BNET_FALLBACK_DIR_CHECK_CONCURRENCY)
|
||||
|
||||
const scanned = await mapWithConcurrency(candidates.filter(Boolean) as Array<{ name: string, installDir: string }>, async (hint) => {
|
||||
return await scanBattleNetHint(hint.name, hint.installDir, progressCallback)
|
||||
}, 4)
|
||||
|
||||
return scanned.filter(Boolean) as LocalGameScanResult[]
|
||||
}, 3)
|
||||
|
||||
for (const batch of fallbackBatches) {
|
||||
for (const game of batch) {
|
||||
const key = normalizeFsPath(game.installDir)
|
||||
if (!dedup.has(key)) dedup.set(key, game)
|
||||
}
|
||||
}
|
||||
|
||||
results.push(...dedup.values())
|
||||
return results
|
||||
return Array.from(dedup.values())
|
||||
}
|
||||
|
||||
|
||||
+157
-133
@@ -1,94 +1,136 @@
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import { LocalGameScanResult, ScanProgressCallback } from './types'
|
||||
import { getWindowsDriveRoots, normalizeFsPath } from './utils'
|
||||
import { dedupeProcessNames, getWindowsDriveRoots, mapWithConcurrency, normalizeDisplayName, normalizeFsPath, parsePowershellJson, pickRelatedExecutables } 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']
|
||||
const EA_FLAT_IGNORE_DIRS = new Set(['ea app', 'ea desktop', 'origin', 'installer', 'installers', 'cache'])
|
||||
|
||||
/**
|
||||
* 注册表中 EA 游戏的展示及路径格式
|
||||
*/
|
||||
type EARegistryRow = {
|
||||
DisplayName?: string
|
||||
InstallLocation?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* EA 游戏的临时搜索线索
|
||||
*/
|
||||
type EAGameHint = {
|
||||
name: string
|
||||
installDir: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断此文件夹关联是否是 EA/Origin 客户端本体
|
||||
* @param name 应用名
|
||||
* @param installDir 安装位置
|
||||
* @returns 判断结果
|
||||
*/
|
||||
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))
|
||||
const lowerName = String(name || '').toLowerCase()
|
||||
const lowerDir = String(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<string[]> {
|
||||
const dirs: string[] = []
|
||||
if (process.platform !== 'win32') return dirs
|
||||
if (process.platform !== 'win32') return []
|
||||
|
||||
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))
|
||||
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'
|
||||
].join('; ')
|
||||
|
||||
const { code, output } = await runCommand('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], 7000)
|
||||
if (code !== 0 || !output) return []
|
||||
|
||||
const rows = parsePowershellJson<string>(output)
|
||||
return rows
|
||||
.map(v => path.normalize(String(v || '').trim()))
|
||||
.filter(Boolean)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从卸载注册表读取 EA 游戏安装目录与名称
|
||||
*/
|
||||
async function getEAUninstallHints(): Promise<Array<{ name: string, installDir: string }>> {
|
||||
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 []
|
||||
}
|
||||
async function getEAUninstallHints(): Promise<EAGameHint[]> {
|
||||
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], 9000)
|
||||
if (code !== 0 || !output) return []
|
||||
|
||||
return parsePowershellJson<EARegistryRow>(output)
|
||||
.map(row => ({
|
||||
name: String(row.DisplayName || '').trim(),
|
||||
installDir: path.normalize(String(row.InstallLocation || '').trim())
|
||||
}))
|
||||
.filter(row => row.name && row.installDir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 特定线索位置深层扫描 EA 进程配置
|
||||
* @param hint 游戏定位线索
|
||||
* @param progressCallback 进度通知
|
||||
* @returns 结果或空
|
||||
*/
|
||||
async function scanEAHintInstall(hint: EAGameHint, progressCallback?: ScanProgressCallback): Promise<LocalGameScanResult | null> {
|
||||
if (isEALauncherEntry(hint.name, hint.installDir)) return null
|
||||
if (!await fs.pathExists(hint.installDir)) return null
|
||||
|
||||
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) return null
|
||||
|
||||
return {
|
||||
name: displayName,
|
||||
processName: dedupeProcessNames(exes.map(e => path.basename(e))),
|
||||
source: 'EA',
|
||||
installDir: hint.installDir
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,72 +138,54 @@ async function getEAUninstallHints(): Promise<Array<{ name: string, installDir:
|
||||
* 通过读取注册表以及扫描常见目录来发现 EA 游戏
|
||||
*/
|
||||
export async function scanEAGames(progressCallback?: ScanProgressCallback): Promise<LocalGameScanResult[]> {
|
||||
progressCallback?.('scanning_platform', 'EA')
|
||||
const results: LocalGameScanResult[] = []
|
||||
const dedup = new Map<string, LocalGameScanResult>()
|
||||
progressCallback?.('scanning_platform', 'EA')
|
||||
const dedup = new Map<string, LocalGameScanResult>()
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
const [regDirs, uninstallHints] = await Promise.all([
|
||||
getEARegistryInstalls(),
|
||||
getEAUninstallHints()
|
||||
])
|
||||
|
||||
// 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
|
||||
})
|
||||
}
|
||||
}
|
||||
const registryHints: EAGameHint[] = regDirs.map(dir => ({
|
||||
name: path.basename(dir),
|
||||
installDir: dir
|
||||
}))
|
||||
|
||||
// 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 mergedHints = [...registryHints, ...uninstallHints]
|
||||
const scannedHints = await mapWithConcurrency(mergedHints, async (hint) => {
|
||||
return await scanEAHintInstall(hint, progressCallback)
|
||||
}, 6)
|
||||
|
||||
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)
|
||||
}
|
||||
for (const game of scannedHints) {
|
||||
if (!game) continue
|
||||
const key = normalizeFsPath(game.installDir)
|
||||
if (!dedup.has(key)) dedup.set(key, game)
|
||||
}
|
||||
|
||||
results.push(...dedup.values())
|
||||
return results
|
||||
// 兜底常见目录扫描
|
||||
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, {
|
||||
ignoreDirNames: EA_FLAT_IGNORE_DIRS,
|
||||
shouldIncludeDir: (installDir, dirName) => !isEALauncherEntry(dirName, installDir)
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
return Array.from(dedup.values())
|
||||
}
|
||||
|
||||
@@ -1,70 +1,99 @@
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import { LocalGameScanResult, ScanProgressCallback } from './types'
|
||||
import { getWindowsDriveRoots, normalizeDisplayName, normalizeFsPath, pickRelatedExecutables, safeReadDir } from './utils'
|
||||
import { dedupeProcessNames, getWindowsDriveRoots, mapWithConcurrency, normalizeDisplayName, normalizeFsPath, pickRelatedExecutables, safeReadDir } from './utils'
|
||||
import { scanFlatPlatformFolder } from './flat'
|
||||
|
||||
/**
|
||||
* Epic 游戏 Manifest 数据文件中的必要字段定义
|
||||
*/
|
||||
type EpicManifestItem = {
|
||||
InstallLocation?: string
|
||||
DisplayName?: string
|
||||
LaunchExecutable?: string
|
||||
AppName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描 Epic Games
|
||||
* 优先读取 %ProgramData%/Epic/EpicGamesLauncher/Data/Manifests 下的配置
|
||||
* 兜底扫描常见 Epic 游戏目录
|
||||
*/
|
||||
export async function scanEpicGames(progressCallback?: ScanProgressCallback): Promise<LocalGameScanResult[]> {
|
||||
progressCallback?.('scanning_platform', 'Epic')
|
||||
const results: LocalGameScanResult[] = []
|
||||
const dedup = new Map<string, LocalGameScanResult>()
|
||||
progressCallback?.('scanning_platform', 'Epic')
|
||||
const dedup = new Map<string, LocalGameScanResult>()
|
||||
|
||||
// 1. 从 Manifests 文件夹读取
|
||||
const programData = process.env.PROGRAMDATA || 'C:\\ProgramData'
|
||||
const manifestDir = path.join(programData, 'Epic', 'EpicGamesLauncher', 'Data', 'Manifests')
|
||||
// 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
|
||||
}
|
||||
}
|
||||
if (await fs.pathExists(manifestDir)) {
|
||||
const files = await safeReadDir(manifestDir)
|
||||
const manifestFiles = files.filter(file => file.isFile() && file.name.endsWith('.item'))
|
||||
|
||||
const parsed = await mapWithConcurrency(manifestFiles, async (file) => {
|
||||
try {
|
||||
const content = await fs.readJSON(path.join(manifestDir, file.name)) as EpicManifestItem
|
||||
const installLocation = String(content.InstallLocation || '').trim()
|
||||
const displayNameRaw = String(content.DisplayName || content.AppName || '').trim()
|
||||
if (!installLocation || !displayNameRaw) return null
|
||||
|
||||
const installDir = path.normalize(installLocation)
|
||||
if (!await fs.pathExists(installDir)) return null
|
||||
const displayName = normalizeDisplayName(displayNameRaw)
|
||||
|
||||
progressCallback?.('scanning_dir', installDir)
|
||||
|
||||
const launchExeRaw = String(content.LaunchExecutable || '').trim()
|
||||
const launchExePath = launchExeRaw ? path.join(installDir, launchExeRaw) : ''
|
||||
const hasLaunchExe = launchExePath && await fs.pathExists(launchExePath)
|
||||
|
||||
let processNames: string[] = []
|
||||
if (hasLaunchExe) {
|
||||
processNames = [path.basename(launchExePath)]
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
const exes = await pickRelatedExecutables(installDir, displayName, progressCallback)
|
||||
if (exes && exes.length > 0) {
|
||||
processNames = dedupeProcessNames([...processNames, ...exes.map(e => path.basename(e))])
|
||||
}
|
||||
}
|
||||
|
||||
results.push(...dedup.values())
|
||||
return results
|
||||
if (processNames.length === 0) return null
|
||||
|
||||
return {
|
||||
name: displayName,
|
||||
processName: processNames,
|
||||
source: 'Epic' as const,
|
||||
installDir
|
||||
} as LocalGameScanResult
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, 8)
|
||||
|
||||
for (const game of parsed) {
|
||||
if (!game) continue
|
||||
dedup.set(normalizeFsPath(game.installDir), game)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const key = normalizeFsPath(game.installDir)
|
||||
if (!dedup.has(key)) {
|
||||
dedup.set(key, game)
|
||||
}
|
||||
}
|
||||
return Array.from(dedup.values())
|
||||
}
|
||||
|
||||
@@ -1,48 +1,59 @@
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import { LocalGameScanResult, Platform, ScanProgressCallback } from './types'
|
||||
import { mapWithConcurrency, normalizeDisplayName, pickRelatedExecutables, safeReadDir } from './utils'
|
||||
import { dedupeProcessNames, mapWithConcurrency, normalizeDisplayName, pickRelatedExecutables, safeReadDir } from './utils'
|
||||
|
||||
/**
|
||||
* 扁平目录扫描相关配置选项
|
||||
*/
|
||||
export type FlatScanOptions = {
|
||||
/** 需要跳过的目录名列表 */
|
||||
ignoreDirNames?: Set<string>
|
||||
/** 如果提供,则执行自定义校验方法通过的才会被扫描 */
|
||||
shouldIncludeDir?: (installDir: string, dirName: string) => boolean
|
||||
/** 顶级节点检索并发限制 */
|
||||
rootConcurrency?: number
|
||||
/** 子节点扫描深层并发数 */
|
||||
entryConcurrency?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描指定目录下的通用平台游戏 (Epic, EA, Microsoft Store)
|
||||
*
|
||||
* 这种扫描方式主要依赖于目录结构和可执行文件的启发式匹配。
|
||||
*
|
||||
* @param source 游戏平台标识
|
||||
* @param progressCallback 进度回调
|
||||
* @returns 扫描到的游戏列表
|
||||
*/
|
||||
export async function scanFlatPlatformFolder(
|
||||
source: Platform,
|
||||
roots: string[],
|
||||
progressCallback?: ScanProgressCallback
|
||||
progressCallback?: ScanProgressCallback,
|
||||
options?: FlatScanOptions
|
||||
): Promise<LocalGameScanResult[]> {
|
||||
const results: LocalGameScanResult[] = []
|
||||
const ignoreDirNames = options?.ignoreDirNames
|
||||
const shouldIncludeDir = options?.shouldIncludeDir
|
||||
|
||||
const tasks = await mapWithConcurrency(roots, async (root) => {
|
||||
if (!await fs.pathExists(root)) return []
|
||||
progressCallback?.('scanning_dir', root)
|
||||
const entries = await safeReadDir(root)
|
||||
|
||||
// 目录中游戏项也并行扫描
|
||||
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, progressCallback)
|
||||
const dirName = entry.name
|
||||
if (ignoreDirNames?.has(dirName.toLowerCase())) return null
|
||||
const installDir = path.join(root, dirName)
|
||||
if (shouldIncludeDir && !shouldIncludeDir(installDir, dirName)) return null
|
||||
|
||||
const exes = await pickRelatedExecutables(installDir, dirName, progressCallback)
|
||||
if (!exes || exes.length === 0) return null
|
||||
|
||||
return {
|
||||
name: normalizeDisplayName(entry.name),
|
||||
processName: exes.map(e => path.basename(e)),
|
||||
name: normalizeDisplayName(dirName),
|
||||
processName: dedupeProcessNames(exes.map(e => path.basename(e))),
|
||||
source,
|
||||
installDir
|
||||
}
|
||||
}, 6)
|
||||
} as LocalGameScanResult
|
||||
}, options?.entryConcurrency ?? 6)
|
||||
|
||||
return scannedGames.filter(g => g !== null) as LocalGameScanResult[]
|
||||
}, 4)
|
||||
|
||||
for (const c of tasks) {
|
||||
results.push(...c)
|
||||
}
|
||||
|
||||
return results
|
||||
}, options?.rootConcurrency ?? 4)
|
||||
return tasks.flat()
|
||||
}
|
||||
|
||||
@@ -3,105 +3,100 @@ import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import { LocalGameScanResult, ScanProgressCallback } from './types'
|
||||
import { normalizeDisplayName, pickRelatedExecutables, shouldSkipExeByName } from './utils'
|
||||
import { dedupeProcessNames, mapWithConcurrency, normalizeDisplayName, pickRelatedExecutables, shouldSkipExeByName } from './utils'
|
||||
|
||||
/**
|
||||
* 特定指向系统级核心与工具的捷径配置黑名单关键字,过滤误扫
|
||||
*/
|
||||
const LOCAL_SHORTCUT_SKIP_TARGET_KEYWORDS = [
|
||||
'code.exe',
|
||||
'\\system32\\',
|
||||
'\\windows\\',
|
||||
'\\microsoft office\\',
|
||||
'\\office\\'
|
||||
]
|
||||
|
||||
/**
|
||||
* 读取本地快捷方式 (.lnk) 识别独立游戏
|
||||
*/
|
||||
export async function scanLocalShortcuts(progressCallback?: ScanProgressCallback): Promise<LocalGameScanResult[]> {
|
||||
progressCallback?.('scanning_platform', 'Local')
|
||||
if (process.platform !== 'win32') return []
|
||||
progressCallback?.('scanning_platform', 'Local')
|
||||
if (process.platform !== 'win32') return []
|
||||
|
||||
const results: LocalGameScanResult[] = []
|
||||
const dedup = new Set<string>()
|
||||
const dedup = new Set<string>()
|
||||
|
||||
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 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[] = []
|
||||
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
|
||||
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 })
|
||||
await mapWithConcurrency(items, 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)
|
||||
}
|
||||
})
|
||||
|
||||
const parsedResults = await Promise.all(parseTasks)
|
||||
for (const res of parsedResults) {
|
||||
if (res !== null) results.push(res)
|
||||
}, 6)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
await mapWithConcurrency(dirsToScan, async (dir) => {
|
||||
await collectLnks(dir)
|
||||
return true
|
||||
}, 4)
|
||||
|
||||
const parsedResults = await mapWithConcurrency(lnkFiles, async (lnk) => {
|
||||
try {
|
||||
const shortcut = shell.readShortcutLink(lnk)
|
||||
const target = String(shortcut.target || '').trim()
|
||||
if (!target || !target.toLowerCase().endsWith('.exe')) return null
|
||||
if (shouldSkipExeByName(path.basename(target))) return null
|
||||
|
||||
const lowerTarget = target.toLowerCase()
|
||||
if (LOCAL_SHORTCUT_SKIP_TARGET_KEYWORDS.some(k => lowerTarget.includes(k))) return null
|
||||
if (!await fs.pathExists(target)) return null
|
||||
|
||||
const installDir = path.dirname(target)
|
||||
const exeKey = target.toLowerCase()
|
||||
if (dedup.has(exeKey)) return null
|
||||
dedup.add(exeKey)
|
||||
|
||||
let displayName = normalizeDisplayName(path.basename(lnk, '.lnk'))
|
||||
if (!displayName) displayName = normalizeDisplayName(path.basename(installDir))
|
||||
|
||||
let processNames = [path.basename(target)]
|
||||
try {
|
||||
const exes = await pickRelatedExecutables(installDir, displayName, progressCallback)
|
||||
if (exes && exes.length > 0) {
|
||||
processNames = dedupeProcessNames([...processNames, ...exes.map(e => path.basename(e))])
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return {
|
||||
name: displayName,
|
||||
processName: processNames,
|
||||
source: 'Local' as const,
|
||||
installDir
|
||||
} as LocalGameScanResult
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, 8)
|
||||
|
||||
return parsedResults.filter(Boolean) as LocalGameScanResult[]
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import { LocalGameScanResult, ScanProgressCallback } from './types'
|
||||
import { getWindowsDriveRoots, normalizeDisplayName, normalizeFsPath, safeReadDir, pickRelatedExecutables } from './utils'
|
||||
import { dedupeProcessNames, getWindowsDriveRoots, mapWithConcurrency, normalizeDisplayName, normalizeFsPath, parsePowershellJson, safeReadDir, pickRelatedExecutables } from './utils'
|
||||
import { runCommand } from '../../utils/command'
|
||||
import { scanFlatPlatformFolder } from './flat'
|
||||
|
||||
/**
|
||||
* 根据注册表或 Manifest 提供的提示信息,优化游戏名称
|
||||
* @param installDir 游戏安装目录
|
||||
* @param hints 注册或解析来的名录库
|
||||
* @returns 最接近真实名字字串或空
|
||||
*/
|
||||
function pickBestNameFromHints(installDir: string, hints: Map<string, string>) {
|
||||
const dirKey = normalizeFsPath(installDir)
|
||||
@@ -25,24 +28,9 @@ function pickBestNameFromHints(installDir: string, hints: Map<string, string>) {
|
||||
return bestName
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 PowerShell 返回的 JSON 字符串
|
||||
*/
|
||||
function parsePowershellJson<T>(raw: string): T[] {
|
||||
const text = raw.trim()
|
||||
if (!text) return []
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
if (Array.isArray(parsed)) return parsed as T[]
|
||||
if (parsed && typeof parsed === 'object') return [parsed as T]
|
||||
return []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Windows 注册表中获取已安装软件的名称和安装路径映射
|
||||
* @returns 包名定位和本地全量索引哈希名库
|
||||
*/
|
||||
async function getRegistryDisplayNameHints(): Promise<Map<string, string>> {
|
||||
const map = new Map<string, string>()
|
||||
@@ -78,6 +66,8 @@ async function getRegistryDisplayNameHints(): Promise<Map<string, string>> {
|
||||
|
||||
/**
|
||||
* 从 AppxManifest.xml 内容中提取显示名称
|
||||
* @param content Application Manifest 文本
|
||||
* @returns 暴露出来的主观标识名字
|
||||
*/
|
||||
function parseDisplayNameFromManifest(content: string): string {
|
||||
const displayName = content.match(/<DisplayName>([^<]+)<\/DisplayName>/i)?.[1]?.trim() || ''
|
||||
@@ -88,15 +78,18 @@ function parseDisplayNameFromManifest(content: string): string {
|
||||
|
||||
/**
|
||||
* 从 XboxGames 目录下的 AppxManifest.xml 解析游戏名称
|
||||
* @param xboxRoots Xbox 默认多盘加载外设口
|
||||
* @returns { 安装基础包索引 -> 主游戏展示字向 }
|
||||
*/
|
||||
async function getManifestDisplayNameHints(xboxRoots: string[]): Promise<Map<string, string>> {
|
||||
const map = new Map<string, string>()
|
||||
|
||||
for (const root of xboxRoots) {
|
||||
if (!await fs.pathExists(root)) continue
|
||||
const rootBatches = await mapWithConcurrency(xboxRoots, async (root) => {
|
||||
if (!await fs.pathExists(root)) return [] as Array<{ installDir: string, name: string }>
|
||||
const entries = await safeReadDir(root)
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
|
||||
const rows = await mapWithConcurrency(entries, async (entry) => {
|
||||
if (!entry.isDirectory()) return null
|
||||
const gameDir = path.join(root, entry.name)
|
||||
const manifestCandidates = [
|
||||
path.join(gameDir, 'AppxManifest.xml'),
|
||||
@@ -109,12 +102,21 @@ async function getManifestDisplayNameHints(xboxRoots: string[]): Promise<Map<str
|
||||
const content = await fs.readFile(manifestPath, 'utf8')
|
||||
const parsedName = parseDisplayNameFromManifest(content)
|
||||
if (!parsedName) continue
|
||||
map.set(normalizeFsPath(gameDir), normalizeDisplayName(parsedName))
|
||||
break
|
||||
return { installDir: gameDir, name: normalizeDisplayName(parsedName) }
|
||||
} catch {
|
||||
// ignore manifest read errors
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}, 6)
|
||||
|
||||
return rows.filter(Boolean) as Array<{ installDir: string, name: string }>
|
||||
}, 4)
|
||||
|
||||
for (const batch of rootBatches) {
|
||||
for (const row of batch) {
|
||||
map.set(normalizeFsPath(row.installDir), row.name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +125,8 @@ async function getManifestDisplayNameHints(xboxRoots: string[]): Promise<Map<str
|
||||
|
||||
/**
|
||||
* 扫描 UWP / 已安装的 AppxPackages
|
||||
* @param progressCallback 透传加载回溯日志流
|
||||
* @returns 分析出标准列表输出
|
||||
*/
|
||||
async function scanAppxPackages(progressCallback?: ScanProgressCallback): Promise<LocalGameScanResult[]> {
|
||||
if (process.platform !== 'win32') return []
|
||||
@@ -132,16 +136,14 @@ async function scanAppxPackages(progressCallback?: ScanProgressCallback): Promis
|
||||
if (code !== 0 || !output) return []
|
||||
|
||||
const rows = parsePowershellJson<{ Name?: string, InstallLocation?: string }>(output)
|
||||
const results: LocalGameScanResult[] = []
|
||||
|
||||
// 避免并发过高
|
||||
for (const row of rows) {
|
||||
const scanned = await mapWithConcurrency(rows, async (row) => {
|
||||
const packageName = String(row.Name || '').trim()
|
||||
const location = String(row.InstallLocation || '').trim()
|
||||
if (!packageName || !location) continue
|
||||
if (!packageName || !location) return null
|
||||
|
||||
const manifestPath = path.join(location, 'AppxManifest.xml')
|
||||
if (!await fs.pathExists(manifestPath)) continue
|
||||
if (!await fs.pathExists(manifestPath)) return null
|
||||
progressCallback?.('scanning_dir', location)
|
||||
|
||||
try {
|
||||
@@ -152,24 +154,24 @@ async function scanAppxPackages(progressCallback?: ScanProgressCallback): Promis
|
||||
content.includes('TargetDeviceFamily Name="Windows.Xbox"') ||
|
||||
/(Forza|Minecraft|Halo|AgeOf|SeaOf|FlightSim|StateOfDecay|Gears)/i.test(packageName)
|
||||
|
||||
if (isGame) {
|
||||
const parsedName = parseDisplayNameFromManifest(content) || packageName
|
||||
const exes = await pickRelatedExecutables(location, parsedName, progressCallback)
|
||||
if (exes && exes.length > 0) {
|
||||
results.push({
|
||||
name: normalizeDisplayName(parsedName),
|
||||
processName: exes.map(e => path.basename(e)),
|
||||
source: 'Microsoft',
|
||||
installDir: location
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (!isGame) return null
|
||||
|
||||
return results
|
||||
const parsedName = parseDisplayNameFromManifest(content) || packageName
|
||||
const exes = await pickRelatedExecutables(location, parsedName, progressCallback)
|
||||
if (!exes || exes.length === 0) return null
|
||||
|
||||
return {
|
||||
name: normalizeDisplayName(parsedName),
|
||||
processName: dedupeProcessNames(exes.map(e => path.basename(e))),
|
||||
source: 'Microsoft' as const,
|
||||
installDir: location
|
||||
} as LocalGameScanResult
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, 6)
|
||||
|
||||
return scanned.filter(Boolean) as LocalGameScanResult[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,10 +188,24 @@ export async function scanMicrosoftGames(progressCallback?: ScanProgressCallback
|
||||
scanAppxPackages(progressCallback)
|
||||
])
|
||||
|
||||
// 去重 (相同的安装目录只保留一个)
|
||||
const uniqueGames = new Map<string, LocalGameScanResult>()
|
||||
for (const game of [...base, ...appxBase]) {
|
||||
uniqueGames.set(normalizeFsPath(game.installDir), game)
|
||||
const key = normalizeFsPath(game.installDir)
|
||||
const prev = uniqueGames.get(key)
|
||||
|
||||
if (!prev) {
|
||||
uniqueGames.set(key, {
|
||||
...game,
|
||||
processName: dedupeProcessNames(game.processName)
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
uniqueGames.set(key, {
|
||||
...prev,
|
||||
name: prev.name.length >= game.name.length ? prev.name : game.name,
|
||||
processName: dedupeProcessNames([...prev.processName, ...game.processName])
|
||||
})
|
||||
}
|
||||
const mergedBase = Array.from(uniqueGames.values())
|
||||
|
||||
|
||||
@@ -1,16 +1,116 @@
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import { LocalGameScanResult, ScanProgressCallback } from './types'
|
||||
import { getWindowsDriveRoots, normalizeDisplayName, normalizeFsPath, pickRelatedExecutables, safeReadDir } from './utils'
|
||||
import {
|
||||
getWindowsDriveRoots,
|
||||
mapWithConcurrency,
|
||||
normalizeDisplayName,
|
||||
normalizeFsPath,
|
||||
parsePowershellJson,
|
||||
pickRelatedExecutables,
|
||||
safeReadDir
|
||||
} from './utils'
|
||||
import { runCommand } from '../../utils/command'
|
||||
|
||||
/**
|
||||
* 读取 Steam 库文件夹路径
|
||||
* 解析 libraryfolders.vdf 文件获取所有库位置
|
||||
* 从 acf 内容中按 key 取值
|
||||
*/
|
||||
function parseAcfValue(content: string, key: string): string {
|
||||
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const match = content.match(new RegExp(`"${escaped}"\\s+"([^"]*)"`, 'i'))
|
||||
return (match?.[1] || '').replace(/\\\\/g, '\\').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 acf 内容中提取状态位
|
||||
*/
|
||||
function parseAppStateFlags(content: string): number {
|
||||
const raw = parseAcfValue(content, 'StateFlags')
|
||||
const state = Number.parseInt(raw, 10)
|
||||
return Number.isFinite(state) ? state : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Steam state bit2 == installed (1 << 2 == 4)
|
||||
*/
|
||||
function isInstalledByStateFlags(stateFlags: number): boolean {
|
||||
return (stateFlags & (1 << 2)) !== 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 libraryfolders.vdf 内容解析包含已配正确存储介质的 Steam 本地位置数组
|
||||
* - 旧格式: "1" "D:\\SteamLibrary"
|
||||
* - 新格式: "1" { "path" "D:\\SteamLibrary" "mounted" "1" ... }
|
||||
* @param content VDF 文件明文
|
||||
* @returns 去重规范化的可用游戏库目录路径大集
|
||||
*/
|
||||
function parseLibraryFoldersVdf(content: string): string[] {
|
||||
const roots = new Set<string>()
|
||||
const lines = content.split(/\r?\n/)
|
||||
|
||||
for (const line of lines) {
|
||||
const old = line.match(/^\s*"\d+"\s+"([^"]+)"\s*$/)
|
||||
if (!old?.[1]) continue
|
||||
roots.add(path.normalize(old[1].replace(/\\\\/g, '\\').trim()))
|
||||
}
|
||||
|
||||
let sectionDepth = 0
|
||||
let hasSection = false
|
||||
let sectionPath = ''
|
||||
let sectionMounted = ''
|
||||
|
||||
const flushSection = () => {
|
||||
if (!sectionPath) return
|
||||
if (sectionMounted && sectionMounted !== '1') return
|
||||
roots.add(path.normalize(sectionPath))
|
||||
}
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim()
|
||||
if (!line) continue
|
||||
|
||||
if (!hasSection) {
|
||||
if (/^"\d+"\s*\{$/.test(line)) {
|
||||
hasSection = true
|
||||
sectionDepth = 1
|
||||
sectionPath = ''
|
||||
sectionMounted = ''
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const openCount = (line.match(/\{/g) || []).length
|
||||
const closeCount = (line.match(/\}/g) || []).length
|
||||
sectionDepth += openCount
|
||||
|
||||
if (sectionDepth === 1) {
|
||||
const kv = line.match(/^"([^"]+)"\s+"([^"]*)"$/)
|
||||
if (kv?.[1] && kv?.[2] !== undefined) {
|
||||
const key = kv[1].toLowerCase()
|
||||
const value = kv[2].replace(/\\\\/g, '\\').trim()
|
||||
if (key === 'path') sectionPath = value
|
||||
if (key === 'mounted') sectionMounted = value
|
||||
}
|
||||
}
|
||||
|
||||
sectionDepth -= closeCount
|
||||
if (sectionDepth <= 0) {
|
||||
flushSection()
|
||||
hasSection = false
|
||||
sectionDepth = 0
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(roots)
|
||||
}
|
||||
|
||||
/**
|
||||
* 集中读取 Steam 在各盘所有的已加载游戏组件/外挂库根目录
|
||||
* @returns 有效存在根路径的集合
|
||||
*/
|
||||
async function readSteamLibraryRoots(): Promise<string[]> {
|
||||
const drives = await getWindowsDriveRoots()
|
||||
const candidateSteamRoots = Array.from(new Set([
|
||||
const candidateSteamRoots = new Set<string>([
|
||||
...drives.flatMap(root => [
|
||||
root,
|
||||
path.join(root, 'Program Files (x86)', 'Steam'),
|
||||
@@ -21,33 +121,40 @@ async function readSteamLibraryRoots(): Promise<string[]> {
|
||||
path.join(root, 'Games', 'SteamLibrary'),
|
||||
path.join(root, 'Game', 'Steam')
|
||||
])
|
||||
]))
|
||||
])
|
||||
|
||||
// 主动嗅探宽泛的 steamapps 目录(搜索驱动器下的前两级目录)
|
||||
// 专门对付脱离注册表的绿色版/遗留库
|
||||
for (const root of drives) {
|
||||
// 受 SteamTools 启发:优先走配置/注册表;兜底嗅探时限制候选目录,避免全盘抖动。
|
||||
await mapWithConcurrency(drives, async (root) => {
|
||||
try {
|
||||
const topLevelDirs = await safeReadDir(root)
|
||||
const dirsToSearch = [root]
|
||||
let searched = 0
|
||||
|
||||
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))
|
||||
}
|
||||
if (searched >= 32) break
|
||||
if (!d.isDirectory()) continue
|
||||
const lower = d.name.toLowerCase()
|
||||
if (['windows', 'programdata', '$recycle.bin', 'system volume information'].includes(lower)) continue
|
||||
if (!/(steam|game|games|library)/i.test(d.name)) continue
|
||||
searched += 1
|
||||
dirsToSearch.push(path.join(root, d.name))
|
||||
}
|
||||
|
||||
await Promise.all(dirsToSearch.map(async (searchDir) => {
|
||||
await mapWithConcurrency(dirsToSearch, async (searchDir) => {
|
||||
try {
|
||||
const steamAppsPath = path.join(searchDir, 'steamapps')
|
||||
if (await fs.pathExists(steamAppsPath)) {
|
||||
candidateSteamRoots.push(searchDir)
|
||||
candidateSteamRoots.add(path.normalize(searchDir))
|
||||
}
|
||||
} catch { }
|
||||
}))
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 6)
|
||||
} catch {
|
||||
// ignore root scan errors
|
||||
}
|
||||
}
|
||||
return true
|
||||
}, 4)
|
||||
|
||||
// 尝试从注册表获取真实 Steam 安装目录
|
||||
try {
|
||||
@@ -61,73 +168,66 @@ async function readSteamLibraryRoots(): Promise<string[]> {
|
||||
].join('; ')
|
||||
const { code, output } = await runCommand('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], 6000)
|
||||
if (code === 0 && output) {
|
||||
const text = output.trim()
|
||||
const roots = text.startsWith('[') ? (JSON.parse(text) as string[]) : [text.replace(/^"|"$/g, '')]
|
||||
const roots = parsePowershellJson<string>(output)
|
||||
for (const root of roots) {
|
||||
const p = String(root || '').trim()
|
||||
if (p) candidateSteamRoots.unshift(path.normalize(p))
|
||||
if (p) candidateSteamRoots.add(path.normalize(p))
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const libs = new Set<string>()
|
||||
|
||||
for (const steamRoot of candidateSteamRoots) {
|
||||
await mapWithConcurrency(Array.from(candidateSteamRoots), async (steamRoot) => {
|
||||
libs.add(path.normalize(steamRoot))
|
||||
|
||||
const libraryVdf = path.join(steamRoot, 'steamapps', 'libraryfolders.vdf')
|
||||
if (!await fs.pathExists(libraryVdf)) continue
|
||||
if (!await fs.pathExists(libraryVdf)) return null
|
||||
try {
|
||||
const content = await fs.readFile(libraryVdf, 'utf8')
|
||||
const lines = content.split(/\r?\n/)
|
||||
for (const line of lines) {
|
||||
// 新版格式: "path" "D:\\SteamLibrary"
|
||||
const m = line.match(/"path"\s+"([^"]+)"/i)
|
||||
if (m?.[1]) {
|
||||
const p = m[1].replace(/\\\\/g, '\\').trim()
|
||||
if (p) libs.add(path.normalize(p))
|
||||
continue
|
||||
}
|
||||
|
||||
// 旧版格式: "1" "D:\\SteamLibrary"
|
||||
const old = line.match(/^\s*"\d+"\s+"([^"]+)"\s*$/)
|
||||
if (old?.[1]) {
|
||||
const p = old[1].replace(/\\\\/g, '\\').trim()
|
||||
if (p) libs.add(path.normalize(p))
|
||||
}
|
||||
const paths = parseLibraryFoldersVdf(content)
|
||||
for (const p of paths) {
|
||||
if (p) libs.add(path.normalize(p))
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[游戏扫描] 解析 Steam libraryfolders.vdf 失败:', e)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, 6)
|
||||
|
||||
return Array.from(new Set(Array.from(libs).map(p => path.normalize(p).toLowerCase())))
|
||||
}
|
||||
|
||||
function parseAcfValue(content: string, key: string): string {
|
||||
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const match = content.match(new RegExp(`"${escaped}"\\s+"([^"]*)"`, 'i'))
|
||||
return (match?.[1] || '').replace(/\\\\/g, '\\').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 appmanifest_*.acf 并顺带拉取 common 中相关已装组件数据组装标准模型
|
||||
* @param libRoot Steam 组件库存储核心位置
|
||||
* @param progressCallback 回调
|
||||
* @returns 游戏匹配集合和统计分析量
|
||||
*/
|
||||
async function scanSteamFromManifests(
|
||||
libRoot: string,
|
||||
progressCallback?: ScanProgressCallback
|
||||
): Promise<LocalGameScanResult[]> {
|
||||
const results: LocalGameScanResult[] = []
|
||||
): Promise<{ games: LocalGameScanResult[], manifestCount: number }> {
|
||||
const steamAppsDir = path.join(libRoot, 'steamapps')
|
||||
if (!await fs.pathExists(steamAppsDir)) return results
|
||||
if (!await fs.pathExists(steamAppsDir)) return { games: [], manifestCount: 0 }
|
||||
|
||||
progressCallback?.('scanning_dir', steamAppsDir)
|
||||
|
||||
const entries = await safeReadDir(steamAppsDir)
|
||||
const manifestFiles = entries.filter(entry => entry.isFile() && /^appmanifest_\d+\.acf$/i.test(entry.name))
|
||||
const manifestCount = manifestFiles.length
|
||||
|
||||
const manifestTasks = manifestFiles.map(async (manifest) => {
|
||||
const manifestResults = await mapWithConcurrency(manifestFiles, async (manifest) => {
|
||||
const manifestPath = path.join(steamAppsDir, manifest.name)
|
||||
try {
|
||||
const content = await fs.readFile(manifestPath, 'utf8')
|
||||
if (!content.replace(/\0/g, '').trim()) return null
|
||||
|
||||
const stateFlags = parseAppStateFlags(content)
|
||||
if (stateFlags !== 0 && !isInstalledByStateFlags(stateFlags)) return null
|
||||
|
||||
const appName = parseAcfValue(content, 'name')
|
||||
const installFolder = parseAcfValue(content, 'installdir')
|
||||
if (!installFolder) return null
|
||||
@@ -141,21 +241,19 @@ async function scanSteamFromManifests(
|
||||
|
||||
return {
|
||||
name: displayName,
|
||||
processName: exes.map(e => path.basename(e)),
|
||||
processName: Array.from(new Set(exes.map(e => path.basename(e)))),
|
||||
source: 'Steam',
|
||||
installDir
|
||||
} as LocalGameScanResult
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
}, 8)
|
||||
|
||||
const manifestResults = await Promise.all(manifestTasks)
|
||||
for (const res of manifestResults) {
|
||||
if (res !== null) results.push(res)
|
||||
return {
|
||||
games: manifestResults.filter((res): res is LocalGameScanResult => res !== null),
|
||||
manifestCount
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,40 +263,43 @@ async function scanSteamFromManifests(
|
||||
export async function scanSteamGames(progressCallback?: ScanProgressCallback): Promise<LocalGameScanResult[]> {
|
||||
progressCallback?.('scanning_platform', 'Steam')
|
||||
const roots = await readSteamLibraryRoots()
|
||||
const results: LocalGameScanResult[] = []
|
||||
const dedup = new Map<string, LocalGameScanResult>()
|
||||
|
||||
for (const libRoot of roots) {
|
||||
const manifestResults = await scanSteamFromManifests(libRoot, progressCallback)
|
||||
const { games: manifestResults, manifestCount } = await scanSteamFromManifests(libRoot, progressCallback)
|
||||
for (const game of manifestResults) {
|
||||
dedup.set(normalizeFsPath(game.installDir), game)
|
||||
}
|
||||
|
||||
// 受 SteamTools 的“基于清单扫描”策略启发:
|
||||
// 已能读取到 manifest 时,避免再全量深扫 common 目录导致 I/O 放大。
|
||||
if (manifestCount > 0) continue
|
||||
|
||||
const commonDir = path.join(libRoot, 'steamapps', 'common')
|
||||
if (!await fs.pathExists(commonDir)) continue
|
||||
|
||||
const games = await safeReadDir(commonDir)
|
||||
progressCallback?.('scanning_dir', commonDir)
|
||||
const scanGamesTasks = games.map(async (entry) => {
|
||||
const gamesResults = await mapWithConcurrency(games, async (entry) => {
|
||||
if (!entry.isDirectory()) return null
|
||||
const installDir = path.join(commonDir, entry.name)
|
||||
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)),
|
||||
processName: Array.from(new Set(exes.map(e => path.basename(e)))),
|
||||
installDir,
|
||||
source: 'Steam' as const
|
||||
}
|
||||
})
|
||||
}, 6)
|
||||
|
||||
const gamesResults = await Promise.all(scanGamesTasks)
|
||||
for (const res of gamesResults) {
|
||||
if (res !== null) dedup.set(normalizeFsPath(res.installDir), res)
|
||||
}
|
||||
}
|
||||
|
||||
results.push(...dedup.values())
|
||||
return results
|
||||
return Array.from(dedup.values())
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 支持的游戏平台标识 */
|
||||
export type Platform = 'Steam' | 'Microsoft' | 'Epic' | 'EA' | 'BattleNet' | 'WeGame' | 'Local' | 'GOG'
|
||||
|
||||
/**
|
||||
@@ -101,4 +102,10 @@ export const GAME_SCAN_EXE_HARD_EXCLUDE = new Set([
|
||||
'vc_redist.x64.exe',
|
||||
'vc_redist.x86.exe',
|
||||
'dotnetfx.exe',
|
||||
// 常见压缩/解包工具,容易被误识别为游戏进程
|
||||
'7z.exe',
|
||||
'7za.exe',
|
||||
'7zr.exe',
|
||||
'unarc.exe',
|
||||
])
|
||||
|
||||
@@ -12,8 +12,20 @@ import {
|
||||
// 调整 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
|
||||
const DRIVE_DETECT_CONCURRENCY = 16 // 并发探测盘符,避免串行等待
|
||||
const PICK_EXE_DEPTH_STAGES = [2, 4, 6] // 深度扫描阶段
|
||||
const PICK_EXE_STAT_SAMPLE_LIMIT = 24 // 采样统计数量
|
||||
const PICK_EXE_EARLY_ACCEPT_SCORE = 8 // 早期接受分数
|
||||
const PICK_EXE_EARLY_ACCEPT_COUNT = 4 // 早期接受数量
|
||||
const PICK_EXE_DEEP_FALLBACK_DEPTH = 6 // 深度回退深度
|
||||
const LARGE_DIR_ENTRY_THRESHOLD = 3000 // 大目录条目阈值
|
||||
const LARGE_DIR_SUBDIR_SCAN_LIMIT = 120 // 大目录子目录扫描限制
|
||||
const LARGE_DIR_PRIORITY_KEYWORDS = [
|
||||
'bin', 'binaries', 'win64', 'win32', 'x64', 'x86',
|
||||
'game', 'client', 'release', 'shipping', 'launcher'
|
||||
]
|
||||
const PICK_EXE_MIN_SCORE = 2
|
||||
const PICK_EXE_SCORE_GAP_FROM_BEST = 4
|
||||
|
||||
/**
|
||||
* 标准化游戏显示名称
|
||||
@@ -34,6 +46,36 @@ export function normalizeFsPath(p: string) {
|
||||
return path.normalize(p).replace(/[\\\/]+$/, '').toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 PowerShell ConvertTo-Json 输出,兼容数组/单对象/单字符串。
|
||||
*/
|
||||
export function parsePowershellJson<T>(raw: string): T[] {
|
||||
const text = String(raw || '').trim()
|
||||
if (!text) return []
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
if (Array.isArray(parsed)) return parsed as T[]
|
||||
if (parsed !== null && parsed !== undefined) return [parsed as T]
|
||||
return []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 进程名去重并统一小写键,保留原始文件名大小写。
|
||||
*/
|
||||
export function dedupeProcessNames(names: string[]) {
|
||||
const map = new Map<string, string>()
|
||||
for (const name of names) {
|
||||
const n = String(name || '').trim()
|
||||
if (!n) continue
|
||||
const key = n.toLowerCase()
|
||||
if (!map.has(key)) map.set(key, n)
|
||||
}
|
||||
return Array.from(map.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地读取目录内容
|
||||
* 遇到错误时返回空数组而不是抛出异常
|
||||
@@ -75,16 +117,18 @@ export async function mapWithConcurrency<T, R>(
|
||||
|
||||
/**
|
||||
* 获取 Windows 系统下的所有驱动器根目录 (C:\, D:\, ...)
|
||||
* 并发探测盘符,避免串行等待。
|
||||
*/
|
||||
export async function getWindowsDriveRoots(): Promise<string[]> {
|
||||
if (process.platform !== 'win32') return []
|
||||
const roots: string[] = []
|
||||
for (let i = 67; i <= 90; i += 1) {
|
||||
const letter = String.fromCharCode(i)
|
||||
|
||||
const letters = Array.from({ length: 24 }, (_, idx) => String.fromCharCode(67 + idx))
|
||||
const detected = await mapWithConcurrency(letters, async (letter) => {
|
||||
const root = `${letter}:\\`
|
||||
if (await fs.pathExists(root)) roots.push(root)
|
||||
}
|
||||
return roots
|
||||
return await fs.pathExists(root) ? root : ''
|
||||
}, DRIVE_DETECT_CONCURRENCY)
|
||||
|
||||
return detected.filter(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,14 +148,47 @@ export function shouldSkipExeByName(exeName: string) {
|
||||
return GAME_SCAN_IGNORE_EXE_KEYWORDS.some(k => lower.includes(k))
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断进程名是否包含软过滤关键词
|
||||
* (在评分阶段降权使用,但不直接跳过)
|
||||
* @param exeName 进程名
|
||||
* @returns 判断结果
|
||||
*/
|
||||
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 dirName 目录名
|
||||
* @returns 启发式得分
|
||||
*/
|
||||
function scoreDirNameForLargeScan(dirName: string) {
|
||||
const lower = String(dirName || '').toLowerCase()
|
||||
if (!lower) return 0
|
||||
|
||||
let score = 0
|
||||
if (LARGE_DIR_PRIORITY_KEYWORDS.some((k) => lower.includes(k))) score += 6
|
||||
if (lower.startsWith('bin')) score += 2
|
||||
if (lower === 'x64' || lower === 'x86') score += 2
|
||||
return score
|
||||
}
|
||||
|
||||
/**
|
||||
* 目录扫描队列的任务定义
|
||||
*/
|
||||
type DirScanTask = {
|
||||
/** 待扫描的目录路径 */
|
||||
dir: string
|
||||
/** 该目录在扫描过程中的层级深度 */
|
||||
depth: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集目录下的所有 .exe 文件路径(迭代扫描,避免递归栈开销)
|
||||
* @param maxDepth 最大扫描深度 (-1 表示无限)
|
||||
* @param progressCallback 进度回调
|
||||
*/
|
||||
export async function collectExePaths(
|
||||
@@ -121,48 +198,66 @@ export async function collectExePaths(
|
||||
progressCallback?: ScanProgressCallback
|
||||
): Promise<string[]> {
|
||||
const results: string[] = []
|
||||
const dirents = await safeReadDir(dir)
|
||||
const pending: DirScanTask[] = [{ dir, depth: currentDepth }]
|
||||
const visited = new Set<string>()
|
||||
|
||||
// 性能优化:单层目录文件异常多时直接跳过,防止卡死
|
||||
if (dirents.length > 3000) {
|
||||
return results
|
||||
}
|
||||
const workerCount = Math.max(1, Math.min(DEFAULT_IO_CONCURRENCY, pending.length || DEFAULT_IO_CONCURRENCY))
|
||||
|
||||
const subDirs: string[] = []
|
||||
const run = async () => {
|
||||
while (true) {
|
||||
const task = pending.shift()
|
||||
if (!task) return
|
||||
|
||||
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) {
|
||||
subDirs.push(full)
|
||||
const normalizedTaskDir = normalizeFsPath(task.dir)
|
||||
if (visited.has(normalizedTaskDir)) continue
|
||||
visited.add(normalizedTaskDir)
|
||||
|
||||
const dirents = await safeReadDir(task.dir)
|
||||
const isLargeDir = dirents.length > LARGE_DIR_ENTRY_THRESHOLD
|
||||
const subDirs: Array<{ fullPath: string, score: number }> = []
|
||||
|
||||
for (const dirent of dirents) {
|
||||
const full = path.join(task.dir, dirent.name)
|
||||
if (dirent.isDirectory()) {
|
||||
const folderName = dirent.name.toLowerCase()
|
||||
if (GAME_SCAN_IGNORE_DIR_NAMES.has(folderName)) continue
|
||||
if (maxDepth === -1 || task.depth < maxDepth) {
|
||||
subDirs.push({
|
||||
fullPath: full,
|
||||
score: scoreDirNameForLargeScan(dirent.name)
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!dirent.isFile() || !dirent.name.toLowerCase().endsWith('.exe')) continue
|
||||
if (shouldSkipExeByName(dirent.name)) continue
|
||||
results.push(full)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!dirent.isFile() || !dirent.name.toLowerCase().endsWith('.exe')) continue
|
||||
if (shouldSkipExeByName(dirent.name)) continue
|
||||
results.push(full)
|
||||
}
|
||||
const selectedSubDirs = isLargeDir && subDirs.length > LARGE_DIR_SUBDIR_SCAN_LIMIT
|
||||
? subDirs
|
||||
.sort((a, b) => b.score - a.score || a.fullPath.localeCompare(b.fullPath))
|
||||
.slice(0, LARGE_DIR_SUBDIR_SCAN_LIMIT)
|
||||
.map(v => v.fullPath)
|
||||
: subDirs.map(v => v.fullPath)
|
||||
|
||||
// 多线程/并发处理所有合法的子目录
|
||||
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)
|
||||
for (const subDir of selectedSubDirs) {
|
||||
progressCallback?.('scanning_dir', subDir)
|
||||
pending.push({ dir: subDir, depth: task.depth + 1 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: workerCount }, () => run())
|
||||
await Promise.all(workers)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* 启发式算法:从目录下选择所有像是游戏主进程及相关子程序的可执行文件
|
||||
*
|
||||
*
|
||||
* 评分规则:
|
||||
* - 文件名与文件夹名完全匹配 (+10)
|
||||
* - 文件名包含文件夹名 (+6)
|
||||
@@ -210,8 +305,18 @@ export async function pickRelatedExecutables(
|
||||
}
|
||||
|
||||
if (allExes.length === 0) continue
|
||||
if (allExes.length >= 8) break
|
||||
if (allExes.some(exe => quickScore(exe) >= 10)) break
|
||||
if (allExes.length >= PICK_EXE_EARLY_ACCEPT_COUNT) break
|
||||
if (allExes.some(exe => quickScore(exe) >= PICK_EXE_EARLY_ACCEPT_SCORE)) break
|
||||
}
|
||||
|
||||
if (allExes.length === 0) {
|
||||
// 仅在浅层完全找不到 exe 时才深扫,避免对每个游戏都做重 I/O。
|
||||
const deepFallback = await collectExePaths(gameDir, PICK_EXE_DEEP_FALLBACK_DEPTH, 1, progressCallback)
|
||||
for (const exe of deepFallback) {
|
||||
if (seen.has(exe)) continue
|
||||
seen.add(exe)
|
||||
allExes.push(exe)
|
||||
}
|
||||
}
|
||||
|
||||
if (allExes.length === 0) return []
|
||||
@@ -239,9 +344,12 @@ export async function pickRelatedExecutables(
|
||||
|
||||
ranked.sort((a, b) => b.score - a.score)
|
||||
|
||||
// 选择得分较高的进程(比如前5个,或者得分 > 0 的进程),但至少返回1个(如果是空则前面已经返回了)
|
||||
// 加上为了支持子程序,我们把得分最高的,以及所有比较可能的是子程序的也带上。
|
||||
// 可以把前3到5个得分最高的 exe 选出来,只要它们不是安装包即可。
|
||||
const selected = ranked.filter(r => r.score >= 0).slice(0, 5).map(r => r.exe)
|
||||
const bestScore = ranked[0]?.score ?? 0
|
||||
const scoreFloor = Math.max(PICK_EXE_MIN_SCORE, bestScore - PICK_EXE_SCORE_GAP_FROM_BEST)
|
||||
const selected = ranked
|
||||
.filter(r => r.score >= scoreFloor)
|
||||
.slice(0, 4)
|
||||
.map(r => r.exe)
|
||||
|
||||
return selected.length > 0 ? selected : [ranked[0].exe]
|
||||
}
|
||||
@@ -1,81 +1,151 @@
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import { LocalGameScanResult, ScanProgressCallback } from './types'
|
||||
import fs from 'fs-extra'
|
||||
import { getWindowsDriveRoots, mapWithConcurrency, normalizeDisplayName, normalizeFsPath, pickRelatedExecutables, safeReadDir } from './utils'
|
||||
import { dedupeProcessNames, getWindowsDriveRoots, mapWithConcurrency, normalizeDisplayName, normalizeFsPath, parsePowershellJson, pickRelatedExecutables, safeReadDir } from './utils'
|
||||
import { runCommand } from '../../utils/command'
|
||||
|
||||
/**
|
||||
* WeGame 中代表存储集合的主索引父容器
|
||||
*/
|
||||
const WEGAME_CONTAINER_DIRS = new Set(['rail_apps', 'common_apps', 'apps', 'games'])
|
||||
/**
|
||||
* WeGame 原生系统级子容器或日志应用存放排除名单
|
||||
*/
|
||||
const WEGAME_NON_GAME_DIRS = new Set(['rail_apps', 'common_apps', 'app', 'apps', 'cache', 'logs', 'temp', 'patch'])
|
||||
|
||||
/**
|
||||
* WeGame 安装主干获取临时结构
|
||||
*/
|
||||
type WeGameInstallRow = {
|
||||
InstallPath?: string
|
||||
InstallDir?: string
|
||||
Path?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取腾讯原生组件的系统环境与注册表根路径
|
||||
* @returns 去重后的 WeGame App 常量库组落脚点
|
||||
*/
|
||||
async function getWeGameInstallRootsFromRegistry(): Promise<string[]> {
|
||||
if (process.platform !== 'win32') return []
|
||||
|
||||
try {
|
||||
const script = [
|
||||
"$keys = @(",
|
||||
" 'HKLM:\\SOFTWARE\\WOW6432Node\\Tencent\\WeGame',",
|
||||
" 'HKLM:\\SOFTWARE\\Tencent\\WeGame',",
|
||||
" 'HKCU:\\SOFTWARE\\Tencent\\WeGame'",
|
||||
")",
|
||||
"$items = foreach ($k in $keys) {",
|
||||
" if (Test-Path $k) {",
|
||||
" Get-ItemProperty -Path $k -ErrorAction SilentlyContinue |",
|
||||
" Select-Object InstallPath, InstallDir, Path",
|
||||
" }",
|
||||
"}",
|
||||
"$items | ConvertTo-Json -Compress"
|
||||
].join('; ')
|
||||
|
||||
const { code, output } = await runCommand('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], 7000)
|
||||
if (code !== 0 || !output) return []
|
||||
|
||||
const rows = parsePowershellJson<WeGameInstallRow>(output)
|
||||
const values = rows.flatMap(row => [row.InstallPath, row.InstallDir, row.Path])
|
||||
.map(v => String(v || '').trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const roots = new Set<string>()
|
||||
for (const val of values) {
|
||||
const normalized = path.normalize(val)
|
||||
roots.add(normalized)
|
||||
roots.add(path.join(normalized, 'apps'))
|
||||
roots.add(path.join(normalized, 'WeGameApps'))
|
||||
}
|
||||
|
||||
return Array.from(roots)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描腾讯 WeGame 游戏
|
||||
*/
|
||||
export async function scanWeGameGames(progressCallback?: ScanProgressCallback): Promise<LocalGameScanResult[]> {
|
||||
progressCallback?.('scanning_platform', 'WeGame')
|
||||
const results: LocalGameScanResult[] = []
|
||||
const dedup = new Map<string, LocalGameScanResult>()
|
||||
progressCallback?.('scanning_platform', 'WeGame')
|
||||
const dedup = new Map<string, LocalGameScanResult>()
|
||||
|
||||
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 [drives, regRoots] = await Promise.all([
|
||||
getWindowsDriveRoots(),
|
||||
getWeGameInstallRootsFromRegistry()
|
||||
])
|
||||
|
||||
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[] = []
|
||||
const candidateRoots = Array.from(new Set([
|
||||
...regRoots,
|
||||
...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')
|
||||
])
|
||||
]))
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const entryDir = path.join(root, entry.name)
|
||||
const entryLower = entry.name.toLowerCase()
|
||||
const gamesFromRoots = await mapWithConcurrency(candidateRoots, async (root) => {
|
||||
if (!await fs.pathExists(root)) return []
|
||||
progressCallback?.('scanning_dir', root)
|
||||
|
||||
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
|
||||
}
|
||||
const entries = await safeReadDir(root)
|
||||
const candidates: string[] = []
|
||||
|
||||
if (!WEGAME_NON_GAME_DIRS.has(entryLower)) {
|
||||
candidates.push(entryDir)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if (!WEGAME_NON_GAME_DIRS.has(entryLower)) {
|
||||
candidates.push(entryDir)
|
||||
}
|
||||
}
|
||||
|
||||
results.push(...dedup.values())
|
||||
return results
|
||||
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: dedupeProcessNames(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)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(dedup.values())
|
||||
}
|
||||
|
||||
|
||||
@@ -44,4 +44,7 @@ export type LocalScanGame = {
|
||||
processName: string[]
|
||||
source: string
|
||||
installDir: string
|
||||
/** 游戏图标 URL(可选) */
|
||||
iconUrl?: string
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { categoryApi, systemApi } from '@/api'
|
||||
import { useGameStore } from '@/stores/games'
|
||||
import { useCategoryStore } from '@/stores/categories'
|
||||
import type { Category, Game, LocalScanGame } from '@/types'
|
||||
import { PLATFORMS } from '@/constants'
|
||||
|
||||
// 将 isScanning 和 scanProgressText 提升到模块级别
|
||||
const isScanning = ref(false)
|
||||
@@ -59,15 +58,6 @@ export function useGameScanner() {
|
||||
return categoryStore.categories.find(c => String(c.name || '').toLowerCase() === normalizedName.toLowerCase())?.id || getFallbackCategoryId()
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保平台分类存在
|
||||
* @param source 游戏来源平台
|
||||
*/
|
||||
async function ensurePlatformCategoryId(source: LocalScanGame['source']): Promise<string> {
|
||||
const platformName = PLATFORMS.find((name) => name === source) || source
|
||||
return ensureCategoryByName(platformName)
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行扫描任务
|
||||
* 包括:扫描本地游戏库、自动添加新游戏、扫描运行进程、匹配运行状态
|
||||
@@ -122,12 +112,6 @@ export function useGameScanner() {
|
||||
* @returns 新增的游戏数量
|
||||
*/
|
||||
async function autoAddGamesFromLibraryScan(localGames: LocalScanGame[]) {
|
||||
for (const platform of PLATFORMS) {
|
||||
const hasPlatformGame = localGames.some(g => g.source === platform)
|
||||
if (!hasPlatformGame) continue
|
||||
await ensureCategoryByName(platform)
|
||||
}
|
||||
|
||||
// 构建现有游戏库的查找键集合,用于快速去重
|
||||
const existingGameKeys = new Set(
|
||||
gameStore.gameLibrary.flatMap((game: Game) => {
|
||||
@@ -153,7 +137,7 @@ export function useGameScanner() {
|
||||
|
||||
for (const item of candidates) {
|
||||
const matchedCategoryId = await categoryApi.match(item.name, item.processName)
|
||||
const platformCategoryId = await ensurePlatformCategoryId(item.source)
|
||||
const platformCategoryId = await ensureCategoryByName(item.source)
|
||||
const mergedCategories = Array.from(new Set(
|
||||
[platformCategoryId, matchedCategoryId, getFallbackCategoryId()]
|
||||
.map(v => String(v || '').trim())
|
||||
@@ -163,6 +147,7 @@ export function useGameScanner() {
|
||||
await gameStore.addGame({
|
||||
name: item.name,
|
||||
processName: item.processName,
|
||||
// iconUrl: item.iconUrl,
|
||||
category: categoryId,
|
||||
categories: mergedCategories,
|
||||
tags: [],
|
||||
|
||||
Vendored
+2
@@ -39,6 +39,7 @@ export interface SystemAPI {
|
||||
processName: string[]
|
||||
source: Platform
|
||||
installDir: string
|
||||
iconUrl?: string
|
||||
}>>
|
||||
getProcessTree: () => Promise<any>
|
||||
ping: (host: string) => Promise<number>
|
||||
@@ -141,3 +142,4 @@ declare global {
|
||||
logs: LogsAPI
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import fs from 'fs-extra'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { collectExePaths, pickRelatedExecutables } from '../../electron/services/scanners/utils'
|
||||
|
||||
const tempRoots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const root of tempRoots) {
|
||||
await fs.remove(root)
|
||||
}
|
||||
tempRoots.length = 0
|
||||
})
|
||||
|
||||
describe('scanner utils', () => {
|
||||
it('keeps root executable scanning when directory entry count is very large', async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'lagzero-scanner-utils-'))
|
||||
tempRoots.push(tempRoot)
|
||||
|
||||
const gameRoot = path.join(tempRoot, 'BigGame')
|
||||
await fs.ensureDir(gameRoot)
|
||||
|
||||
const chunks = 32
|
||||
const totalFiles = 3001
|
||||
const perChunk = Math.ceil(totalFiles / chunks)
|
||||
|
||||
for (let c = 0; c < chunks; c += 1) {
|
||||
const writes: Promise<void>[] = []
|
||||
const start = c * perChunk
|
||||
const end = Math.min(totalFiles, (c + 1) * perChunk)
|
||||
for (let i = start; i < end; i += 1) {
|
||||
writes.push(fs.writeFile(path.join(gameRoot, `dummy_${i}.tmp`), ''))
|
||||
}
|
||||
await Promise.all(writes)
|
||||
}
|
||||
|
||||
const gameExe = path.join(gameRoot, 'BigGame.exe')
|
||||
await fs.writeFile(gameExe, '')
|
||||
|
||||
const exes = await collectExePaths(gameRoot, 1)
|
||||
const names = exes.map((e) => path.basename(e).toLowerCase())
|
||||
|
||||
expect(names).toContain('biggame.exe')
|
||||
})
|
||||
|
||||
it('respects maxDepth boundary without scanning one level deeper', async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'lagzero-scanner-depth-'))
|
||||
tempRoots.push(tempRoot)
|
||||
|
||||
const gameRoot = path.join(tempRoot, 'DepthGame')
|
||||
const depth2Dir = path.join(gameRoot, 'Binaries')
|
||||
const depth3Dir = path.join(depth2Dir, 'Win64')
|
||||
|
||||
await fs.ensureDir(depth3Dir)
|
||||
await fs.writeFile(path.join(depth2Dir, 'DepthGame.exe'), '')
|
||||
await fs.writeFile(path.join(depth3Dir, 'DepthGame-Win64.exe'), '')
|
||||
|
||||
const exes = await collectExePaths(gameRoot, 2)
|
||||
const names = exes.map((e) => path.basename(e).toLowerCase())
|
||||
|
||||
expect(names).toContain('depthgame.exe')
|
||||
expect(names).not.toContain('depthgame-win64.exe')
|
||||
})
|
||||
|
||||
it('filters obvious archive helper executables from selected process names', async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'lagzero-scanner-pick-'))
|
||||
tempRoots.push(tempRoot)
|
||||
|
||||
const gameRoot = path.join(tempRoot, 'Cyberpunk 2077')
|
||||
await fs.ensureDir(gameRoot)
|
||||
|
||||
await fs.writeFile(path.join(gameRoot, 'Cyberpunk2077.exe'), '')
|
||||
await fs.writeFile(path.join(gameRoot, 'REDprelauncher.exe'), '')
|
||||
await fs.writeFile(path.join(gameRoot, '7za.exe'), '')
|
||||
|
||||
const exes = await pickRelatedExecutables(gameRoot, 'Cyberpunk 2077')
|
||||
const names = exes.map((e) => path.basename(e).toLowerCase())
|
||||
|
||||
expect(names).toContain('cyberpunk2077.exe')
|
||||
expect(names).not.toContain('7za.exe')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user