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
Some checks are pending
CI / windows (push) Waiting to run
This commit is contained in:
parent
f68ed4112f
commit
baa9f7391c
14 changed files with 377 additions and 15 deletions
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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<E extends EventName>(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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ScanReport> } {
|
||||
export function registerLibrary(ctx: Ctx, fetchMedia: (ids: string[]) => void): { rescan: () => Promise<ScanReport> } {
|
||||
const { store } = ctx
|
||||
let running: Promise<ScanReport> | null = null
|
||||
|
||||
|
|
@ -42,6 +42,7 @@ export function registerLibrary(ctx: Ctx): { rescan: () => Promise<ScanReport> }
|
|||
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<ScanReport> }
|
|||
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
|
||||
})
|
||||
|
|
|
|||
32
src/main/media-ipc.ts
Normal file
32
src/main/media-ipc.ts
Normal 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
60
src/main/media/cache.ts
Normal 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
91
src/main/media/index.ts
Normal 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
76
src/main/media/sources.ts
Normal 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(/"/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<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
|
||||
}
|
||||
|
|
@ -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<string, Game>()
|
||||
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<LibraryFile>(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<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 } }
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<html lang="fr">
|
||||
<head>
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -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<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) },
|
||||
'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() }
|
||||
|
|
|
|||
|
|
@ -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://<id>/)
|
||||
|
|
@ -37,6 +40,10 @@ export interface Api {
|
|||
'library:addManual': (input: ManualGameInput) => Promise<Game>
|
||||
'library:remove': (id: string) => Promise<void>
|
||||
'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:create': (name: string) => Promise<Collection>
|
||||
'collections:rename': (id: string, name: string) => Promise<void>
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
41
tests/media.test.ts
Normal file
41
tests/media.test.ts
Normal 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> & 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue