From bcc96d5fe51d8012e4f16471012cb69e60a55ff0 Mon Sep 17 00:00:00 2001
From: yasder5 <102179445+Yasder5@users.noreply.github.com>
Date: Tue, 6 Oct 2026 21:04:55 +0200
Subject: [PATCH 1/2] =?UTF-8?q?Scanners=20Steam,=20Epic,=20GOG,=20Ubisoft?=
=?UTF-8?q?=20Connect=20et=20EA=20app=20(registre,=20VDF/ACF,=20manifestes?=
=?UTF-8?q?),=20isol=C3=A9s=20derri=C3=A8re=20une=20interface=20commune?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/main/scanners/ea.ts | 36 ++++++++++
src/main/scanners/epic.ts | 54 +++++++++++++++
src/main/scanners/gog.ts | 34 +++++++++
src/main/scanners/index.ts | 46 +++++++++++++
src/main/scanners/merge.ts | 56 +++++++++++++++
src/main/scanners/reg.ts | 34 +++++++++
src/main/scanners/steam.ts | 73 ++++++++++++++++++++
src/main/scanners/types.ts | 18 +++++
src/main/scanners/ubisoft.ts | 25 +++++++
src/main/scanners/vdf.ts | 47 +++++++++++++
tests/fixtures/appmanifest_367520.acf | 13 ++++
tests/fixtures/epic.item | 13 ++++
tests/fixtures/gog-reg.txt | 19 +++++
tests/fixtures/installerdata.xml | 13 ++++
tests/fixtures/libraryfolders.vdf | 21 ++++++
tests/scanners.test.ts | 99 +++++++++++++++++++++++++++
16 files changed, 601 insertions(+)
create mode 100644 src/main/scanners/ea.ts
create mode 100644 src/main/scanners/epic.ts
create mode 100644 src/main/scanners/gog.ts
create mode 100644 src/main/scanners/index.ts
create mode 100644 src/main/scanners/merge.ts
create mode 100644 src/main/scanners/reg.ts
create mode 100644 src/main/scanners/steam.ts
create mode 100644 src/main/scanners/types.ts
create mode 100644 src/main/scanners/ubisoft.ts
create mode 100644 src/main/scanners/vdf.ts
create mode 100644 tests/fixtures/appmanifest_367520.acf
create mode 100644 tests/fixtures/epic.item
create mode 100644 tests/fixtures/gog-reg.txt
create mode 100644 tests/fixtures/installerdata.xml
create mode 100644 tests/fixtures/libraryfolders.vdf
create mode 100644 tests/scanners.test.ts
diff --git a/src/main/scanners/ea.ts b/src/main/scanners/ea.ts
new file mode 100644
index 0000000..d882f03
--- /dev/null
+++ b/src/main/scanners/ea.ts
@@ -0,0 +1,36 @@
+/* EA app (et anciens jeux Origin) : dossiers d'installation dans le registre ; l'exe vient de
+ __Installer\installerdata.xml ([clé]\jeu.exe). Lancement direct : l'EA app prend le relais pour les DRM. */
+import { win32 as path } from 'node:path'
+import { children } from './reg'
+import type { Scanner, ScanEnv, ScannedGame } from './types'
+
+export const EA_KEYS = ['HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\EA Games', 'HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Origin Games']
+
+/* Exécutable et titre dans installerdata.xml */
+export function parseInstallerData(xml: string): { exe?: string; title?: string; id?: string } {
+ const fp = xml.match(/[\s\S]*?([^<]+)<\/filePath>/i)?.[1]
+ const exe = fp?.replace(/^\[[^\]]*\]/, '').replace(/^[\\/]+/, '').trim()
+ // titre anglais en priorité : c'est sous ce nom que les jaquettes sont trouvées
+ const title = xml.match(/]*locale="en_US"[^>]*>([^<]+)]*>([^<]+)([^<]+)<\/contentID>/i)?.[1]
+ return { exe: exe || undefined, title: title?.trim(), id: id?.trim() }
+}
+
+export async function eaGames(env: ScanEnv): Promise {
+ const out: ScannedGame[] = []
+ for (const key of EA_KEYS) {
+ for (const [sub, v] of children(await env.reg(key), key)) {
+ const dir = v['Install Dir'] ?? v.InstallDir
+ if (!dir) continue
+ const base = path.normalize(dir)
+ const xml = path.join(base, '__Installer', 'installerdata.xml')
+ const info = env.exists(xml) ? parseInstallerData(env.readText(xml)) : {}
+ const exe = info.exe ? path.join(base, info.exe) : undefined
+ if (!exe || !env.exists(exe)) continue // sans exe fiable on ne propose pas le jeu
+ out.push({ platform: 'ea', platformId: info.id ?? sub, name: v.DisplayName ?? info.title ?? sub, installDir: base, exe, workDir: path.dirname(exe), installed: true })
+ }
+ }
+ return out
+}
+
+export const ea: Scanner = { id: 'ea', name: 'EA app', scan: eaGames }
diff --git a/src/main/scanners/epic.ts b/src/main/scanners/epic.ts
new file mode 100644
index 0000000..c96d28e
--- /dev/null
+++ b/src/main/scanners/epic.ts
@@ -0,0 +1,54 @@
+/* Epic Games : manifestes JSON *.item dans ProgramData, lancement par le protocole du launcher Epic. */
+import { win32 as path } from 'node:path'
+import type { Scanner, ScannedGame } from './types'
+
+interface EpicItem {
+ DisplayName?: string
+ InstallLocation?: string
+ LaunchExecutable?: string
+ LaunchCommand?: string
+ CatalogNamespace?: string
+ CatalogItemId?: string
+ AppName?: string
+ MainGameAppName?: string
+ bIsIncompleteInstall?: boolean
+ bIsApplication?: boolean
+ AppCategories?: string[]
+}
+
+export function parseEpicItem(json: string): ScannedGame | null {
+ const it = JSON.parse(json) as EpicItem
+ if (!it.AppName || !it.DisplayName || !it.CatalogNamespace || !it.CatalogItemId) return null
+ if (it.MainGameAppName && it.MainGameAppName !== it.AppName) return null // DLC
+ const cats = it.AppCategories ?? []
+ if (cats.length && !cats.includes('games')) return null // Unreal Engine, plugins…
+ const id = `${it.CatalogNamespace}:${it.CatalogItemId}:${it.AppName}`
+ return {
+ platform: 'epic',
+ platformId: it.AppName,
+ name: it.DisplayName,
+ installDir: it.InstallLocation ? path.normalize(it.InstallLocation) : undefined,
+ exe: it.InstallLocation && it.LaunchExecutable ? path.join(it.InstallLocation, it.LaunchExecutable) : undefined,
+ launchUri: `com.epicgames.launcher://apps/${encodeURIComponent(id)}?action=launch&silent=true`,
+ installed: !it.bIsIncompleteInstall
+ }
+}
+
+export const epic: Scanner = {
+ id: 'epic',
+ name: 'Epic Games',
+ async scan(env) {
+ const dir = path.join(env.programData, 'Epic', 'EpicGamesLauncher', 'Data', 'Manifests')
+ const out: ScannedGame[] = []
+ for (const f of env.list(dir)) {
+ if (!f.toLowerCase().endsWith('.item')) continue
+ try {
+ const g = parseEpicItem(env.readText(path.join(dir, f)))
+ if (g) out.push(g)
+ } catch {
+ /* manifeste illisible : on passe au suivant */
+ }
+ }
+ return out
+ }
+}
diff --git a/src/main/scanners/gog.ts b/src/main/scanners/gog.ts
new file mode 100644
index 0000000..4b48ea6
--- /dev/null
+++ b/src/main/scanners/gog.ts
@@ -0,0 +1,34 @@
+/* GOG Galaxy : une sous-clé de registre par jeu, lancement direct de l'exe (Galaxy pas nécessaire). */
+import { win32 as path } from 'node:path'
+import { children } from './reg'
+import type { Scanner, ScannedGame } from './types'
+
+export const GOG_KEY = 'HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\GOG.com\\Games'
+
+export function gogFromReg(map: Map>): ScannedGame[] {
+ const out: ScannedGame[] = []
+ for (const [id, v] of children(map, GOG_KEY)) {
+ if (v.dependsOn) continue // DLC
+ const name = v.gameName ?? v.GAMENAME, exe = v.exe ?? v.EXE, dir = v.path ?? v.PATH
+ if (!name || !exe) continue
+ out.push({
+ platform: 'gog',
+ platformId: v.gameID ?? id,
+ name,
+ installDir: dir ? path.normalize(dir) : path.dirname(exe),
+ exe: path.normalize(exe),
+ args: v.launchParam || undefined,
+ workDir: v.workingDir ? path.normalize(v.workingDir) : undefined,
+ installed: true
+ })
+ }
+ return out
+}
+
+export const gog: Scanner = {
+ id: 'gog',
+ name: 'GOG Galaxy',
+ async scan(env) {
+ return gogFromReg(await env.reg(GOG_KEY)).filter((g) => env.exists(g.exe!))
+ }
+}
diff --git a/src/main/scanners/index.ts b/src/main/scanners/index.ts
new file mode 100644
index 0000000..f2014af
--- /dev/null
+++ b/src/main/scanners/index.ts
@@ -0,0 +1,46 @@
+/* Lance les scanners actifs en parallèle ; un scanner qui échoue ou traîne est ignoré et journalisé. */
+import { existsSync, readdirSync, readFileSync } from 'node:fs'
+import type { ScannerId, Settings } from '../../shared/types'
+import { log } from '../log'
+import { ea } from './ea'
+import { epic } from './epic'
+import { gog } from './gog'
+import { regQuery } from './reg'
+import { steam } from './steam'
+import type { Scanner, ScanEnv, ScannedGame } from './types'
+import { ubisoft } from './ubisoft'
+
+const L = log('scan')
+export const ALL_SCANNERS: Scanner[] = [steam, epic, gog, ubisoft, ea]
+
+export const realEnv: ScanEnv = {
+ programData: process.env.PROGRAMDATA ?? 'C:\\ProgramData',
+ reg: regQuery,
+ exists: (p) => existsSync(p),
+ readText: (p) => readFileSync(p, 'utf8'),
+ list: (d) => { try { return readdirSync(d) } catch { return [] } }
+}
+
+export interface ScanResult {
+ ok: Partial> // scanners terminés (même avec 0 jeu)
+ failed: ScannerId[]
+}
+
+const withTimeout = (p: Promise, ms: number) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(`délai dépassé (${ms} ms)`)), ms))])
+
+export async function runScanners(settings: Settings, env: ScanEnv = realEnv, scanners = ALL_SCANNERS): Promise {
+ const res: ScanResult = { ok: {}, failed: [] }
+ await Promise.all(
+ scanners.filter((s) => settings.scanners[s.id]).map(async (s) => {
+ const t = Date.now()
+ try {
+ res.ok[s.id] = await withTimeout(s.scan(env), 30000)
+ L.info(s.name, res.ok[s.id]!.length, 'jeux en', Date.now() - t, 'ms')
+ } catch (e) {
+ res.failed.push(s.id)
+ L.error(s.name, 'a échoué', e)
+ }
+ })
+ )
+ return res
+}
diff --git a/src/main/scanners/merge.ts b/src/main/scanners/merge.ts
new file mode 100644
index 0000000..581abb7
--- /dev/null
+++ b/src/main/scanners/merge.ts
@@ -0,0 +1,56 @@
+/* Fusion d'un scan avec la bibliothèque existante. Pur : testé sans Windows.
+ - jeu nouveau : ajouté (addedAt = maintenant)
+ - jeu connu : chemins / lancement mis à jour, données de l'utilisateur gardées (favori, temps, collections, nom modifié)
+ - jeu d'un scanner terminé mais plus trouvé : marqué désinstallé (jamais supprimé : on garde temps de jeu et collections)
+ - scanner en échec ou désactivé : ses jeux ne bougent pas
+ - doublons : même id, ou même dossier d'installation qu'un jeu d'une plateforme (le jeu ajouté à la main est fusionné dedans) */
+import type { Game, ScannerId } from '../../shared/types'
+import type { ScanResult } from './index'
+import type { ScannedGame } from './types'
+
+export const gameId = (g: Pick) => `${g.platform}:${g.platformId}`
+const norm = (p?: string) => p?.toLowerCase().replace(/[\\/]+$/, '').replace(/\//g, '\\')
+
+export interface MergeStats { added: number; updated: number; uninstalled: number; merged: number }
+
+export function mergeScan(existing: Game[], result: Pick, now = Date.now()): { games: Game[]; stats: MergeStats } {
+ const byId = new Map(existing.map((g) => [g.id, { ...g }]))
+ const stats: MergeStats = { added: 0, updated: 0, uninstalled: 0, merged: 0 }
+ const seen = new Set()
+
+ for (const [scanner, list] of Object.entries(result.ok) as [ScannerId, ScannedGame[]][]) {
+ for (const s of list) {
+ const id = gameId(s)
+ if (seen.has(id)) continue // même jeu listé deux fois (deux bibliothèques Steam…)
+ seen.add(id)
+ const old = byId.get(id)
+ const fresh = { installDir: s.installDir, exe: s.exe ?? old?.exe, launchUri: s.launchUri, steamAppId: s.steamAppId ?? old?.steamAppId, installed: s.installed ?? true }
+ if (old) {
+ Object.assign(old, fresh, old.nameLocked ? {} : { name: s.name }, old.argsLocked ? {} : { args: s.args, workDir: s.workDir })
+ stats.updated++
+ } else {
+ byId.set(id, { id, platform: s.platform, platformId: s.platformId, name: s.name, args: s.args, workDir: s.workDir, ...fresh, favorite: false, hidden: false, addedAt: now, playtime: 0, collections: [], media: {} })
+ stats.added++
+ }
+ }
+ for (const g of byId.values()) {
+ if (g.platform === scanner && !seen.has(g.id) && g.installed) { g.installed = false; stats.uninstalled++ }
+ }
+ }
+
+ // un jeu ajouté à la main dont l'exe est dans le dossier d'un jeu détecté : on fusionne dans le jeu détecté
+ const platformGames = [...byId.values()].filter((g) => g.platform !== 'manual' && g.installDir)
+ for (const m of [...byId.values()].filter((g) => g.platform === 'manual' && g.exe)) {
+ const exe = norm(m.exe)!
+ const host = platformGames.find((g) => exe.startsWith(norm(g.installDir)! + '\\'))
+ if (!host) continue
+ host.playtime += m.playtime
+ host.favorite ||= m.favorite
+ host.lastPlayed = Math.max(host.lastPlayed ?? 0, m.lastPlayed ?? 0) || undefined
+ host.collections = [...new Set([...host.collections, ...m.collections])]
+ host.media = { ...m.media, ...host.media }
+ byId.delete(m.id)
+ stats.merged++
+ }
+ return { games: [...byId.values()], stats }
+}
diff --git a/src/main/scanners/reg.ts b/src/main/scanners/reg.ts
new file mode 100644
index 0000000..586591b
--- /dev/null
+++ b/src/main/scanners/reg.ts
@@ -0,0 +1,34 @@
+/* Lecture du registre Windows via reg.exe (pas de module natif). Parseur pur, testé. */
+import { execFile } from 'node:child_process'
+
+/* Sortie de `reg query /s` -> clé complète -> { nomDeValeur: donnée } ; la valeur par défaut a le nom "" */
+export function parseReg(out: string): Map> {
+ const res = new Map>()
+ let cur: Record | null = null
+ for (const raw of out.split(/\r?\n/)) {
+ if (!raw.trim()) continue
+ if (/^HKEY_/i.test(raw)) {
+ cur = {}
+ res.set(raw.trim().toUpperCase(), cur)
+ continue
+ }
+ const m = raw.match(/^\s{2,}(.*?)\s{2,}(REG_\w+)(?:\s{2,}(.*))?$/)
+ if (m && cur) cur[m[1] === '(Default)' || m[1] === '(par défaut)' ? '' : m[1]] = m[3] ?? ''
+ }
+ return res
+}
+
+export function regQuery(key: string): Promise