Scanners Steam, Epic, GOG, Ubisoft Connect et EA app (registre, VDF/ACF, manifestes), isolés derrière une interface commune
This commit is contained in:
parent
b8b1afc626
commit
bcc96d5fe5
16 changed files with 601 additions and 0 deletions
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]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue