Compare commits
2 commits
b8b1afc626
...
f68ed4112f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f68ed4112f | ||
|
|
bcc96d5fe5 |
24 changed files with 780 additions and 7 deletions
|
|
@ -2,6 +2,7 @@
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { app, BrowserWindow } from 'electron'
|
import { app, BrowserWindow } from 'electron'
|
||||||
import { emit, handle, registerCore } from './ipc'
|
import { emit, handle, registerCore } from './ipc'
|
||||||
|
import { registerLibrary } from './library'
|
||||||
import { initLog, log } from './log'
|
import { initLog, log } from './log'
|
||||||
import { Store } from './store'
|
import { Store } from './store'
|
||||||
import { registerThemeScheme, Themes } from './themes'
|
import { registerThemeScheme, Themes } from './themes'
|
||||||
|
|
@ -56,19 +57,22 @@ app.on('second-instance', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
registerCore({
|
const ctx = {
|
||||||
store,
|
store,
|
||||||
dataDir,
|
dataDir,
|
||||||
win: () => win,
|
win: () => win,
|
||||||
onSettings: (s, before) => {
|
onSettings: (s: typeof store.settings, before: typeof store.settings) => {
|
||||||
if (win && s.fullscreen !== before.fullscreen) win.setFullScreen(s.fullscreen)
|
if (win && s.fullscreen !== before.fullscreen) win.setFullScreen(s.fullscreen)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
registerCore(ctx)
|
||||||
|
const library = registerLibrary(ctx)
|
||||||
themes.registerProtocol()
|
themes.registerProtocol()
|
||||||
handle('themes:list', async () => themes.list())
|
handle('themes:list', async () => themes.list())
|
||||||
themes.watch((id) => emit(win, 'themes:changed', { id, themes: themes.list() }))
|
themes.watch((id) => emit(win, 'themes:changed', { id, themes: themes.list() }))
|
||||||
win = createWindow()
|
win = createWindow()
|
||||||
L.info('démarrage', app.getVersion())
|
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())
|
app.on('before-quit', () => store.flush())
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,10 @@ export function registerCore(ctx: Ctx): void {
|
||||||
handle('library:update', async (id, patch) => {
|
handle('library:update', async (id, patch) => {
|
||||||
const g = store.games.get(str(id, 300, 'id'))
|
const g = store.games.get(str(id, 300, 'id'))
|
||||||
if (!g) throw new Error('jeu introuvable')
|
if (!g) throw new Error('jeu introuvable')
|
||||||
Object.assign(g, gamePatch(patch))
|
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)
|
||||||
changed()
|
changed()
|
||||||
return g
|
return g
|
||||||
})
|
})
|
||||||
|
|
|
||||||
68
src/main/library.ts
Normal file
68
src/main/library.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
/* 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<FsFilter, RegExp> = { 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<ScanReport> } {
|
||||||
|
const { store } = ctx
|
||||||
|
let running: Promise<ScanReport> | 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 }
|
||||||
|
}
|
||||||
36
src/main/scanners/ea.ts
Normal file
36
src/main/scanners/ea.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
/* EA app (et anciens jeux Origin) : dossiers d'installation dans le registre ; l'exe vient de
|
||||||
|
__Installer\installerdata.xml (<filePath>[clé]\jeu.exe</filePath>). 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(/<runtime>[\s\S]*?<filePath>([^<]+)<\/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(/<gameTitle[^>]*locale="en_US"[^>]*>([^<]+)</i)?.[1] ?? xml.match(/<gameTitle[^>]*>([^<]+)</i)?.[1]
|
||||||
|
const id = xml.match(/<contentID>([^<]+)<\/contentID>/i)?.[1]
|
||||||
|
return { exe: exe || undefined, title: title?.trim(), id: id?.trim() }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function eaGames(env: ScanEnv): Promise<ScannedGame[]> {
|
||||||
|
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 }
|
||||||
54
src/main/scanners/epic.ts
Normal file
54
src/main/scanners/epic.ts
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/main/scanners/gog.ts
Normal file
34
src/main/scanners/gog.ts
Normal file
|
|
@ -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<string, Record<string, string>>): 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!))
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/main/scanners/index.ts
Normal file
46
src/main/scanners/index.ts
Normal file
|
|
@ -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<Record<ScannerId, ScannedGame[]>> // scanners terminés (même avec 0 jeu)
|
||||||
|
failed: ScannerId[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const withTimeout = <T>(p: Promise<T>, ms: number) => Promise.race([p, new Promise<T>((_, 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<ScanResult> {
|
||||||
|
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
|
||||||
|
}
|
||||||
56
src/main/scanners/merge.ts
Normal file
56
src/main/scanners/merge.ts
Normal file
|
|
@ -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<Game, 'platform' | 'platformId'>) => `${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<ScanResult, 'ok'>, 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<string>()
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
34
src/main/scanners/reg.ts
Normal file
34
src/main/scanners/reg.ts
Normal file
|
|
@ -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 <clé> /s` -> clé complète -> { nomDeValeur: donnée } ; la valeur par défaut a le nom "" */
|
||||||
|
export function parseReg(out: string): Map<string, Record<string, string>> {
|
||||||
|
const res = new Map<string, Record<string, string>>()
|
||||||
|
let cur: Record<string, string> | 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<Map<string, Record<string, string>>> {
|
||||||
|
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<string, Record<string, string>>, parent: string): [string, Record<string, string>][] {
|
||||||
|
const p = parent.toUpperCase() + '\\'
|
||||||
|
return [...map].filter(([k]) => k.startsWith(p) && !k.slice(p.length).includes('\\')).map(([k, v]) => [k.slice(p.length), v])
|
||||||
|
}
|
||||||
73
src/main/scanners/steam.ts
Normal file
73
src/main/scanners/steam.ts
Normal file
|
|
@ -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<string | null> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/main/scanners/types.ts
Normal file
18
src/main/scanners/types.ts
Normal file
|
|
@ -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<Game, 'platform' | 'platformId' | 'name'> & Partial<Pick<Game, 'installDir' | 'exe' | 'args' | 'workDir' | 'launchUri' | 'steamAppId' | 'installed'>>
|
||||||
|
|
||||||
|
export interface ScanEnv {
|
||||||
|
programData: string // C:\ProgramData
|
||||||
|
reg: (key: string) => Promise<Map<string, Record<string, string>>> // 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<ScannedGame[]>
|
||||||
|
}
|
||||||
25
src/main/scanners/ubisoft.ts
Normal file
25
src/main/scanners/ubisoft.ts
Normal file
|
|
@ -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<string, Record<string, string>>): 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!))
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/main/scanners/vdf.ts
Normal file
47
src/main/scanners/vdf.ts
Normal file
|
|
@ -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]
|
||||||
|
}
|
||||||
|
|
@ -60,3 +60,16 @@ export function gamePatch(p: unknown): GamePatch {
|
||||||
}
|
}
|
||||||
return o
|
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<string, unknown>
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,17 @@ const fire = <E extends EventName>(e: E, p: Events[E]) => listeners.get(e)?.forE
|
||||||
|
|
||||||
let settings: Settings = { ...DEFAULT_SETTINGS }
|
let settings: Settings = { ...DEFAULT_SETTINGS }
|
||||||
const collections: Collection[] = []
|
const collections: Collection[] = []
|
||||||
const games: Game[] = []
|
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: {}
|
||||||
|
}))
|
||||||
|
|
||||||
async function themes(): Promise<ThemeEntry[]> {
|
async function themes(): Promise<ThemeEntry[]> {
|
||||||
const out: ThemeEntry[] = []
|
const out: ThemeEntry[] = []
|
||||||
|
|
@ -33,6 +43,15 @@ export const mock: OrbeApi = {
|
||||||
fire('library:changed', games)
|
fire('library:changed', games)
|
||||||
return g
|
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:list': async () => collections,
|
||||||
'collections:create': async (name) => {
|
'collections:create': async (name) => {
|
||||||
const c = { id: String(Date.now()), name, createdAt: Date.now() }
|
const c = { id: String(Date.now()), name, createdAt: Date.now() }
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,27 @@
|
||||||
/* Contrat IPC : une seule source de vérité pour le preload (window.orbe) et les handlers du processus principal. */
|
/* Contrat IPC : une seule source de vérité pour le preload (window.orbe) et les handlers du processus principal. */
|
||||||
import type { AppInfo, Collection, Game, Settings } from './types'
|
import type { AppInfo, Collection, Game, ScannerId, Settings } from './types'
|
||||||
import type { ThemeManifest } from './theme'
|
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 {
|
export interface ThemeEntry {
|
||||||
manifest: ThemeManifest
|
manifest: ThemeManifest
|
||||||
base: string // URL de base des fichiers du thème (orbe-theme://<id>/)
|
base: string // URL de base des fichiers du thème (orbe-theme://<id>/)
|
||||||
|
|
@ -13,6 +33,10 @@ export interface Api {
|
||||||
'app:info': () => Promise<AppInfo>
|
'app:info': () => Promise<AppInfo>
|
||||||
'library:list': () => Promise<Game[]>
|
'library:list': () => Promise<Game[]>
|
||||||
'library:update': (id: string, patch: Partial<Pick<Game, 'name' | 'favorite' | 'hidden' | 'exe' | 'args' | 'workDir' | 'collections'>>) => Promise<Game>
|
'library:update': (id: string, patch: Partial<Pick<Game, 'name' | 'favorite' | 'hidden' | 'exe' | 'args' | 'workDir' | 'collections'>>) => Promise<Game>
|
||||||
|
'library:rescan': () => Promise<ScanReport>
|
||||||
|
'library:addManual': (input: ManualGameInput) => Promise<Game>
|
||||||
|
'library:remove': (id: string) => Promise<void>
|
||||||
|
'fs:list': (dir: string, filter: FsFilter) => Promise<FsListing>
|
||||||
'collections:list': () => Promise<Collection[]>
|
'collections:list': () => Promise<Collection[]>
|
||||||
'collections:create': (name: string) => Promise<Collection>
|
'collections:create': (name: string) => Promise<Collection>
|
||||||
'collections:rename': (id: string, name: string) => Promise<void>
|
'collections:rename': (id: string, name: string) => Promise<void>
|
||||||
|
|
@ -25,6 +49,7 @@ export interface Api {
|
||||||
/* Événements poussés par le processus principal vers l'interface */
|
/* Événements poussés par le processus principal vers l'interface */
|
||||||
export interface Events {
|
export interface Events {
|
||||||
'library:changed': Game[]
|
'library:changed': Game[]
|
||||||
|
'scan:state': { running: boolean; report?: ScanReport }
|
||||||
'settings:changed': Settings
|
'settings:changed': Settings
|
||||||
'themes:changed': { id: string; themes: ThemeEntry[] }
|
'themes:changed': { id: string; themes: ThemeEntry[] }
|
||||||
}
|
}
|
||||||
|
|
@ -36,6 +61,10 @@ export const CHANNELS = [
|
||||||
'app:info',
|
'app:info',
|
||||||
'library:list',
|
'library:list',
|
||||||
'library:update',
|
'library:update',
|
||||||
|
'library:rescan',
|
||||||
|
'library:addManual',
|
||||||
|
'library:remove',
|
||||||
|
'fs:list',
|
||||||
'collections:list',
|
'collections:list',
|
||||||
'collections:create',
|
'collections:create',
|
||||||
'collections:rename',
|
'collections:rename',
|
||||||
|
|
@ -45,7 +74,7 @@ export const CHANNELS = [
|
||||||
'themes:list'
|
'themes:list'
|
||||||
] as const satisfies readonly Channel[]
|
] as const satisfies readonly Channel[]
|
||||||
|
|
||||||
export const EVENTS = ['library:changed', 'settings:changed', 'themes:changed'] as const satisfies readonly EventName[]
|
export const EVENTS = ['library:changed', 'scan:state', 'settings:changed', 'themes:changed'] as const satisfies readonly EventName[]
|
||||||
|
|
||||||
/* Ce que l'interface voit dans window.orbe */
|
/* Ce que l'interface voit dans window.orbe */
|
||||||
export type OrbeApi = Api & {
|
export type OrbeApi = Api & {
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ export interface Game {
|
||||||
media: GameMedia
|
media: GameMedia
|
||||||
info?: GameInfo
|
info?: GameInfo
|
||||||
steamAppId?: string // utile pour les médias même hors Steam
|
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 {
|
export interface Session {
|
||||||
|
|
|
||||||
13
tests/fixtures/appmanifest_367520.acf
vendored
Normal file
13
tests/fixtures/appmanifest_367520.acf
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
"AppState"
|
||||||
|
{
|
||||||
|
"appid" "367520"
|
||||||
|
"universe" "1"
|
||||||
|
"name" "Hollow Knight"
|
||||||
|
"StateFlags" "4"
|
||||||
|
"installdir" "Hollow Knight"
|
||||||
|
// commentaire
|
||||||
|
"UserConfig"
|
||||||
|
{
|
||||||
|
"language" "french"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
tests/fixtures/epic.item
vendored
Normal file
13
tests/fixtures/epic.item
vendored
Normal file
|
|
@ -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"]
|
||||||
|
}
|
||||||
19
tests/fixtures/gog-reg.txt
vendored
Normal file
19
tests/fixtures/gog-reg.txt
vendored
Normal file
|
|
@ -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
|
||||||
13
tests/fixtures/installerdata.xml
vendored
Normal file
13
tests/fixtures/installerdata.xml
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<DiPManifest version="4.0">
|
||||||
|
<gameTitles>
|
||||||
|
<gameTitle locale="en_US">Mass Effect Legendary Edition</gameTitle>
|
||||||
|
<gameTitle locale="fr_FR">Mass Effect édition Légendaire</gameTitle>
|
||||||
|
</gameTitles>
|
||||||
|
<contentIDs><contentID>Origin.OFR.50.0004012</contentID></contentIDs>
|
||||||
|
<runtime>
|
||||||
|
<launcher>
|
||||||
|
<filePath>[HKEY_LOCAL_MACHINE\SOFTWARE\BioWare\Mass Effect Legendary Edition\Install Dir]\Game\Launcher\MassEffectLauncher.exe</filePath>
|
||||||
|
</launcher>
|
||||||
|
</runtime>
|
||||||
|
</DiPManifest>
|
||||||
21
tests/fixtures/libraryfolders.vdf
vendored
Normal file
21
tests/fixtures/libraryfolders.vdf
vendored
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
tests/merge.test.ts
Normal file
34
tests/merge.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
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>): 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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
99
tests/scanners.test.ts
Normal file
99
tests/scanners.test.ts
Normal file
|
|
@ -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<string, string> = {
|
||||||
|
'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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue