From baa9f7391cbe93505ba280bd58cc924cd445617f Mon Sep 17 00:00:00 2001 From: yasder5 <102179445+Yasder5@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:41:06 +0200 Subject: [PATCH] =?UTF-8?q?Jaquettes=20et=20m=C3=A9dias=20:=20SteamGridDB,?= =?UTF-8?q?=20CDN=20Steam,=20infos=20du=20store,=20cache=20local,=20choix?= =?UTF-8?q?=20manuel=20;=20cl=C3=A9=20API=20chiffr=C3=A9e=20(DPAPI)=20et?= =?UTF-8?q?=20jamais=20renvoy=C3=A9e=20en=20clair?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 19 ++++++-- src/main/ipc.ts | 17 +++++-- src/main/library.ts | 4 +- src/main/media-ipc.ts | 32 +++++++++++++ src/main/media/cache.ts | 60 ++++++++++++++++++++++++ src/main/media/index.ts | 91 ++++++++++++++++++++++++++++++++++++ src/main/media/sources.ts | 76 ++++++++++++++++++++++++++++++ src/main/store.ts | 16 +++++-- src/renderer/index.html | 2 +- src/renderer/src/dev/mock.ts | 8 +++- src/shared/ipc.ts | 11 +++++ src/shared/types.ts | 3 ++ tests/media.test.ts | 41 ++++++++++++++++ tests/store.test.ts | 12 +++++ 14 files changed, 377 insertions(+), 15 deletions(-) create mode 100644 src/main/media-ipc.ts create mode 100644 src/main/media/cache.ts create mode 100644 src/main/media/index.ts create mode 100644 src/main/media/sources.ts create mode 100644 tests/media.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index 7ded7dc..fc16820 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,9 +1,12 @@ /* Processus principal : fenêtre plein écran, stockage, IPC. */ import { join } from 'node:path' -import { app, BrowserWindow } from 'electron' +import { app, BrowserWindow, safeStorage } from 'electron' import { emit, handle, registerCore } from './ipc' import { registerLibrary } from './library' import { initLog, log } from './log' +import { Media } from './media' +import { MediaCache } from './media/cache' +import { registerMedia } from './media-ipc' import { Store } from './store' import { registerThemeScheme, Themes } from './themes' @@ -19,7 +22,9 @@ if (!app.requestSingleInstanceLock()) app.quit() registerThemeScheme() let win: BrowserWindow | null = null -const store = new Store(dataDir) +// la clé SteamGridDB est chiffrée sur le disque (DPAPI) quand c'est possible +const crypt = { enc: (s: string) => safeStorage.encryptString(s).toString('base64'), dec: (s: string) => safeStorage.decryptString(Buffer.from(s, 'base64')) } +let store: Store const themes = new Themes(dataDir) function createWindow(): BrowserWindow { @@ -57,16 +62,22 @@ app.on('second-instance', () => { }) app.whenReady().then(() => { + store = new Store(dataDir, safeStorage.isEncryptionAvailable() ? crypt : undefined) const ctx = { store, dataDir, win: () => win, onSettings: (s: typeof store.settings, before: typeof store.settings) => { if (win && s.fullscreen !== before.fullscreen) win.setFullScreen(s.fullscreen) + if (s.steamGridDbKey !== before.steamGridDbKey) { media.keyChanged(); media.enqueue(media.missing()) } } } + const cache = new MediaCache(join(dataDir, 'media')) + cache.registerProtocol() registerCore(ctx) - const library = registerLibrary(ctx) + const media = new Media(ctx, cache) + registerMedia(ctx, media) + const library = registerLibrary(ctx, (ids) => media.enqueue(ids)) themes.registerProtocol() handle('themes:list', async () => themes.list()) themes.watch((id) => emit(win, 'themes:changed', { id, themes: themes.list() })) @@ -75,5 +86,5 @@ app.whenReady().then(() => { 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()) app.on('window-all-closed', () => app.quit()) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 6bc6e0f..a93faf0 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto' import { app, ipcMain, type BrowserWindow, type IpcMainInvokeEvent } from 'electron' import type { Api, Channel, EventName, Events } from '../shared/ipc' +import type { Settings } from '../shared/types' import { log } from './log' import type { Store } from './store' import { gamePatch, settingsPatch, str } from './validate' @@ -12,9 +13,11 @@ export interface Ctx { store: Store win: () => BrowserWindow | null dataDir: string - onSettings: (s: import('../shared/types').Settings, before: import('../shared/types').Settings) => void + onSettings: (s: Settings, before: Settings) => void } +export const MASK = '••••' + export function emit(win: BrowserWindow | null, event: E, payload: Events[E]): void { if (win && !win.isDestroyed()) win.webContents.send(event, payload) } @@ -75,13 +78,17 @@ export function registerCore(ctx: Ctx): void { changed() }) - handle('settings:get', async () => store.settings) + // la clé API ne repart jamais en clair vers l'interface : seulement ses 4 derniers caractères + const masked = (s: Settings): Settings => ({ ...s, steamGridDbKey: s.steamGridDbKey ? MASK + s.steamGridDbKey.slice(-4) : '' }) + handle('settings:get', async () => masked(store.settings)) handle('settings:set', async (patch) => { const before = { ...store.settings } - store.settings = { ...store.settings, ...settingsPatch(patch) } + const p = settingsPatch(patch) + if (p.steamGridDbKey?.startsWith(MASK)) delete p.steamGridDbKey // valeur masquée renvoyée telle quelle + store.settings = { ...store.settings, ...p } store.save() ctx.onSettings(store.settings, before) - emit(ctx.win(), 'settings:changed', store.settings) - return store.settings + emit(ctx.win(), 'settings:changed', masked(store.settings)) + return masked(store.settings) }) } diff --git a/src/main/library.ts b/src/main/library.ts index a658a13..5a79f60 100644 --- a/src/main/library.ts +++ b/src/main/library.ts @@ -27,7 +27,7 @@ export function listDir(dir: string, filter: FsFilter): FsListing { return { dir, parent: dir === root ? '' : dirname(dir), entries: entries.slice(0, 2000) } } -export function registerLibrary(ctx: Ctx): { rescan: () => Promise } { +export function registerLibrary(ctx: Ctx, fetchMedia: (ids: string[]) => void): { rescan: () => Promise } { const { store } = ctx let running: Promise | null = null @@ -42,6 +42,7 @@ export function registerLibrary(ctx: Ctx): { rescan: () => Promise } L.info('scan terminé', report) emit(ctx.win(), 'library:changed', store.list()) emit(ctx.win(), 'scan:state', { running: false, report }) + fetchMedia(games.filter((g) => !g.hidden && (!g.media.cover || !g.media.hero) && (g.mediaCheckedAt ?? 0) < Date.now() - 7 * 864e5).map((g) => g.id)) return report })().finally(() => { running = null })) @@ -52,6 +53,7 @@ export function registerLibrary(ctx: Ctx): { rescan: () => Promise } g.platformId = g.id.slice('manual:'.length) store.games.set(g.id, g) store.save() + fetchMedia([g.id]) emit(ctx.win(), 'library:changed', store.list()) return g }) diff --git a/src/main/media-ipc.ts b/src/main/media-ipc.ts new file mode 100644 index 0000000..922c3fc --- /dev/null +++ b/src/main/media-ipc.ts @@ -0,0 +1,32 @@ +/* Handlers IPC des médias */ +import { handle, type Ctx } from './ipc' +import type { Media } from './media' +import { KINDS, type Kind } from './media/sources' +import { str } from './validate' + +export function registerMedia(ctx: Ctx, media: Media): void { + const game = (id: unknown) => { + const g = ctx.store.games.get(str(id, 300, 'id')) + if (!g) throw new Error('jeu introuvable') + return g + } + const kind = (k: unknown): Kind => { + if (!KINDS.includes(k as Kind)) throw new Error('type de média inconnu') + return k as Kind + } + const done = (g: ReturnType) => { ctx.store.save(); return g } + + handle('media:options', async (id, k) => media.options(game(id), kind(k))) + handle('media:set', async (id, k, url) => { + const g = game(id), kd = kind(k), local = await media.cache.download(g.id, kd, str(url, 1000, 'url')) + if (!local) throw new Error('téléchargement impossible') + g.media[kd] = local + return done(g) + }) + handle('media:setFile', async (id, k, path) => { + const g = game(id), kd = kind(k) + g.media[kd] = media.cache.fromFile(g.id, kd, str(path, 1000, 'fichier')) + return done(g) + }) + handle('media:refresh', async (id) => media.enqueue([game(id).id], true)) +} diff --git a/src/main/media/cache.ts b/src/main/media/cache.ts new file mode 100644 index 0000000..e0d51e8 --- /dev/null +++ b/src/main/media/cache.ts @@ -0,0 +1,60 @@ +/* Cache disque des images (%APPDATA%/OrbeLauncher/media), servi par orbe-media://m/. */ +import { copyFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs' +import { extname, join, sep } from 'node:path' +import { pathToFileURL } from 'node:url' +import { net, protocol } from 'electron' +import { safeJoin } from '../../shared/theme' +import { allowedUrl, type Kind } from './sources' + +const MAX = 15 * 1024 * 1024 +const TYPES: Record = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif', 'image/x-icon': '.ico', 'image/vnd.microsoft.icon': '.ico' } + +export class MediaCache { + constructor(readonly dir: string) { + mkdirSync(dir, { recursive: true }) + } + private base(gameId: string, kind: Kind) { + return gameId.replace(/[^\w-]/g, '_') + '-' + kind + } + private clear(gameId: string, kind: Kind) { + const b = this.base(gameId, kind) + '.' + for (const f of readdirSync(this.dir)) if (f.startsWith(b)) unlinkSync(join(this.dir, f)) + } + private url(file: string) { + return `orbe-media://m/${encodeURIComponent(file)}?v=${Date.now()}` // ?v= : l'image change même si le nom reste + } + + /* Télécharge une image (hôtes autorisés seulement), renvoie son URL locale ou null */ + async download(gameId: string, kind: Kind, url: string): Promise { + if (!allowedUrl(url)) throw new Error('adresse d’image non autorisée') + const r = await fetch(url, { signal: AbortSignal.timeout(20000) }) + if (!r.ok) return null + const type = (r.headers.get('content-type') ?? '').split(';')[0].trim() + const ext = TYPES[type] + if (!ext || Number(r.headers.get('content-length') ?? 0) > MAX) return null + const buf = Buffer.from(await r.arrayBuffer()) + if (buf.length > MAX || buf.length < 100) return null + this.clear(gameId, kind) + const file = this.base(gameId, kind) + ext + writeFileSync(join(this.dir, file), buf) + return this.url(file) + } + + /* Image choisie sur le disque par l'utilisateur : copiée dans le cache (le fichier d'origine peut disparaître) */ + fromFile(gameId: string, kind: Kind, path: string): string { + const ext = extname(path).toLowerCase() + if (!['.png', '.jpg', '.jpeg', '.webp', '.gif'].includes(ext)) throw new Error('image png, jpg, webp ou gif attendue') + this.clear(gameId, kind) + const file = this.base(gameId, kind) + ext + copyFileSync(path, join(this.dir, file)) + return this.url(file) + } + + registerProtocol(): void { + protocol.handle('orbe-media', (req) => { + const file = safeJoin(this.dir, new URL(req.url).pathname, sep) + if (!file || !existsSync(file)) return new Response('introuvable', { status: 404 }) + return net.fetch(pathToFileURL(file).toString()) + }) + } +} diff --git a/src/main/media/index.ts b/src/main/media/index.ts new file mode 100644 index 0000000..2cc5337 --- /dev/null +++ b/src/main/media/index.ts @@ -0,0 +1,91 @@ +/* Récupération des médias en tâche de fond : SteamGridDB si une clé est saisie, sinon CDN Steam ; infos du store Steam. + File d'attente à 3 téléchargements simultanés ; sans image, l'interface dessine une jaquette au style du thème. */ +import type { Game } from '../../shared/types' +import { emit, type Ctx } from '../ipc' +import { log } from '../log' +import { MediaCache } from './cache' +import { KINDS, steamCdn, SteamGridDB, steamInfo, steamSearch, type Kind, type MediaOption } from './sources' + +const L = log('media') + +export class Media { + private queue: string[] = [] + private active = 0 + private emitTimer: NodeJS.Timeout | null = null + private badKey = false + + constructor(private ctx: Ctx, readonly cache: MediaCache) {} + + private sgdb(): SteamGridDB | null { + const k = this.ctx.store.settings.steamGridDbKey + return k && !this.badKey ? new SteamGridDB(k) : null + } + keyChanged(): void { + this.badKey = false + } + + /* L'interface est prévenue au plus toutes les 1,5 s (pas à chaque image) */ + private changed(): void { + this.ctx.store.save() + this.emitTimer ??= setTimeout(() => { this.emitTimer = null; emit(this.ctx.win(), 'library:changed', this.ctx.store.list()) }, 1500) + } + + async options(g: Game, kind: Kind): Promise { + const sg = this.sgdb() + if (sg) { + g.sgdbId ??= (await sg.gameId(g.name, g.steamAppId)) ?? undefined + if (g.sgdbId) return (await sg.options(g.sgdbId, kind)).slice(0, 40) + } + return g.steamAppId ? steamCdn(g.steamAppId, kind).map((url) => ({ url, thumb: url })) : [] + } + + private async fill(g: Game, force: boolean): Promise { + if (!g.steamAppId && !g.steamSearched && g.platform !== 'steam') { + g.steamSearched = true // une seule recherche par jeu + g.steamAppId = (await steamSearch(g.name).catch(() => null)) ?? undefined + } + const sg = this.sgdb() + for (const kind of KINDS) { + if (g.media[kind] && !force) continue + const urls: string[] = [] + if (sg) { + try { + g.sgdbId ??= (await sg.gameId(g.name, g.steamAppId)) ?? undefined + if (g.sgdbId) urls.push(...(await sg.options(g.sgdbId, kind)).slice(0, 2).map((o) => o.url)) + } catch (e) { + if (String(e).includes('refusée')) { this.badKey = true; L.warn('clé SteamGridDB refusée, CDN Steam seulement') } else L.warn('SteamGridDB', g.name, e) + } + } + if (g.steamAppId) urls.push(...steamCdn(g.steamAppId, kind)) + for (const u of urls) { + const local = await this.cache.download(g.id, kind, u).catch(() => null) + if (local) { g.media[kind] = local; break } + } + } + if ((!g.info || force) && g.steamAppId) g.info = (await steamInfo(g.steamAppId, this.ctx.store.settings.language).catch(() => null)) ?? g.info + g.mediaCheckedAt = Date.now() + } + + /* Ajoute des jeux à la file ; force = re-télécharger même s'ils ont déjà des images */ + enqueue(ids: string[], force = false): void { + for (const id of ids) if (!this.queue.includes(id)) this.queue.push(force ? '!' + id : id) + this.pump() + } + + /* Jeux sans jaquette, pas revus depuis une semaine */ + missing(): string[] { + const week = Date.now() - 7 * 864e5 + return this.ctx.store.list().filter((g) => !g.hidden && (!g.media.cover || !g.media.hero) && (g.mediaCheckedAt ?? 0) < week).map((g) => g.id) + } + + private pump(): void { + while (this.active < 3 && this.queue.length) { + const item = this.queue.shift()!, force = item.startsWith('!'), g = this.ctx.store.games.get(force ? item.slice(1) : item) + if (!g) continue + this.active++ + this.fill(g, force) + .catch((e) => L.warn('médias', g.name, e)) + .finally(() => { this.active--; this.changed(); this.pump() }) + } + } +} diff --git a/src/main/media/sources.ts b/src/main/media/sources.ts new file mode 100644 index 0000000..84069b3 --- /dev/null +++ b/src/main/media/sources.ts @@ -0,0 +1,76 @@ +/* Sources de médias : SteamGridDB (clé API), CDN Steam, API du store Steam. Fonctions sans état, `fetch` injectable pour les tests. */ +import type { GameInfo } from '../../shared/types' + +export type Kind = 'cover' | 'hero' | 'logo' | 'icon' +export const KINDS: Kind[] = ['cover', 'hero', 'logo', 'icon'] +export interface MediaOption { url: string; thumb: string; width?: number; height?: number } +type Fetch = typeof fetch + +const SGDB = 'https://www.steamgriddb.com/api/v2' +const SGDB_PATH: Record = { + cover: 'grids/game/{id}?dimensions=600x900,342x482,660x930&types=static', + hero: 'heroes/game/{id}?types=static', + logo: 'logos/game/{id}?types=static', + icon: 'icons/game/{id}?types=static' +} + +/* Hôtes d'où le processus principal accepte de télécharger une image (pas de requête vers une adresse arbitraire) */ +export const ALLOWED_HOSTS = ['cdn2.steamgriddb.com', 'cdn.cloudflare.steamstatic.com', 'shared.steamstatic.com', 'shared.cloudflare.steamstatic.com', 'cdn.akamai.steamstatic.com', 'shared.akamai.steamstatic.com', 'steamcdn-a.akamaihd.net'] +export const allowedUrl = (u: string) => { try { const x = new URL(u); return x.protocol === 'https:' && ALLOWED_HOSTS.includes(x.hostname) } catch { return false } } + +export const normName = (s: string) => s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[™®©]/g, '').replace(/[^a-z0-9]+/g, ' ').trim() + +export class SteamGridDB { + constructor(private key: string, private f: Fetch = fetch) {} + private async get(path: string): Promise { + const r = await this.f(`${SGDB}/${path}`, { headers: { Authorization: `Bearer ${this.key}` }, signal: AbortSignal.timeout(15000) }) + if (r.status === 401 || r.status === 403) throw new Error('clé SteamGridDB refusée') + if (!r.ok) return null + const j = (await r.json()) as { success: boolean; data: T } + return j.success ? j.data : null + } + /* Identifiant SteamGridDB : par l'appid Steam si on l'a, sinon par le nom (correspondance exacte d'abord) */ + async gameId(name: string, steamAppId?: string): Promise { + if (steamAppId) { + const g = await this.get<{ id: number }>(`games/steam/${steamAppId}`) + if (g) return g.id + } + const found = (await this.get<{ id: number; name: string }[]>(`search/autocomplete/${encodeURIComponent(name)}`)) ?? [] + return (found.find((x) => normName(x.name) === normName(name)) ?? found[0])?.id ?? null + } + async options(id: number, kind: Kind): Promise { + const d = (await this.get<{ url: string; thumb: string; width: number; height: number }[]>(SGDB_PATH[kind].replace('{id}', String(id)))) ?? [] + return d.map((x) => ({ url: x.url, thumb: x.thumb, width: x.width, height: x.height })) + } +} + +/* CDN Steam : chemins classiques, encore valables pour la très grande majorité des jeux */ +export function steamCdn(appid: string, kind: Kind): string[] { + const old = `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}` + const b = `https://shared.steamstatic.com/store_item_assets/steam/apps/${appid}` + return { + cover: [`${old}/library_600x900_2x.jpg`, `${b}/library_600x900_2x.jpg`, `${old}/library_600x900.jpg`], + hero: [`${old}/library_hero.jpg`, `${b}/library_hero.jpg`], + logo: [`${old}/logo.png`, `${b}/logo.png`], + icon: [] as string[] + }[kind] +} + +/* Infos du store Steam (description courte, genres, date) */ +export async function steamInfo(appid: string, lang: 'fr' | 'en', f: Fetch = fetch): Promise { + const r = await f(`https://store.steampowered.com/api/appdetails?appids=${appid}&l=${lang === 'fr' ? 'french' : 'english'}`, { signal: AbortSignal.timeout(15000) }) + if (!r.ok) return null + const j = (await r.json()) as Record + const d = j[appid]?.success ? j[appid].data : undefined + if (!d) return null + const strip = (s?: string) => s?.replace(/<[^>]+>/g, '').replace(/"/g, '"').replace(/&/g, '&').replace(/'/g, "'").trim() + return { description: strip(d.short_description), genres: d.genres?.map((g) => g.description).slice(0, 4), releaseDate: d.release_date?.date, developer: d.developers?.[0] } +} + +/* Trouve l'appid Steam d'un jeu d'une autre plateforme (nom identique uniquement, pour ne pas se tromper de jeu) */ +export async function steamSearch(name: string, f: Fetch = fetch): Promise { + const r = await f(`https://store.steampowered.com/api/storesearch/?term=${encodeURIComponent(name)}&l=english&cc=FR`, { signal: AbortSignal.timeout(15000) }) + if (!r.ok) return null + const j = (await r.json()) as { items?: { id: number; name: string }[] } + return j.items?.find((i) => normName(i.name) === normName(name))?.id.toString() ?? null +} diff --git a/src/main/store.ts b/src/main/store.ts index 4b51095..0ef74cc 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -30,6 +30,12 @@ function writeJson(file: string, data: unknown): void { renameSync(tmp, file) } +/* Chiffrement facultatif des secrets (safeStorage d'Electron = DPAPI sous Windows) */ +export interface Crypt { + enc: (s: string) => string + dec: (s: string) => string +} + export class Store { games = new Map() collections: Collection[] = [] @@ -37,14 +43,17 @@ export class Store { settings: Settings = structuredClone(DEFAULT_SETTINGS) private timer: NodeJS.Timeout | null = null - constructor(private dir: string) { + constructor(private dir: string, private crypt?: Crypt) { mkdirSync(dir, { recursive: true }) const lib = readJson(join(dir, 'library.json'), { version: 1, games: [], collections: [] }) for (const g of lib.games) this.games.set(g.id, g) this.collections = lib.collections this.sessions = readJson(join(dir, 'sessions.json'), []) - const s = readJson>(join(dir, 'settings.json'), {}) + const { steamGridDbKeyEnc, ...s } = readJson & { steamGridDbKeyEnc?: string }>(join(dir, 'settings.json'), {}) this.settings = { ...this.settings, ...s, scanners: { ...this.settings.scanners, ...s.scanners } } + if (steamGridDbKeyEnc && crypt) { + try { this.settings.steamGridDbKey = crypt.dec(steamGridDbKeyEnc) } catch { L.warn('clé SteamGridDB illisible (autre session Windows ?), à ressaisir') } + } } list(): Game[] { @@ -62,6 +71,7 @@ export class Store { this.timer = null writeJson(join(this.dir, 'library.json'), { version: 1, games: this.list(), collections: this.collections } satisfies LibraryFile) writeJson(join(this.dir, 'sessions.json'), this.sessions) - writeJson(join(this.dir, 'settings.json'), this.settings) + const { steamGridDbKey, ...rest } = this.settings + writeJson(join(this.dir, 'settings.json'), this.crypt && steamGridDbKey ? { ...rest, steamGridDbKey: '', steamGridDbKeyEnc: this.crypt.enc(steamGridDbKey) } : this.settings) } } diff --git a/src/renderer/index.html b/src/renderer/index.html index ce022d7..aaa8343 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -2,7 +2,7 @@ - + Orbe Launcher diff --git a/src/renderer/src/dev/mock.ts b/src/renderer/src/dev/mock.ts index 13ccbdd..11a8181 100644 --- a/src/renderer/src/dev/mock.ts +++ b/src/renderer/src/dev/mock.ts @@ -15,9 +15,11 @@ const SAMPLE: [string, string, string][] = [ ['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 cdn = (a: string) => { const b = `https://cdn.cloudflare.steamstatic.com/steam/apps/${a}`; return { cover: `${b}/library_600x900_2x.jpg`, hero: `${b}/library_hero.jpg`, logo: `${b}/logo.png` } } 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: {} + addedAt: Date.now() - i * 3 * day, lastPlayed: i < 6 ? Date.now() - i * day : undefined, playtime: (16 - i) * 3600 * 2.3, collections: [], media: i === 7 ? {} : cdn(appid), + info: { description: 'Description du jeu tirée du store Steam (aperçu).', genres: ['Action', 'Aventure'], releaseDate: '2017', developer: 'Studio' } })) async function themes(): Promise { @@ -52,6 +54,10 @@ export const mock: OrbeApi = { }, '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 }] }), + 'media:options': async (id) => { const g = games.find((x) => x.id === id)!; const c = cdn(g.platformId); return [c.cover, c.cover].map((url) => ({ url, thumb: url })) }, + 'media:set': async (id, kind, url) => { const g = games.find((x) => x.id === id)!; g.media[kind] = url; fire('library:changed', games); return g }, + 'media:setFile': async (id) => games.find((x) => x.id === id)!, + 'media:refresh': async () => {}, '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..a82da4c 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -22,6 +22,9 @@ 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 type MediaKind = 'cover' | 'hero' | 'logo' | 'icon' +export interface MediaOption { url: string; thumb: string; width?: number; height?: number } + export interface ThemeEntry { manifest: ThemeManifest base: string // URL de base des fichiers du thème (orbe-theme:///) @@ -37,6 +40,10 @@ export interface Api { 'library:addManual': (input: ManualGameInput) => Promise 'library:remove': (id: string) => Promise 'fs:list': (dir: string, filter: FsFilter) => Promise + 'media:options': (gameId: string, kind: MediaKind) => Promise + 'media:set': (gameId: string, kind: MediaKind, url: string) => Promise + 'media:setFile': (gameId: string, kind: MediaKind, path: string) => Promise + 'media:refresh': (gameId: string) => Promise 'collections:list': () => Promise 'collections:create': (name: string) => Promise 'collections:rename': (id: string, name: string) => Promise @@ -65,6 +72,10 @@ export const CHANNELS = [ 'library:addManual', 'library:remove', 'fs:list', + 'media:options', + 'media:set', + 'media:setFile', + 'media:refresh', 'collections:list', 'collections:create', 'collections:rename', diff --git a/src/shared/types.ts b/src/shared/types.ts index 8cf3df6..eb77c0d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -40,6 +40,9 @@ export interface Game { 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 + sgdbId?: number // identifiant SteamGridDB (mis en cache) + steamSearched?: boolean // appid Steam déjà cherché pour un jeu hors Steam + mediaCheckedAt?: number } export interface Session { diff --git a/tests/media.test.ts b/tests/media.test.ts new file mode 100644 index 0000000..3a69e62 --- /dev/null +++ b/tests/media.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { allowedUrl, normName, SteamGridDB, steamCdn, steamInfo, steamSearch } from '../src/main/media/sources' + +const json = (body: unknown, status = 200) => async () => new Response(JSON.stringify(body), { status }) + +describe('médias', () => { + it("n'autorise que les CDN connus en https", () => { + expect(allowedUrl('https://cdn2.steamgriddb.com/grid/abc.png')).toBe(true) + expect(allowedUrl('http://cdn2.steamgriddb.com/grid/abc.png')).toBe(false) + expect(allowedUrl('https://169.254.169.254/latest')).toBe(false) + expect(allowedUrl('https://cdn2.steamgriddb.com.evil.io/x.png')).toBe(false) + expect(allowedUrl('pas une url')).toBe(false) + }) + it('normalise les noms', () => { + expect(normName('ELDEN RING™')).toBe('elden ring') + expect(normName('Pokémon : Édition')).toBe('pokemon edition') + }) + it('donne des URL CDN Steam', () => { + expect(steamCdn('620', 'cover')[0]).toContain('/620/library_600x900_2x.jpg') + expect(steamCdn('620', 'icon')).toEqual([]) + }) + it('SteamGridDB : id par appid, sinon par nom exact', async () => { + const calls: string[] = [] + const f = (async (u: string) => { + calls.push(u) + if (u.includes('/games/steam/')) return new Response('{}', { status: 404 }) + return new Response(JSON.stringify({ success: true, data: [{ id: 1, name: 'Hades II' }, { id: 2, name: 'Hades' }] })) + }) as typeof fetch + expect(await new SteamGridDB('k', f).gameId('Hades', '1145360')).toBe(2) + expect(calls[0]).toContain('/games/steam/1145360') + }) + it('SteamGridDB : clé refusée = erreur claire', async () => { + await expect(new SteamGridDB('k', json({}, 401) as unknown as typeof fetch).gameId('x')).rejects.toThrow('refusée') + }) + it('store Steam : infos nettoyées et recherche exacte', async () => { + const info = await steamInfo('620', 'fr', json({ '620': { success: true, data: { short_description: 'Un jeu & plus', genres: [{ description: 'Réflexion' }], release_date: { date: '18 avr. 2011' }, developers: ['Valve'] } } }) as unknown as typeof fetch) + expect(info).toEqual({ description: 'Un jeu & plus', genres: ['Réflexion'], releaseDate: '18 avr. 2011', developer: 'Valve' }) + expect(await steamSearch('Portal 2', json({ items: [{ id: 999, name: 'Portal 2 Soundtrack' }, { id: 620, name: 'Portal 2' }] }) as unknown as typeof fetch)).toBe('620') + expect(await steamSearch('Inconnu', json({ items: [{ id: 1, name: 'Autre' }] }) as unknown as typeof fetch)).toBeNull() + }) +}) diff --git a/tests/store.test.ts b/tests/store.test.ts index 942bb46..5476f7b 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -31,3 +31,15 @@ describe('Store', () => { expect(new Store(d).settings.scanners).toEqual({ ...DEFAULT_SETTINGS.scanners, steam: false }) }) }) + +describe('Store : clé API chiffrée', () => { + it("n'écrit jamais la clé en clair quand le chiffrement est disponible", () => { + const d = tmp(), crypt = { enc: (s: string) => Buffer.from(s).toString('hex'), dec: (s: string) => Buffer.from(s, 'hex').toString() } + const s = new Store(d, crypt) + s.settings.steamGridDbKey = 'secret123' + s.flush() + const raw = readFileSync(join(d, 'settings.json'), 'utf8') + expect(raw).not.toContain('secret123') + expect(new Store(d, crypt).settings.steamGridDbKey).toBe('secret123') + }) +})