feat(main): 增强主进程日志与游戏扫描稳定性与性能

- 主进程新增 CrashReporter 捕获崩溃信息,提升诊断能力
- 优化 ipcMain 游戏扫描进度事件,加入节流防止 IPC 风暴
- 扫描平台分工重构,支持并发执行,添加执行日志和错误捕获
- 完善扫描结果去重逻辑,优先基于标准化安装目录合并游戏条目
- 增加未捕获异常、渲染进程退出和子进程退出日志,提升稳定性监控
- 更新日志模块,错误级日志立即同步落盘,降低闪退日志丢失风险
- 延长日志落盘监听事件,兼容多种退出信号安全清理日志
- 主进程窗口配置修改,始终保留调试工具,提高开发调试便捷性
- 优化扫描服务代码注释和参数说明,提升可维护性和易读性
This commit is contained in:
ZenEcho committed 2026-03-05 20:18:18 +08:00
1 parent 78d6271882
commit 3cf582e71b
19 files changed
+1907 -596

No files matched your search

+579
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
}
})
}
+2 -1
View File
@@ -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,
},
+66 -25
View File
@@ -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
}
}
+180 -27
View File
@@ -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
View File
@@ -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())
}
+81 -52
View File
@@ -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())
}
+34 -23
View File
@@ -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()
}
+84 -89
View File
@@ -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[]
}
+63 -47
View File
@@ -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())
+166 -65
View File
@@ -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())
}
+7
View File
@@ -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',
])
+152 -44
View File
@@ -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]
}
+131 -61
View File
@@ -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())
}
+3
View File
@@ -44,4 +44,7 @@ export type LocalScanGame = {
processName: string[]
source: string
installDir: string
/** 游戏图标 URL(可选) */
iconUrl?: string
}
+2 -17
View File
@@ -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: [],
+2
View File
@@ -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
}
}
+83
View File
@@ -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')
})
})