Jaquettes et médias : SteamGridDB, CDN Steam, infos du store, cache local, choix manuel ; clé API chiffrée (DPAPI) et jamais renvoyée en clair
Some checks are pending
CI / windows (push) Waiting to run

This commit is contained in:
yasder5 2026-10-08 20:41:06 +02:00
parent f68ed4112f
commit baa9f7391c
14 changed files with 377 additions and 15 deletions

View file

@ -1,9 +1,12 @@
/* Processus principal : fenêtre plein écran, stockage, IPC. */ /* Processus principal : fenêtre plein écran, stockage, IPC. */
import { join } from 'node:path' import { join } from 'node:path'
import { app, BrowserWindow } from 'electron' import { app, BrowserWindow, safeStorage } from 'electron'
import { emit, handle, registerCore } from './ipc' import { emit, handle, registerCore } from './ipc'
import { registerLibrary } from './library' import { registerLibrary } from './library'
import { initLog, log } from './log' import { initLog, log } from './log'
import { Media } from './media'
import { MediaCache } from './media/cache'
import { registerMedia } from './media-ipc'
import { Store } from './store' import { Store } from './store'
import { registerThemeScheme, Themes } from './themes' import { registerThemeScheme, Themes } from './themes'
@ -19,7 +22,9 @@ if (!app.requestSingleInstanceLock()) app.quit()
registerThemeScheme() registerThemeScheme()
let win: BrowserWindow | null = null 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) const themes = new Themes(dataDir)
function createWindow(): BrowserWindow { function createWindow(): BrowserWindow {
@ -57,16 +62,22 @@ app.on('second-instance', () => {
}) })
app.whenReady().then(() => { app.whenReady().then(() => {
store = new Store(dataDir, safeStorage.isEncryptionAvailable() ? crypt : undefined)
const ctx = { const ctx = {
store, store,
dataDir, dataDir,
win: () => win, win: () => win,
onSettings: (s: typeof store.settings, before: typeof store.settings) => { 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)
if (s.steamGridDbKey !== before.steamGridDbKey) { media.keyChanged(); media.enqueue(media.missing()) }
} }
} }
const cache = new MediaCache(join(dataDir, 'media'))
cache.registerProtocol()
registerCore(ctx) 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() 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() }))
@ -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 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()) app.on('window-all-closed', () => app.quit())

View file

@ -2,6 +2,7 @@
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import { app, ipcMain, type BrowserWindow, type IpcMainInvokeEvent } from 'electron' import { app, ipcMain, type BrowserWindow, type IpcMainInvokeEvent } from 'electron'
import type { Api, Channel, EventName, Events } from '../shared/ipc' import type { Api, Channel, EventName, Events } from '../shared/ipc'
import type { Settings } from '../shared/types'
import { log } from './log' import { log } from './log'
import type { Store } from './store' import type { Store } from './store'
import { gamePatch, settingsPatch, str } from './validate' import { gamePatch, settingsPatch, str } from './validate'
@ -12,9 +13,11 @@ export interface Ctx {
store: Store store: Store
win: () => BrowserWindow | null win: () => BrowserWindow | null
dataDir: string 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<E extends EventName>(win: BrowserWindow | null, event: E, payload: Events[E]): void { export function emit<E extends EventName>(win: BrowserWindow | null, event: E, payload: Events[E]): void {
if (win && !win.isDestroyed()) win.webContents.send(event, payload) if (win && !win.isDestroyed()) win.webContents.send(event, payload)
} }
@ -75,13 +78,17 @@ export function registerCore(ctx: Ctx): void {
changed() 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) => { handle('settings:set', async (patch) => {
const before = { ...store.settings } 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() store.save()
ctx.onSettings(store.settings, before) ctx.onSettings(store.settings, before)
emit(ctx.win(), 'settings:changed', store.settings) emit(ctx.win(), 'settings:changed', masked(store.settings))
return store.settings return masked(store.settings)
}) })
} }

