diff --git a/src/main/index.ts b/src/main/index.ts index 7ded7dc..67d20a6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2,7 +2,6 @@ import { join } from 'node:path' import { app, BrowserWindow } from 'electron' import { emit, handle, registerCore } from './ipc' -import { registerLibrary } from './library' import { initLog, log } from './log' import { Store } from './store' import { registerThemeScheme, Themes } from './themes' @@ -57,22 +56,19 @@ app.on('second-instance', () => { }) app.whenReady().then(() => { - const ctx = { + registerCore({ store, dataDir, win: () => win, - onSettings: (s: typeof store.settings, before: typeof store.settings) => { + onSettings: (s, before) => { if (win && s.fullscreen !== before.fullscreen) win.setFullScreen(s.fullscreen) } - } - registerCore(ctx) - const library = registerLibrary(ctx) + }) themes.registerProtocol() handle('themes:list', async () => themes.list()) themes.watch((id) => emit(win, 'themes:changed', { id, themes: themes.list() })) win = createWindow() L.info('démarrage', app.getVersion()) - win.webContents.once('did-finish-load', () => library.rescan()) // scan au démarrage, l'interface affiche déjà la bibliothèque connue }) app.on('before-quit', () => store.flush()) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 6bc6e0f..92e3f90 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -49,10 +49,7 @@ export function registerCore(ctx: Ctx): void { handle('library:update', async (id, patch) => { const g = store.games.get(str(id, 300, 'id')) if (!g) throw new Error('jeu introuvable') - const p = gamePatch(patch) - if (p.name !== undefined && p.name !== g.name) g.nameLocked = true - if (p.args !== undefined || p.workDir !== undefined) g.argsLocked = true - Object.assign(g, p) + Object.assign(g, gamePatch(patch)) changed() return g }) diff --git a/src/main/library.ts b/src/main/library.ts deleted file mode 100644 index a658a13..0000000 --- a/src/main/library.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* Bibliothèque : scan, ajout manuel, suppression, explorateur de fichiers pour la manette. */ -import { randomUUID } from 'node:crypto' -import { readdirSync, statSync } from 'node:fs' -import { dirname, join, parse } from 'node:path' -import type { FsFilter, FsListing, ScanReport } from '../shared/ipc' -import { emit, handle, type Ctx } from './ipc' -import { log } from './log' -import { gameId, mergeScan } from './scanners/merge' -import { runScanners } from './scanners' -import { manualInput, str } from './validate' - -const L = log('library') -const EXT: Record = { exe: /\.(exe|bat|cmd|lnk|url)$/i, image: /\.(png|jpe?g|webp|gif)$/i, dir: /^$/ } - -export function listDir(dir: string, filter: FsFilter): FsListing { - if (!dir) { - // racine : lecteurs disponibles (Windows) ou / ailleurs - if (process.platform !== 'win32') return listDir('/', filter) - const drives = 'CDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map((d) => d + ':\\').filter((d) => { try { statSync(d); return true } catch { return false } }) - return { dir: '', parent: null, entries: drives.map((d) => ({ name: d, path: d, dir: true })) } - } - const entries = readdirSync(dir, { withFileTypes: true }) - .filter((e) => !e.name.startsWith('.') && !e.name.startsWith('$') && (e.isDirectory() || EXT[filter].test(e.name))) - .map((e) => ({ name: e.name, path: join(dir, e.name), dir: e.isDirectory() })) - .sort((a, b) => Number(b.dir) - Number(a.dir) || a.name.localeCompare(b.name, 'fr', { sensitivity: 'base' })) - const root = parse(dir).root - return { dir, parent: dir === root ? '' : dirname(dir), entries: entries.slice(0, 2000) } -} - -export function registerLibrary(ctx: Ctx): { rescan: () => Promise } { - const { store } = ctx - let running: Promise | null = null - - const rescan = () => - (running ??= (async () => { - emit(ctx.win(), 'scan:state', { running: true }) - const res = await runScanners(store.settings) - const { games, stats } = mergeScan(store.list(), res) - store.games = new Map(games.map((g) => [g.id, g])) - store.save() - const report = { ...stats, failed: res.failed, total: games.length } - L.info('scan terminé', report) - emit(ctx.win(), 'library:changed', store.list()) - emit(ctx.win(), 'scan:state', { running: false, report }) - return report - })().finally(() => { running = null })) - - handle('library:rescan', rescan) - handle('library:addManual', async (input) => { - const m = manualInput(input) - const g = { id: gameId({ platform: 'manual', platformId: randomUUID() }), platform: 'manual' as const, platformId: '', ...m, installDir: dirname(m.exe), installed: true, favorite: false, hidden: false, addedAt: Date.now(), playtime: 0, collections: [], media: {}, nameLocked: true, argsLocked: true } - g.platformId = g.id.slice('manual:'.length) - store.games.set(g.id, g) - store.save() - emit(ctx.win(), 'library:changed', store.list()) - return g - }) - handle('library:remove', async (id) => { - const g = store.games.get(str(id, 300, 'id')) - if (!g) return - if (g.platform === 'manual') store.games.delete(g.id) - else g.hidden = true // un jeu détecté reviendrait au prochain scan : on le masque - store.save() - emit(ctx.win(), 'library:changed', store.list()) - }) - handle('fs:list', async (dir, filter) => listDir(str(dir, 1000, 'dossier'), filter in EXT ? filter : 'exe')) - return { rescan } -} diff --git a/src/main/scanners/ea.ts b/src/main/scanners/ea.ts deleted file mode 100644 index d882f03..0000000 --- a/src/main/scanners/ea.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* 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 deleted file mode 100644 index c96d28e..0000000 --- a/src/main/scanners/epic.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* 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 deleted file mode 100644 index 4b48ea6..0000000 --- a/src/main/scanners/gog.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* 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 deleted file mode 100644 index f2014af..0000000 --- a/src/main/scanners/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* 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 deleted file mode 100644 index 581abb7..0000000 --- a/src/main/scanners/merge.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* 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 deleted file mode 100644 index 586591b..0000000 --- a/src/main/scanners/reg.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* 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 deleted file mode 100644 index ebeaea6..0000000 --- a/src/main/scanners/steam.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* 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 deleted file mode 100644 index 8189e55..0000000 --- a/src/main/scanners/types.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* 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 deleted file mode 100644 index 152367a..0000000 --- a/src/main/scanners/ubisoft.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* 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 deleted file mode 100644 index 79044b7..0000000 --- a/src/main/scanners/vdf.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* 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/src/main/validate.ts b/src/main/validate.ts index 8b46800..4edf008 100644 --- a/src/main/validate.ts +++ b/src/main/validate.ts @@ -60,16 +60,3 @@ export function gamePatch(p: unknown): GamePatch { } return o } - -export function manualInput(p: unknown): { name: string; exe: string; args?: string; workDir?: string } { - if (!p || typeof p !== 'object') throw new InvalidInput('jeu invalide') - const i = p as Record - const exe = str(i.exe, 260, 'exécutable') - if (!/\.(exe|bat|cmd|lnk|url)$/i.test(exe)) throw new InvalidInput('exécutable .exe, .bat, .cmd, .lnk ou .url attendu') - return { - name: str(i.name ?? '', 200, 'nom').trim() || exe.split(/[\\/]/).pop()!.replace(/\.\w+$/, ''), - exe, - args: i.args === undefined ? undefined : str(i.args, 1000, 'arguments'), - workDir: i.workDir === undefined || i.workDir === '' ? undefined : str(i.workDir, 260, 'dossier de travail') - } -} diff --git a/src/renderer/src/dev/mock.ts b/src/renderer/src/dev/mock.ts index 13ccbdd..444cd65 100644 --- a/src/renderer/src/dev/mock.ts +++ b/src/renderer/src/dev/mock.ts @@ -8,17 +8,7 @@ const fire = (e: E, p: Events[E]) => listeners.get(e)?.forE let settings: Settings = { ...DEFAULT_SETTINGS } const collections: Collection[] = [] -const SAMPLE: [string, string, string][] = [ - ['367520', 'Hollow Knight', 'steam'], ['1245620', 'ELDEN RING', 'steam'], ['1145360', 'Hades', 'steam'], ['504230', 'Celeste', 'steam'], - ['413150', 'Stardew Valley', 'steam'], ['1091500', 'Cyberpunk 2077', 'gog'], ['292030', 'The Witcher 3: Wild Hunt', 'gog'], ['620', 'Portal 2', 'steam'], - ['753640', 'Outer Wilds', 'epic'], ['632470', 'Disco Elysium', 'steam'], ['374320', 'DARK SOULS III', 'steam'], ['1086940', "Baldur's Gate 3", 'steam'], - ['814380', 'Sekiro: Shadows Die Twice', 'steam'], ['1092790', 'Inscryption', 'epic'], ['1687950', 'Persona 5 Royal', 'steam'], ['2552430', 'KINGDOM HEARTS -HD 1.5+2.5 ReMIX-', 'epic'] -] -const day = 864e5 -const games: Game[] = SAMPLE.map(([appid, name, platform], i) => ({ - id: `${platform}:${appid}`, platform: platform as Game['platform'], platformId: appid, name, steamAppId: appid, installed: i !== 13, favorite: i % 5 === 0, hidden: false, - addedAt: Date.now() - i * 3 * day, lastPlayed: i < 6 ? Date.now() - i * day : undefined, playtime: (16 - i) * 3600 * 2.3, collections: [], media: {} -})) +const games: Game[] = [] async function themes(): Promise { const out: ThemeEntry[] = [] @@ -43,15 +33,6 @@ export const mock: OrbeApi = { fire('library:changed', games) return g }, - 'library:rescan': async () => ({ added: 0, updated: games.length, uninstalled: 0, merged: 0, failed: [], total: games.length }), - 'library:addManual': async (input) => { - const g: Game = { id: 'manual:' + Date.now(), platform: 'manual', platformId: String(Date.now()), ...input, installed: true, favorite: false, hidden: false, addedAt: Date.now(), playtime: 0, collections: [], media: {} } - games.push(g) - fire('library:changed', games) - return g - }, - 'library:remove': async (id) => { games.splice(games.findIndex((g) => g.id === id), 1); fire('library:changed', games) }, - 'fs:list': async (dir) => ({ dir, parent: dir ? '' : null, entries: dir ? [{ name: 'Jeu.exe', path: dir + '\\Jeu.exe', dir: false }] : [{ name: 'C:\\', path: 'C:\\', dir: true }] }), 'collections:list': async () => collections, 'collections:create': async (name) => { const c = { id: String(Date.now()), name, createdAt: Date.now() } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index a664283..c16c542 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -1,27 +1,7 @@ /* Contrat IPC : une seule source de vérité pour le preload (window.orbe) et les handlers du processus principal. */ -import type { AppInfo, Collection, Game, ScannerId, Settings } from './types' +import type { AppInfo, Collection, Game, Settings } from './types' import type { ThemeManifest } from './theme' -export interface ScanReport { - added: number - updated: number - uninstalled: number - merged: number - failed: ScannerId[] - total: number -} - -export interface ManualGameInput { - name: string - exe: string - args?: string - workDir?: string -} - -export interface FsEntry { name: string; path: string; dir: boolean } -export interface FsListing { dir: string; parent: string | null; entries: FsEntry[] } -export type FsFilter = 'exe' | 'image' | 'dir' - export interface ThemeEntry { manifest: ThemeManifest base: string // URL de base des fichiers du thème (orbe-theme:///) @@ -33,10 +13,6 @@ export interface Api { 'app:info': () => Promise 'library:list': () => Promise 'library:update': (id: string, patch: Partial>) => Promise - 'library:rescan': () => Promise - 'library:addManual': (input: ManualGameInput) => Promise - 'library:remove': (id: string) => Promise - 'fs:list': (dir: string, filter: FsFilter) => Promise 'collections:list': () => Promise 'collections:create': (name: string) => Promise 'collections:rename': (id: string, name: string) => Promise @@ -49,7 +25,6 @@ export interface Api { /* Événements poussés par le processus principal vers l'interface */ export interface Events { 'library:changed': Game[] - 'scan:state': { running: boolean; report?: ScanReport } 'settings:changed': Settings 'themes:changed': { id: string; themes: ThemeEntry[] } } @@ -61,10 +36,6 @@ export const CHANNELS = [ 'app:info', 'library:list', 'library:update', - 'library:rescan', - 'library:addManual', - 'library:remove', - 'fs:list', 'collections:list', 'collections:create', 'collections:rename', @@ -74,7 +45,7 @@ export const CHANNELS = [ 'themes:list' ] as const satisfies readonly Channel[] -export const EVENTS = ['library:changed', 'scan:state', 'settings:changed', 'themes:changed'] as const satisfies readonly EventName[] +export const EVENTS = ['library:changed', 'settings:changed', 'themes:changed'] as const satisfies readonly EventName[] /* Ce que l'interface voit dans window.orbe */ export type OrbeApi = Api & { diff --git a/src/shared/types.ts b/src/shared/types.ts index 8cf3df6..b4114a7 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -38,8 +38,6 @@ export interface Game { media: GameMedia info?: GameInfo steamAppId?: string // utile pour les médias même hors Steam - nameLocked?: boolean // nom modifié par l'utilisateur : le scan ne l'écrase plus - argsLocked?: boolean // arguments / dossier de travail modifiés à la main } export interface Session { diff --git a/tests/fixtures/appmanifest_367520.acf b/tests/fixtures/appmanifest_367520.acf deleted file mode 100644 index 9fde2b4..0000000 --- a/tests/fixtures/appmanifest_367520.acf +++ /dev/null @@ -1,13 +0,0 @@ -"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 deleted file mode 100644 index ced59a4..0000000 --- a/tests/fixtures/epic.item +++ /dev/null @@ -1,13 +0,0 @@ -{ - "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 deleted file mode 100644 index 11809ef..0000000 --- a/tests/fixtures/gog-reg.txt +++ /dev/null @@ -1,19 +0,0 @@ - -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 deleted file mode 100644 index fa78b38..0000000 --- a/tests/fixtures/installerdata.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - 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 deleted file mode 100644 index b9701a5..0000000 --- a/tests/fixtures/libraryfolders.vdf +++ /dev/null @@ -1,21 +0,0 @@ -"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/merge.test.ts b/tests/merge.test.ts deleted file mode 100644 index 06f2867..0000000 --- a/tests/merge.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { mergeScan } from '../src/main/scanners/merge' -import type { Game } from '../src/shared/types' - -const base = (o: Partial): Game => ({ id: '', platform: 'steam', platformId: '', name: '', installed: true, favorite: false, hidden: false, addedAt: 0, playtime: 0, collections: [], media: {}, ...o }) - -describe('mergeScan', () => { - it('ajoute, met à jour et garde les données utilisateur', () => { - const old = [base({ id: 'steam:1', platformId: '1', name: 'Vieux nom', favorite: true, playtime: 500, collections: ['c'] })] - const { games, stats } = mergeScan(old, { ok: { steam: [{ platform: 'steam', platformId: '1', name: 'Nouveau', installDir: 'C:\\A' }, { platform: 'steam', platformId: '2', name: 'B' }] } }, 42) - expect(stats).toMatchObject({ added: 1, updated: 1 }) - const a = games.find((g) => g.id === 'steam:1')! - expect(a).toMatchObject({ name: 'Nouveau', favorite: true, playtime: 500, collections: ['c'], installDir: 'C:\\A' }) - expect(games.find((g) => g.id === 'steam:2')!.addedAt).toBe(42) - }) - it('ne renomme pas un jeu renommé par l’utilisateur', () => { - const { games } = mergeScan([base({ id: 'steam:1', platformId: '1', name: 'Mon nom', nameLocked: true })], { ok: { steam: [{ platform: 'steam', platformId: '1', name: 'Officiel' }] } }) - expect(games[0].name).toBe('Mon nom') - }) - it('marque désinstallé, sans toucher aux scanners en échec', () => { - const old = [base({ id: 'steam:1', platformId: '1' }), base({ id: 'epic:x', platform: 'epic', platformId: 'x' })] - const { games, stats } = mergeScan(old, { ok: { steam: [] } }) - expect(stats.uninstalled).toBe(1) - expect(games.find((g) => g.id === 'steam:1')!.installed).toBe(false) - expect(games.find((g) => g.id === 'epic:x')!.installed).toBe(true) - }) - it('dédoublonne et fusionne un ajout manuel dans le jeu détecté', () => { - const old = [base({ id: 'manual:m', platform: 'manual', platformId: 'm', exe: 'D:\\GOG Games\\W3\\bin\\w3.exe', playtime: 100, favorite: true })] - const { games, stats } = mergeScan(old, { ok: { gog: [{ platform: 'gog', platformId: '9', name: 'W3', installDir: 'D:\\GOG Games\\W3' }, { platform: 'gog', platformId: '9', name: 'W3' }] } }) - expect(games).toHaveLength(1) - expect(stats.merged).toBe(1) - expect(games[0]).toMatchObject({ id: 'gog:9', playtime: 100, favorite: true }) - }) -}) diff --git a/tests/scanners.test.ts b/tests/scanners.test.ts deleted file mode 100644 index 6fb74c1..0000000 --- a/tests/scanners.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -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() - }) -})