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>> { + if (process.platform !== 'win32') return Promise.resolve(new Map()) + return new Promise((resolve) => { + execFile('reg', ['query', key, '/s'], { windowsHide: true, timeout: 15000, maxBuffer: 32 * 1024 * 1024 }, (err, stdout) => { + resolve(err ? new Map() : parseReg(stdout)) // clé absente = rien d'installé, pas une erreur + }) + }) +} + +/* Sous-clés directes d'une clé (une entrée par jeu chez GOG / Ubisoft) */ +export function children(map: Map>, parent: string): [string, Record][] { + const p = parent.toUpperCase() + '\\' + return [...map].filter(([k]) => k.startsWith(p) && !k.slice(p.length).includes('\\')).map(([k, v]) => [k.slice(p.length), v]) +} diff --git a/src/main/scanners/steam.ts b/src/main/scanners/steam.ts new file mode 100644 index 0000000..ebeaea6 --- /dev/null +++ b/src/main/scanners/steam.ts @@ -0,0 +1,73 @@ +/* Steam : dossier via le registre, bibliothèques via libraryfolders.vdf, jeux via appmanifest_*.acf. */ +import { win32 as path } from 'node:path' +import type { Scanner, ScanEnv, ScannedGame } from './types' +import { get, parseVdf, type Vdf } from './vdf' + +// outils et runtimes listés comme des jeux par Steam +const NOT_GAMES = /^(Steamworks Common Redistributables|Steam Linux Runtime.*|Proton.*|SteamVR.*|Steam Controller Configs|Spacewar)$/i +const TOOL_IDS = new Set(['228980', '1070560', '1391110', '1628350', '250820', '1493710']) + +export async function steamRoot(env: ScanEnv): Promise { + for (const key of ['HKCU\\Software\\Valve\\Steam', 'HKLM\\SOFTWARE\\WOW6432Node\\Valve\\Steam', 'HKLM\\SOFTWARE\\Valve\\Steam']) { + for (const vals of (await env.reg(key)).values()) { + const p = vals.SteamPath ?? vals.InstallPath + if (p) return path.normalize(p) + } + } + const def = 'C:\\Program Files (x86)\\Steam' + return env.exists(def) ? def : null +} + +/* Dossiers de bibliothèque : format récent ("0" { "path" … }) et ancien ("1" "D:\\Steam") */ +export function libraryPaths(vdfText: string, root: string): string[] { + const lf = get(parseVdf(vdfText), 'libraryfolders') + const out = new Set([path.normalize(root)]) + if (lf && typeof lf === 'object') { + for (const [k, v] of Object.entries(lf)) { + if (!/^\d+$/.test(k)) continue + const p = typeof v === 'string' ? v : (get(v, 'path') as string | undefined) + if (p) out.add(path.normalize(p)) + } + } + return [...out] +} + +export function parseManifest(acf: string, library: string): ScannedGame | null { + const st = get(parseVdf(acf), 'AppState') as Vdf | undefined + const appid = get(st, 'appid'), name = get(st, 'name'), dir = get(st, 'installdir'), flags = Number(get(st, 'StateFlags') ?? 0) + if (typeof appid !== 'string' || typeof name !== 'string' || TOOL_IDS.has(appid) || NOT_GAMES.test(name)) return null + return { + platform: 'steam', + platformId: appid, + name, + steamAppId: appid, + installDir: typeof dir === 'string' ? path.join(library, 'steamapps', 'common', dir) : undefined, + launchUri: `steam://rungameid/${appid}`, + installed: (flags & 4) === 4 // 4 = entièrement installé + } +} + +export const steam: Scanner = { + id: 'steam', + name: 'Steam', + async scan(env) { + const root = await steamRoot(env) + if (!root) return [] + const vdf = path.join(root, 'steamapps', 'libraryfolders.vdf') + const libs = env.exists(vdf) ? libraryPaths(env.readText(vdf), root) : [root] + const games: ScannedGame[] = [] + for (const lib of libs) { + const apps = path.join(lib, 'steamapps') + for (const f of env.list(apps)) { + if (!/^appmanifest_\d+\.acf$/i.test(f)) continue + try { + const g = parseManifest(env.readText(path.join(apps, f)), lib) + if (g) games.push(g) + } catch { + /* manifeste en cours d'écriture par Steam : ignoré ce coup-ci */ + } + } + } + return games + } +} diff --git a/src/main/scanners/types.ts b/src/main/scanners/types.ts new file mode 100644 index 0000000..8189e55 --- /dev/null +++ b/src/main/scanners/types.ts @@ -0,0 +1,18 @@ +/* Interface commune des scanners : chacun est isolé, peut échouer sans gêner les autres. */ +import type { Game, ScannerId } from '../../shared/types' + +export type ScannedGame = Pick & Partial> + +export interface ScanEnv { + programData: string // C:\ProgramData + reg: (key: string) => Promise>> // lecture du registre (récursive) + exists: (p: string) => boolean + readText: (p: string) => string + list: (dir: string) => string[] // noms de fichiers d'un dossier ([] s'il n'existe pas) +} + +export interface Scanner { + id: ScannerId + name: string + scan: (env: ScanEnv) => Promise +} diff --git a/src/main/scanners/ubisoft.ts b/src/main/scanners/ubisoft.ts new file mode 100644 index 0000000..152367a --- /dev/null +++ b/src/main/scanners/ubisoft.ts @@ -0,0 +1,25 @@ +/* Ubisoft Connect : jeux installés dans le registre, lancement via uplay://. Le nom vient du dossier d'installation. */ +import { win32 as path } from 'node:path' +import { children } from './reg' +import type { Scanner, ScannedGame } from './types' + +export const UBI_KEY = 'HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Ubisoft\\Launcher\\Installs' + +export function ubisoftFromReg(map: Map>): ScannedGame[] { + const out: ScannedGame[] = [] + for (const [id, v] of children(map, UBI_KEY)) { + const dir = v.InstallDir + if (!dir || !/^\d+$/.test(id)) continue + const clean = path.normalize(dir.replace(/\//g, '\\')) + out.push({ platform: 'ubisoft', platformId: id, name: path.basename(clean), installDir: clean, launchUri: `uplay://launch/${id}/0`, installed: true }) + } + return out +} + +export const ubisoft: Scanner = { + id: 'ubisoft', + name: 'Ubisoft Connect', + async scan(env) { + return ubisoftFromReg(await env.reg(UBI_KEY)).filter((g) => env.exists(g.installDir!)) + } +} diff --git a/src/main/scanners/vdf.ts b/src/main/scanners/vdf.ts new file mode 100644 index 0000000..79044b7 --- /dev/null +++ b/src/main/scanners/vdf.ts @@ -0,0 +1,47 @@ +/* Parseur du format texte KeyValues de Valve (libraryfolders.vdf, appmanifest_*.acf). */ +export type Vdf = { [k: string]: string | Vdf } + +export function parseVdf(text: string): Vdf { + let i = 0 + const n = text.length + const skip = () => { + for (;;) { + while (i < n && /\s/.test(text[i])) i++ + if (text[i] === '/' && text[i + 1] === '/') { while (i < n && text[i] !== '\n') i++; continue } + return + } + } + const str = (): string => { + if (text[i] !== '"') { // jeton sans guillemets + const s = i + while (i < n && !/[\s{}"]/.test(text[i])) i++ + return text.slice(s, i) + } + i++ + let out = '' + while (i < n && text[i] !== '"') { + if (text[i] === '\\' && i + 1 < n) { const c = text[++i]; out += c === 'n' ? '\n' : c === 't' ? '\t' : c } else out += text[i] + i++ + } + i++ + return out + } + const obj = (): Vdf => { + const o: Vdf = {} + for (;;) { + skip() + if (i >= n || text[i] === '}') { i++; return o } + const k = str() + skip() + if (text[i] === '{') { i++; o[k] = obj() } else o[k] = str() + } + } + return obj() +} + +/* Accès insensible à la casse (les clés varient : "AppState" / "appstate") */ +export function get(o: Vdf | string | undefined, key: string): Vdf | string | undefined { + if (!o || typeof o === 'string') return undefined + const k = Object.keys(o).find((x) => x.toLowerCase() === key.toLowerCase()) + return k === undefined ? undefined : o[k] +} diff --git a/tests/fixtures/appmanifest_367520.acf b/tests/fixtures/appmanifest_367520.acf new file mode 100644 index 0000000..9fde2b4 --- /dev/null +++ b/tests/fixtures/appmanifest_367520.acf @@ -0,0 +1,13 @@ +"AppState" +{ + "appid" "367520" + "universe" "1" + "name" "Hollow Knight" + "StateFlags" "4" + "installdir" "Hollow Knight" + // commentaire + "UserConfig" + { + "language" "french" + } +} diff --git a/tests/fixtures/epic.item b/tests/fixtures/epic.item new file mode 100644 index 0000000..ced59a4 --- /dev/null +++ b/tests/fixtures/epic.item @@ -0,0 +1,13 @@ +{ + "FormatVersion": 0, + "bIsIncompleteInstall": false, + "LaunchCommand": "", + "LaunchExecutable": "Binaries/Win64/Game.exe", + "DisplayName": "Outer Wilds", + "InstallLocation": "C:\\Program Files\\Epic Games\\OuterWilds", + "CatalogNamespace": "ns123", + "CatalogItemId": "item456", + "AppName": "Bison", + "MainGameAppName": "Bison", + "AppCategories": ["public", "games", "applications"] +} diff --git a/tests/fixtures/gog-reg.txt b/tests/fixtures/gog-reg.txt new file mode 100644 index 0000000..11809ef --- /dev/null +++ b/tests/fixtures/gog-reg.txt @@ -0,0 +1,19 @@ + +HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\GOG.com\Games + +HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\GOG.com\Games\1207658924 + gameName REG_SZ The Witcher 3: Wild Hunt + exe REG_SZ D:\GOG Games\The Witcher 3\bin\x64\witcher3.exe + path REG_SZ D:\GOG Games\The Witcher 3 + workingDir REG_SZ D:\GOG Games\The Witcher 3\bin\x64 + launchParam REG_SZ + gameID REG_SZ 1207658924 + dependsOn REG_SZ + +HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\GOG.com\Games\1640424747 + gameName REG_SZ Witcher 3 DLC + exe REG_SZ D:\GOG Games\The Witcher 3\bin\x64\witcher3.exe + dependsOn REG_SZ 1207658924 + +HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\GOG.com\Games\1207658924\sub + x REG_DWORD 0x1 diff --git a/tests/fixtures/installerdata.xml b/tests/fixtures/installerdata.xml new file mode 100644 index 0000000..fa78b38 --- /dev/null +++ b/tests/fixtures/installerdata.xml @@ -0,0 +1,13 @@ + + + + Mass Effect Legendary Edition + Mass Effect édition Légendaire + + Origin.OFR.50.0004012 + + + [HKEY_LOCAL_MACHINE\SOFTWARE\BioWare\Mass Effect Legendary Edition\Install Dir]\Game\Launcher\MassEffectLauncher.exe + + + diff --git a/tests/fixtures/libraryfolders.vdf b/tests/fixtures/libraryfolders.vdf new file mode 100644 index 0000000..b9701a5 --- /dev/null +++ b/tests/fixtures/libraryfolders.vdf @@ -0,0 +1,21 @@ +"libraryfolders" +{ + "0" + { + "path" "C:\\Program Files (x86)\\Steam" + "label" "" + "apps" + { + "228980" "1234" + "367520" "9100000000" + } + } + "1" + { + "path" "D:\\SteamLibrary" + "apps" + { + "1245620" "50000000000" + } + } +} diff --git a/tests/scanners.test.ts b/tests/scanners.test.ts new file mode 100644 index 0000000..6fb74c1 --- /dev/null +++ b/tests/scanners.test.ts @@ -0,0 +1,99 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { parseInstallerData } from '../src/main/scanners/ea' +import { parseEpicItem } from '../src/main/scanners/epic' +import { gogFromReg } from '../src/main/scanners/gog' +import { runScanners } from '../src/main/scanners/index' +import { parseReg } from '../src/main/scanners/reg' +import { libraryPaths, parseManifest, steam } from '../src/main/scanners/steam' +import type { ScanEnv, Scanner } from '../src/main/scanners/types' +import { ubisoftFromReg, UBI_KEY } from '../src/main/scanners/ubisoft' +import { parseVdf } from '../src/main/scanners/vdf' +import { DEFAULT_SETTINGS } from '../src/shared/types' + +const fx = (f: string) => readFileSync(join(__dirname, 'fixtures', f), 'utf8') + +describe('VDF', () => { + it('lit objets imbriqués, échappements et commentaires', () => { + const v = parseVdf(fx('appmanifest_367520.acf')) as any + expect(v.AppState.name).toBe('Hollow Knight') + expect(v.AppState.UserConfig.language).toBe('french') + expect((parseVdf('"a" { "p" "C:\\\\x\\"y" }') as any).a.p).toBe('C:\\x"y') + }) +}) + +describe('Steam', () => { + it('trouve toutes les bibliothèques (nouveau et ancien format)', () => { + expect(libraryPaths(fx('libraryfolders.vdf'), 'C:\\Program Files (x86)\\Steam')).toEqual(['C:\\Program Files (x86)\\Steam', 'D:\\SteamLibrary']) + expect(libraryPaths('"LibraryFolders" { "TimeNextStatsReport" "1" "1" "E:\\\\Jeux" }', 'C:\\Steam')).toEqual(['C:\\Steam', 'E:\\Jeux']) + }) + it('lit un manifeste et ignore les outils', () => { + expect(parseManifest(fx('appmanifest_367520.acf'), 'D:\\SteamLibrary')).toMatchObject({ + platformId: '367520', name: 'Hollow Knight', installDir: 'D:\\SteamLibrary\\steamapps\\common\\Hollow Knight', launchUri: 'steam://rungameid/367520', installed: true + }) + expect(parseManifest('"AppState" { "appid" "228980" "name" "Steamworks Common Redistributables" }', 'C:\\')).toBeNull() + expect(parseManifest('"AppState" { "appid" "1" "name" "Jeu" "StateFlags" "1026" }', 'C:\\')?.installed).toBe(false) + }) + it('scan complet sur un faux disque', async () => { + const files: Record = { + 'C:\\Steam\\steamapps\\libraryfolders.vdf': '"libraryfolders" { "0" { "path" "C:\\\\Steam" } "1" { "path" "D:\\\\Lib" } }', + 'C:\\Steam\\steamapps\\appmanifest_367520.acf': fx('appmanifest_367520.acf'), + 'D:\\Lib\\steamapps\\appmanifest_620.acf': '"AppState" { "appid" "620" "name" "Portal 2" "StateFlags" "4" "installdir" "Portal 2" }' + } + const env: ScanEnv = { + programData: 'C:\\ProgramData', + reg: async (k) => new Map(k.startsWith('HKCU') ? [[k.toUpperCase(), { SteamPath: 'C:/Steam' }]] : []), + exists: (p) => p in files, + readText: (p) => files[p], + list: (d) => Object.keys(files).filter((f) => f.startsWith(d + '\\')).map((f) => f.slice(d.length + 1)) + } + expect((await steam.scan(env)).map((g) => g.name).sort()).toEqual(['Hollow Knight', 'Portal 2']) + }) +}) + +describe('Epic', () => { + it('lit un manifeste .item', () => { + expect(parseEpicItem(fx('epic.item'))).toMatchObject({ + platformId: 'Bison', name: 'Outer Wilds', + exe: 'C:\\Program Files\\Epic Games\\OuterWilds\\Binaries\\Win64\\Game.exe', + launchUri: 'com.epicgames.launcher://apps/ns123%3Aitem456%3ABison?action=launch&silent=true' + }) + }) + it('ignore DLC et applications', () => { + expect(parseEpicItem(JSON.stringify({ ...JSON.parse(fx('epic.item')), MainGameAppName: 'Autre' }))).toBeNull() + expect(parseEpicItem(JSON.stringify({ ...JSON.parse(fx('epic.item')), AppCategories: ['engines'] }))).toBeNull() + }) +}) + +describe('registre', () => { + it('parse la sortie de reg query', () => { + const m = parseReg(fx('gog-reg.txt')) + expect(m.get('HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432NODE\\GOG.COM\\GAMES\\1207658924')?.gameName).toBe('The Witcher 3: Wild Hunt') + expect(m.get('HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432NODE\\GOG.COM\\GAMES\\1207658924')?.launchParam).toBe('') + }) + it('GOG : jeux sans DLC ni sous-clés', () => { + const g = gogFromReg(parseReg(fx('gog-reg.txt'))) + expect(g).toHaveLength(1) + expect(g[0]).toMatchObject({ platformId: '1207658924', exe: 'D:\\GOG Games\\The Witcher 3\\bin\\x64\\witcher3.exe', args: undefined }) + }) + it('Ubisoft : nom tiré du dossier', () => { + const m = parseReg(`${UBI_KEY}\\635\r\n InstallDir REG_SZ C:/Program Files (x86)/Ubisoft/Ubisoft Game Launcher/games/Assassin's Creed Origins/\r\n`) + expect(ubisoftFromReg(m)[0]).toMatchObject({ name: "Assassin's Creed Origins", launchUri: 'uplay://launch/635/0' }) + }) + it('EA : exe et titre depuis installerdata.xml', () => { + expect(parseInstallerData(fx('installerdata.xml'))).toEqual({ exe: 'Game\\Launcher\\MassEffectLauncher.exe', title: 'Mass Effect Legendary Edition', id: 'Origin.OFR.50.0004012' }) + }) +}) + +describe('runScanners', () => { + it('isole un scanner en échec et ignore les désactivés', async () => { + const ok: Scanner = { id: 'steam', name: 'ok', scan: async () => [{ platform: 'steam', platformId: '1', name: 'A' }] } + const ko: Scanner = { id: 'epic', name: 'ko', scan: async () => { throw new Error('boum') } } + const off: Scanner = { id: 'gog', name: 'off', scan: async () => [{ platform: 'gog', platformId: '2', name: 'B' }] } + const r = await runScanners({ ...DEFAULT_SETTINGS, scanners: { ...DEFAULT_SETTINGS.scanners, gog: false } }, {} as ScanEnv, [ok, ko, off]) + expect(r.ok.steam).toHaveLength(1) + expect(r.failed).toEqual(['epic']) + expect(r.ok.gog).toBeUndefined() + }) +})