View file

@ -27,7 +27,7 @@ export function listDir(dir: string, filter: FsFilter): FsListing {
return { dir, parent: dir === root ? '' : dirname(dir), entries: entries.slice(0, 2000) } return { dir, parent: dir === root ? '' : dirname(dir), entries: entries.slice(0, 2000) }
} }
export function registerLibrary(ctx: Ctx): { rescan: () => Promise<ScanReport> } { export function registerLibrary(ctx: Ctx, fetchMedia: (ids: string[]) => void): { rescan: () => Promise<ScanReport> } {
const { store } = ctx const { store } = ctx
let running: Promise<ScanReport> | null = null let running: Promise<ScanReport> | null = null
@ -42,6 +42,7 @@ export function registerLibrary(ctx: Ctx): { rescan: () => Promise<ScanReport> }
L.info('scan terminé', report) L.info('scan terminé', report)
emit(ctx.win(), 'library:changed', store.list()) emit(ctx.win(), 'library:changed', store.list())
emit(ctx.win(), 'scan:state', { running: false, report }) 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 return report
})().finally(() => { running = null })) })().finally(() => { running = null }))
@ -52,6 +53,7 @@ export function registerLibrary(ctx: Ctx): { rescan: () => Promise<ScanReport> }
g.platformId = g.id.slice('manual:'.length) g.platformId = g.id.slice('manual:'.length)
store.games.set(g.id, g) store.games.set(g.id, g)
store.save() store.save()
fetchMedia([g.id])
emit(ctx.win(), 'library:changed', store.list()) emit(ctx.win(), 'library:changed', store.list())
return g return g
}) })

32
src/main/media-ipc.ts Normal file
View file

@ -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<typeof game>) => { 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))
}

60
src/main/media/cache.ts Normal file
View file

@ -0,0 +1,60 @@
/* Cache disque des images (%APPDATA%/OrbeLauncher/media), servi par orbe-media://m/<fichier>. */
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<string, string> = { '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<string | null> {
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())
})
}
}

91
src/main/media/index.ts Normal file
View file

@ -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<MediaOption[]> {
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<void> {
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() })
}
}
}

76
src/main/media/sources.ts Normal file
View file

