diff --git a/docs/electron-scanners-deep-dive.md b/docs/electron-scanners-deep-dive.md new file mode 100644 index 0000000..3b64ac4 --- /dev/null +++ b/docs/electron-scanners-deep-dive.md @@ -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/.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` 目录可能会被随机丢弃,从而彻底丧失被扫描到的机会。 diff --git a/electron/main/index.ts b/electron/main/index.ts index b773cab..848011e 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -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) }) }) + + diff --git a/electron/main/logger.ts b/electron/main/logger.ts index 17706f9..5243aae 100644 --- a/electron/main/logger.ts +++ b/electron/main/logger.ts @@ -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) { } }) } + + + diff --git a/electron/main/window.ts b/electron/main/window.ts index 28458f8..09a6985 100644 --- a/electron/main/window.ts +++ b/electron/main/window.ts @@ -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, }, diff --git a/electron/services/game-scanner.ts b/electron/services/game-scanner.ts index f73800c..5d1a319 100644 --- a/electron/services/game-scanner.ts +++ b/electron/services/game-scanner.ts @@ -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 { if (process.platform !== 'win32') return [] - const scanners = [ - scanSteamGames, - scanMicrosoftGames, - scanEpicGames, - scanEAGames, - scanBattleNetGames, - scanWeGameGames, - scanLocalShortcuts + const scanners: Array<{ name: string, run: (progressCallback?: ScanProgressCallback) => Promise }> = [ + { 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() - 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() + const fallback = new Map() + + 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 { let results: string[] = [] @@ -74,3 +114,4 @@ export class GameScannerService { return results } } + diff --git a/electron/services/scanners/battlenet.ts b/electron/services/scanners/battlenet.ts index 70930bf..c494b5d 100644 --- a/electron/services/scanners/battlenet.ts +++ b/electron/services/scanners/battlenet.ts @@ -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> { + 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(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 { + 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 { - progressCallback?.('scanning_platform', 'BattleNet') - const results: LocalGameScanResult[] = [] - const dedup = new Map() + progressCallback?.('scanning_platform', 'BattleNet') + const dedup = new Map() - const drives = await getWindowsDriveRoots() - const candidateRoots = Array.from(new Set(drives.flatMap(root => [ - path.join(root, 'Program Files (x86)', 'Battle.net'), - path.join(root, 'Program Files', 'Battle.net'), - path.join(root, 'Program Files (x86)'), - path.join(root, 'Program Files'), - path.join(root, 'Games'), - path.join(root, 'Game') - ]))) + 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()) } + + diff --git a/electron/services/scanners/ea.ts b/electron/services/scanners/ea.ts index 858a2ab..6fe9473 100644 --- a/electron/services/scanners/ea.ts +++ b/electron/services/scanners/ea.ts @@ -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 { - 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(output) + return rows + .map(v => path.normalize(String(v || '').trim())) + .filter(Boolean) + } catch { + return [] + } } /** * 从卸载注册表读取 EA 游戏安装目录与名称 */ -async function getEAUninstallHints(): Promise> { - if (process.platform !== 'win32') return [] - try { - const script = [ - "$roots = @(", - " 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',", - " 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',", - " 'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'", - ")", - "$items = foreach ($r in $roots) {", - " Get-ItemProperty -Path $r -ErrorAction SilentlyContinue |", - " Where-Object { $_.DisplayName -and $_.InstallLocation -and (($_.Publisher -match '(?i)Electronic\\s+Arts|\\bEA\\b') -or ($_.DisplayName -match '(?i)\\bEA\\b|\\bOrigin\\b|Electronic\\s+Arts')) } |", - " Select-Object DisplayName, InstallLocation", - "}", - "$items | ConvertTo-Json -Compress" - ].join('; ') - const { code, output } = await runCommand('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], 8000) - if (code !== 0 || !output) return [] - const text = output.trim() - const rows = text.startsWith('[') ? JSON.parse(text) : [JSON.parse(text)] - return rows - .map((row: any) => ({ - name: String(row?.DisplayName || '').trim(), - installDir: path.normalize(String(row?.InstallLocation || '').trim()) - })) - .filter((row: any) => row.name && row.installDir) - } catch { - return [] - } +async function getEAUninstallHints(): Promise { + if (process.platform !== 'win32') return [] + + try { + const script = [ + "$roots = @(", + " 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',", + " 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',", + " 'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'", + ")", + "$items = foreach ($r in $roots) {", + " Get-ItemProperty -Path $r -ErrorAction SilentlyContinue |", + " Where-Object { $_.DisplayName -and $_.InstallLocation -and (($_.Publisher -match '(?i)Electronic\\s+Arts|\\bEA\\b') -or ($_.DisplayName -match '(?i)\\bEA\\b|\\bOrigin\\b|Electronic\\s+Arts')) } |", + " Select-Object DisplayName, InstallLocation", + "}", + "$items | ConvertTo-Json -Compress" + ].join('; ') + + const { code, output } = await runCommand('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], 9000) + if (code !== 0 || !output) return [] + + return parsePowershellJson(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 { + 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 { - progressCallback?.('scanning_platform', 'EA') - const results: LocalGameScanResult[] = [] - const dedup = new Map() + progressCallback?.('scanning_platform', 'EA') + const dedup = new Map() - // 1. 通过注册表扫描 - const regDirs = await getEARegistryInstalls() - for (const dir of regDirs) { - if (await fs.pathExists(dir)) { - const folderName = path.basename(dir) - if (isEALauncherEntry(folderName, dir)) continue - progressCallback?.('scanning_dir', dir) - const displayName = normalizeDisplayName(folderName) - const exes = await pickRelatedExecutables(dir, displayName, progressCallback) - if (exes && exes.length > 0) { - const game: LocalGameScanResult = { - name: displayName, - processName: exes.map(e => path.basename(e)), - source: 'EA', - installDir: dir - } - dedup.set(normalizeFsPath(dir), game) - } - } - } + 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()) } + diff --git a/electron/services/scanners/epic.ts b/electron/services/scanners/epic.ts index 082e03b..b65f29a 100644 --- a/electron/services/scanners/epic.ts +++ b/electron/services/scanners/epic.ts @@ -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 { - progressCallback?.('scanning_platform', 'Epic') - const results: LocalGameScanResult[] = [] - const dedup = new Map() + progressCallback?.('scanning_platform', 'Epic') + const dedup = new Map() - // 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()) } + diff --git a/electron/services/scanners/flat.ts b/electron/services/scanners/flat.ts index 72bce8a..4e5284c 100644 --- a/electron/services/scanners/flat.ts +++ b/electron/services/scanners/flat.ts @@ -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 + /** 如果提供,则执行自定义校验方法通过的才会被扫描 */ + 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 { - 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() } + diff --git a/electron/services/scanners/local.ts b/electron/services/scanners/local.ts index 05aedf1..aa54721 100644 --- a/electron/services/scanners/local.ts +++ b/electron/services/scanners/local.ts @@ -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 { - 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() + const dedup = new Set() - const dirsToScan = [ - path.join(os.homedir(), 'Desktop'), - path.join(process.env.PUBLIC || 'C:\\Users\\Public', 'Desktop'), - path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Microsoft', 'Windows', 'Start Menu', 'Programs'), - path.join(process.env.PROGRAMDATA || 'C:\\ProgramData', 'Microsoft', 'Windows', 'Start Menu', 'Programs') - ] + const 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[] } diff --git a/electron/services/scanners/microsoft.ts b/electron/services/scanners/microsoft.ts index c2492d6..33c4390 100644 --- a/electron/services/scanners/microsoft.ts +++ b/electron/services/scanners/microsoft.ts @@ -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) { const dirKey = normalizeFsPath(installDir) @@ -25,24 +28,9 @@ function pickBestNameFromHints(installDir: string, hints: Map) { return bestName } -/** - * 解析 PowerShell 返回的 JSON 字符串 - */ -function parsePowershellJson(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> { const map = new Map() @@ -78,6 +66,8 @@ async function getRegistryDisplayNameHints(): Promise> { /** * 从 AppxManifest.xml 内容中提取显示名称 + * @param content Application Manifest 文本 + * @returns 暴露出来的主观标识名字 */ function parseDisplayNameFromManifest(content: string): string { const displayName = content.match(/([^<]+)<\/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> { const map = new Map() - 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 + }, 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 { 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() 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()) diff --git a/electron/services/scanners/steam.ts b/electron/services/scanners/steam.ts index d468fec..18c0e25 100644 --- a/electron/services/scanners/steam.ts +++ b/electron/services/scanners/steam.ts @@ -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() + 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 { const drives = await getWindowsDriveRoots() - const candidateSteamRoots = Array.from(new Set([ + const candidateSteamRoots = new Set([ ...drives.flatMap(root => [ root, path.join(root, 'Program Files (x86)', 'Steam'), @@ -21,33 +121,40 @@ async function readSteamLibraryRoots(): Promise { 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 { ].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(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() - 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 { - 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 { progressCallback?.('scanning_platform', 'Steam') const roots = await readSteamLibraryRoots() - const results: LocalGameScanResult[] = [] const dedup = new Map() for (const libRoot of roots) { - const manifestResults = await scanSteamFromManifests(libRoot, 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()) } + diff --git a/electron/services/scanners/types.ts b/electron/services/scanners/types.ts index 5e353c4..1334327 100644 --- a/electron/services/scanners/types.ts +++ b/electron/services/scanners/types.ts @@ -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', ]) + diff --git a/electron/services/scanners/utils.ts b/electron/services/scanners/utils.ts index 245df6f..e43b262 100644 --- a/electron/services/scanners/utils.ts +++ b/electron/services/scanners/utils.ts @@ -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(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() + 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( /** * 获取 Windows 系统下的所有驱动器根目录 (C:\, D:\, ...) + * 并发探测盘符,避免串行等待。 */ export async function getWindowsDriveRoots(): Promise { 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 { const results: string[] = [] - const dirents = await safeReadDir(dir) + const pending: DirScanTask[] = [{ dir, depth: currentDepth }] + const visited = new Set() - // 性能优化:单层目录文件异常多时直接跳过,防止卡死 - 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] } diff --git a/electron/services/scanners/wegame.ts b/electron/services/scanners/wegame.ts index 9173640..f6b07b1 100644 --- a/electron/services/scanners/wegame.ts +++ b/electron/services/scanners/wegame.ts @@ -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 { + 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(output) + const values = rows.flatMap(row => [row.InstallPath, row.InstallDir, row.Path]) + .map(v => String(v || '').trim()) + .filter(Boolean) + + const roots = new Set() + 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 { - progressCallback?.('scanning_platform', 'WeGame') - const results: LocalGameScanResult[] = [] - const dedup = new Map() + progressCallback?.('scanning_platform', 'WeGame') + const dedup = new Map() - const drives = await getWindowsDriveRoots() - const candidateRoots = Array.from(new Set(drives.flatMap(root => [ - path.join(root, 'WeGameApps'), - path.join(root, 'WeGameApp'), - path.join(root, 'Tencent', 'WeGame', 'apps'), - path.join(root, 'Program Files', 'Tencent', 'WeGame', 'apps'), - path.join(root, 'Program Files (x86)', 'Tencent', 'WeGame', 'apps'), - path.join(root, 'Program Files', 'WeGameApps'), - path.join(root, 'Program Files (x86)', 'WeGameApps') - ]))) + const [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()) } + + diff --git a/shared/types/game.ts b/shared/types/game.ts index 739639c..368ac17 100644 --- a/shared/types/game.ts +++ b/shared/types/game.ts @@ -44,4 +44,7 @@ export type LocalScanGame = { processName: string[] source: string installDir: string + /** 游戏图标 URL(可选) */ + iconUrl?: string } + diff --git a/src/composables/useGameScanner.ts b/src/composables/useGameScanner.ts index e608c7c..744dd43 100644 --- a/src/composables/useGameScanner.ts +++ b/src/composables/useGameScanner.ts @@ -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 { - 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: [], diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 9b1f268..99adaca 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -39,6 +39,7 @@ export interface SystemAPI { processName: string[] source: Platform installDir: string + iconUrl?: string }>> getProcessTree: () => Promise ping: (host: string) => Promise @@ -141,3 +142,4 @@ declare global { logs: LogsAPI } } + diff --git a/tests/unit/scanner-utils.spec.ts b/tests/unit/scanner-utils.spec.ts new file mode 100644 index 0000000..ad64a6f --- /dev/null +++ b/tests/unit/scanner-utils.spec.ts @@ -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[] = [] + 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') + }) +})