@ -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<Kind, string> = {
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<T>(path: string): Promise<T | null> {
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<number | null> {
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<MediaOption[]> {
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<GameInfo | null> {
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<string, { success: boolean; data?: { short_description?: string; genres?: { description: string }[]; release_date?: { date?: string }; developers?: string[] } }>
const d = j[appid]?.success ? j[appid].data : undefined
if (!d) return null
const strip = (s?: string) => s?.replace(/<[^>]+>/g, '').replace(/&quot;/g, '"').replace(/&amp;/g, '&').replace(/&#39;/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<string | null> {
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
}

View file

@ -30,6 +30,12 @@ function writeJson(file: string, data: unknown): void {
renameSync(tmp, file) 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 { export class Store {
games = new Map<string, Game>() games = new Map<string, Game>()
collections: Collection[] = [] collections: Collection[] = []
@ -37,14 +43,17 @@ export class Store {
settings: Settings = structuredClone(DEFAULT_SETTINGS) settings: Settings = structuredClone(DEFAULT_SETTINGS)
private timer: NodeJS.Timeout | null = null private timer: NodeJS.Timeout | null = null
constructor(private dir: string) { constructor(private dir: string, private crypt?: Crypt) {
mkdirSync(dir, { recursive: true }) mkdirSync(dir, { recursive: true })
const lib = readJson<LibraryFile>(join(dir, 'library.json'), { version: 1, games: [], collections: [] }) const lib = readJson<LibraryFile>(join(dir, 'library.json'), { version: 1, games: [], collections: [] })
for (const g of lib.games) this.games.set(g.id, g) for (const g of lib.games) this.games.set(g.id, g)
this.collections = lib.collections this.collections = lib.collections
this.sessions = readJson<Session[]>(join(dir, 'sessions.json'), []) this.sessions = readJson<Session[]>(join(dir, 'sessions.json'), [])
const s = readJson<Partial<Settings>>(join(dir, 'settings.json'), {}) const { steamGridDbKeyEnc, ...s } = readJson<Partial<Settings> & { steamGridDbKeyEnc?: string }>(join(dir, 'settings.json'), {})
this.settings = { ...this.settings, ...s, scanners: { ...this.settings.scanners, ...s.scanners } } 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[] { list(): Game[] {
@ -62,6 +71,7 @@ export class Store {
this.timer = null this.timer = null
writeJson(join(this.dir, 'library.json'), { version: 1, games: this.list(), collections: this.collections } satisfies LibraryFile) 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, '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)
} }
} }

View file

@ -2,7 +2,7 @@
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: orbe-media: orbe-theme:; media-src 'self' orbe-theme:; font-src 'self' orbe-theme:; style-src 'self' 'unsafe-inline' orbe-theme:; script-src 'self'; connect-src 'self' orbe-theme:"> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: orbe-media: orbe-theme: https://cdn2.steamgriddb.com https://shared.cloudflare.steamstatic.com https://cdn.cloudflare.steamstatic.com; media-src 'self' orbe-theme:; font-src 'self' orbe-theme:; style-src 'self' 'unsafe-inline' orbe-theme:; script-src 'self'; connect-src 'self' orbe-theme:">
<title>Orbe Launcher</title> <title>Orbe Launcher</title>
</head> </head>
<body> <body>

View file

@ -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'] ['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 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) => ({ 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, 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<ThemeEntry[]> { async function themes(): Promise<ThemeEntry[]> {
@ -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) }, '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 }] }), '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: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() }

View file

@ -22,6 +22,9 @@ export interface FsEntry { name: string; path: string; dir: boolean }
export interface FsListing { dir: string; parent: string | null; entries: FsEntry[] } export interface FsListing { dir: string; parent: string | null; entries: FsEntry[] }
export type FsFilter = 'exe' | 'image' | 'dir' 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 { 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>/)
@ -37,6 +40,10 @@ export interface Api {
'library:addManual': (input: ManualGameInput) => Promise<Game> 'library:addManual': (input: ManualGameInput) => Promise<Game>
'library:remove': (id: string) => Promise<void> 'library:remove': (id: string) => Promise<void>
'fs:list': (dir: string, filter: FsFilter) => Promise<FsListing> 'fs:list': (dir: string, filter: FsFilter) => Promise<FsListing>
'media:options': (gameId: string, kind: MediaKind) => Promise<MediaOption[]>
'media:set': (gameId: string, kind: MediaKind, url: string) => Promise<Game>
'media:setFile': (gameId: string, kind: MediaKind, path: string) => Promise<Game>
'media:refresh': (gameId: string) => Promise<void>
'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>
@ -65,6 +72,10 @@ export const CHANNELS = [
'library:addManual', 'library:addManual',
'library:remove', 'library:remove',
'fs:list', 'fs:list',
'media:options',
'media:set',
'media:setFile',
'media:refresh',
'collections:list', 'collections:list',
'collections:create', 'collections:create',
'collections:rename', 'collections:rename',

View file

@ -40,6 +40,9 @@ export interface Game {
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 nameLocked?: boolean // nom modifié par l'utilisateur : le scan ne l'écrase plus
argsLocked?: boolean // arguments / dossier de travail modifiés à la main 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 { export interface Session {

41
tests/media.test.ts Normal file
View file

@ -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 <b>jeu</b> &amp; 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()
})
})

View file

@ -31,3 +31,15 @@ describe('Store', () => {
expect(new Store(d).settings.scanners).toEqual({ ...DEFAULT_SETTINGS.scanners, steam: false }) 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')
})
})