Contrat IPC typé, stockage JSON atomique, journal fichier et validation des entrées
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
afb305b3e8
commit
835f8cbbbb
11 changed files with 487 additions and 15 deletions
|
|
@ -1,26 +1,69 @@
|
||||||
|
/* 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 } from 'electron'
|
||||||
|
import { registerCore } from './ipc'
|
||||||
|
import { initLog, log } from './log'
|
||||||
|
import { Store } from './store'
|
||||||
|
|
||||||
|
// Toutes les données dans %APPDATA%/OrbeLauncher (réglages, bibliothèque, cache, logs)
|
||||||
|
const dataDir = join(app.getPath('appData'), 'OrbeLauncher')
|
||||||
|
app.setPath('userData', dataDir)
|
||||||
|
initLog(join(dataDir, 'logs'))
|
||||||
|
const L = log('main')
|
||||||
|
process.on('uncaughtException', (e) => L.error('exception non gérée', e))
|
||||||
|
process.on('unhandledRejection', (e) => L.error('promesse rejetée', e))
|
||||||
|
|
||||||
|
if (!app.requestSingleInstanceLock()) app.quit()
|
||||||
|
|
||||||
|
let win: BrowserWindow | null = null
|
||||||
|
const store = new Store(dataDir)
|
||||||
|
|
||||||
function createWindow(): BrowserWindow {
|
function createWindow(): BrowserWindow {
|
||||||
const win = new BrowserWindow({
|
const w = new BrowserWindow({
|
||||||
width: 1600,
|
width: 1600,
|
||||||
height: 900,
|
height: 900,
|
||||||
fullscreen: !process.env.ELECTRON_RENDERER_URL,
|
fullscreen: store.settings.fullscreen && !process.env.ELECTRON_RENDERER_URL,
|
||||||
|
kiosk: store.settings.kiosk,
|
||||||
backgroundColor: '#0a0a0a',
|
backgroundColor: '#0a0a0a',
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
show: false,
|
show: false,
|
||||||
|
title: 'Orbe Launcher',
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(__dirname, '../preload/index.js'),
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
sandbox: true
|
sandbox: true,
|
||||||
|
backgroundThrottling: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
win.once('ready-to-show', () => win.show())
|
w.once('ready-to-show', () => w.show())
|
||||||
if (process.env.ELECTRON_RENDERER_URL) win.loadURL(process.env.ELECTRON_RENDERER_URL)
|
// aucune navigation ni nouvelle fenêtre : les liens externes ne s'ouvrent pas dans le launcher
|
||||||
else win.loadFile(join(__dirname, '../renderer/index.html'))
|
w.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||||
return win
|
w.webContents.on('will-navigate', (e) => e.preventDefault())
|
||||||
|
if (process.env.ELECTRON_RENDERER_URL) w.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||||
|
else w.loadFile(join(__dirname, '../renderer/index.html'))
|
||||||
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(createWindow)
|
app.on('second-instance', () => {
|
||||||
|
if (win) {
|
||||||
|
if (win.isMinimized()) win.restore()
|
||||||
|
win.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
registerCore({
|
||||||
|
store,
|
||||||
|
dataDir,
|
||||||
|
win: () => win,
|
||||||
|
onSettings: (s, before) => {
|
||||||
|
if (win && s.fullscreen !== before.fullscreen) win.setFullScreen(s.fullscreen)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
win = createWindow()
|
||||||
|
L.info('démarrage', app.getVersion())
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('before-quit', () => store.flush())
|
||||||
app.on('window-all-closed', () => app.quit())
|
app.on('window-all-closed', () => app.quit())
|
||||||
|
|
|
||||||
84
src/main/ipc.ts
Normal file
84
src/main/ipc.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
/* Enregistre les handlers IPC. Seule la page de l'appli peut appeler ; chaque entrée est validée. */
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { app, ipcMain, type BrowserWindow, type IpcMainInvokeEvent } from 'electron'
|
||||||
|
import type { Api, Channel, EventName, Events } from '../shared/ipc'
|
||||||
|
import { log } from './log'
|
||||||
|
import type { Store } from './store'
|
||||||
|
import { gamePatch, settingsPatch, str } from './validate'
|
||||||
|
|
||||||
|
const L = log('ipc')
|
||||||
|
|
||||||
|
export interface Ctx {
|
||||||
|
store: Store
|
||||||
|
win: () => BrowserWindow | null
|
||||||
|
dataDir: string
|
||||||
|
onSettings: (s: import('../shared/types').Settings, before: import('../shared/types').Settings) => 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Refuse les appels qui ne viennent pas de notre page (iframe, page externe chargée par erreur) */
|
||||||
|
function trusted(e: IpcMainInvokeEvent): boolean {
|
||||||
|
const url = e.senderFrame?.url ?? ''
|
||||||
|
return url.startsWith('file://') || (!!process.env.ELECTRON_RENDERER_URL && url.startsWith(process.env.ELECTRON_RENDERER_URL))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handle<C extends Channel>(channel: C, fn: Api[C]): void {
|
||||||
|
ipcMain.handle(channel, async (e, ...args: unknown[]) => {
|
||||||
|
if (!trusted(e)) throw new Error('appel refusé')
|
||||||
|
try {
|
||||||
|
return await (fn as (...a: unknown[]) => unknown)(...args)
|
||||||
|
} catch (err) {
|
||||||
|
L.error(channel, err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerCore(ctx: Ctx): void {
|
||||||
|
const { store } = ctx
|
||||||
|
const changed = () => {
|
||||||
|
store.save()
|
||||||
|
emit(ctx.win(), 'library:changed', store.list())
|
||||||
|
}
|
||||||
|
|
||||||
|
handle('app:info', async () => ({ version: app.getVersion(), platform: process.platform, dataDir: ctx.dataDir, dev: !app.isPackaged }))
|
||||||
|
handle('library:list', async () => store.list())
|
||||||
|
handle('library:update', async (id, patch) => {
|
||||||
|
const g = store.games.get(str(id, 300, 'id'))
|
||||||
|
if (!g) throw new Error('jeu introuvable')
|
||||||
|
Object.assign(g, gamePatch(patch))
|
||||||
|
changed()
|
||||||
|
return g
|
||||||
|
})
|
||||||
|
|
||||||
|
handle('collections:list', async () => store.collections)
|
||||||
|
handle('collections:create', async (name) => {
|
||||||
|
const c = { id: randomUUID(), name: str(name, 60, 'nom').trim() || 'Collection', createdAt: Date.now() }
|
||||||
|
store.collections.push(c)
|
||||||
|
store.save()
|
||||||
|
return c
|
||||||
|
})
|
||||||
|
handle('collections:rename', async (id, name) => {
|
||||||
|
const c = store.collections.find((x) => x.id === id)
|
||||||
|
if (c) c.name = str(name, 60, 'nom').trim() || c.name
|
||||||
|
store.save()
|
||||||
|
})
|
||||||
|
handle('collections:delete', async (id) => {
|
||||||
|
store.collections = store.collections.filter((c) => c.id !== id)
|
||||||
|
for (const g of store.games.values()) g.collections = g.collections.filter((c) => c !== id)
|
||||||
|
changed()
|
||||||
|
})
|
||||||
|
|
||||||
|
handle('settings:get', async () => store.settings)
|
||||||
|
handle('settings:set', async (patch) => {
|
||||||
|
const before = { ...store.settings }
|
||||||
|
store.settings = { ...store.settings, ...settingsPatch(patch) }
|
||||||
|
store.save()
|
||||||
|
ctx.onSettings(store.settings, before)
|
||||||
|
emit(ctx.win(), 'settings:changed', store.settings)
|
||||||
|
return store.settings
|
||||||
|
})
|
||||||
|
}
|
||||||
31
src/main/log.ts
Normal file
31
src/main/log.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
/* Journal fichier : %APPDATA%/OrbeLauncher/logs/AAAA-MM-JJ.log, 14 jours gardés. Jamais de clé API dans les messages. */
|
||||||
|
import { appendFileSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
let dir = ''
|
||||||
|
|
||||||
|
export function initLog(logDir: string): void {
|
||||||
|
dir = logDir
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
const files = readdirSync(dir).filter((f) => f.endsWith('.log')).sort()
|
||||||
|
for (const f of files.slice(0, -14)) unlinkSync(join(dir, f))
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(level: string, scope: string, msg: unknown[]): void {
|
||||||
|
const now = new Date()
|
||||||
|
const text = msg.map((m) => (m instanceof Error ? (m.stack ?? m.message) : typeof m === 'string' ? m : JSON.stringify(m))).join(' ')
|
||||||
|
const line = `${now.toISOString()} ${level} [${scope}] ${text}\n`
|
||||||
|
if (process.env.ELECTRON_RENDERER_URL || level === 'ERROR') process.stderr.write(line)
|
||||||
|
if (!dir) return
|
||||||
|
try {
|
||||||
|
appendFileSync(join(dir, now.toISOString().slice(0, 10) + '.log'), line)
|
||||||
|
} catch {
|
||||||
|
/* disque plein ou dossier supprimé : on ne plante pas pour un log */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const log = (scope: string) => ({
|
||||||
|
info: (...m: unknown[]) => write('INFO', scope, m),
|
||||||
|
warn: (...m: unknown[]) => write('WARN', scope, m),
|
||||||
|
error: (...m: unknown[]) => write('ERROR', scope, m)
|
||||||
|
})
|
||||||
67
src/main/store.ts
Normal file
67
src/main/store.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
/* Stockage JSON local (bibliothèque, collections, sessions, réglages). Écriture atomique (fichier temporaire + rename), sauvegarde différée. */
|
||||||
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { DEFAULT_SETTINGS, type Collection, type Game, type Session, type Settings } from '../shared/types'
|
||||||
|
import { log } from './log'
|
||||||
|
|
||||||
|
const L = log('store')
|
||||||
|
|
||||||
|
interface LibraryFile {
|
||||||
|
version: 1
|
||||||
|
games: Game[]
|
||||||
|
collections: Collection[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson<T>(file: string, fallback: T): T {
|
||||||
|
if (!existsSync(file)) return fallback
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(file, 'utf8')) as T
|
||||||
|
} catch (e) {
|
||||||
|
// fichier abîmé : on le met de côté au lieu de l'écraser, et on repart de zéro
|
||||||
|
L.error('fichier illisible, mis de côté :', file, e)
|
||||||
|
renameSync(file, file + '.bad-' + Date.now())
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJson(file: string, data: unknown): void {
|
||||||
|
const tmp = file + '.tmp'
|
||||||
|
writeFileSync(tmp, JSON.stringify(data, null, 1))
|
||||||
|
renameSync(tmp, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Store {
|
||||||
|
games = new Map<string, Game>()
|
||||||
|
collections: Collection[] = []
|
||||||
|
sessions: Session[] = []
|
||||||
|
settings: Settings = structuredClone(DEFAULT_SETTINGS)
|
||||||
|
private timer: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
|
constructor(private dir: string) {
|
||||||
|
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'), {})
|
||||||
|
this.settings = { ...this.settings, ...s, scanners: { ...this.settings.scanners, ...s.scanners } }
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): Game[] {
|
||||||
|
return [...this.games.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sauvegarde groupée : plusieurs modifications rapprochées = une seule écriture */
|
||||||
|
save(): void {
|
||||||
|
if (this.timer) clearTimeout(this.timer)
|
||||||
|
this.timer = setTimeout(() => this.flush(), 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
flush(): void {
|
||||||
|
if (this.timer) clearTimeout(this.timer)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/main/validate.ts
Normal file
62
src/main/validate.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
/* Validation des données reçues de l'interface (frontière de confiance de l'IPC). */
|
||||||
|
import { SCANNERS, type Game, type Settings } from '../shared/types'
|
||||||
|
|
||||||
|
export class InvalidInput extends Error {}
|
||||||
|
|
||||||
|
export function str(v: unknown, max = 500, name = 'valeur'): string {
|
||||||
|
if (typeof v !== 'string' || v.length > max) throw new InvalidInput(`${name} : texte de ${max} caractères max attendu`)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool = (v: unknown, name: string): boolean => {
|
||||||
|
if (typeof v !== 'boolean') throw new InvalidInput(`${name} : booléen attendu`)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ne garde que les champs connus, chacun vérifié ; tout le reste est ignoré */
|
||||||
|
export function settingsPatch(p: unknown): Partial<Settings> {
|
||||||
|
if (!p || typeof p !== 'object') throw new InvalidInput('réglages invalides')
|
||||||
|
const i = p as Record<string, unknown>, o: Partial<Settings> = {}
|
||||||
|
if ('theme' in i) o.theme = str(i.theme, 64, 'theme')
|
||||||
|
if ('steamGridDbKey' in i) o.steamGridDbKey = str(i.steamGridDbKey, 128, 'clé SteamGridDB').trim()
|
||||||
|
if ('autostart' in i) o.autostart = bool(i.autostart, 'autostart')
|
||||||
|
if ('fullscreen' in i) o.fullscreen = bool(i.fullscreen, 'fullscreen')
|
||||||
|
if ('kiosk' in i) o.kiosk = bool(i.kiosk, 'kiosk')
|
||||||
|
if ('vibration' in i) o.vibration = bool(i.vibration, 'vibration')
|
||||||
|
if ('volume' in i) {
|
||||||
|
if (typeof i.volume !== 'number' || !(i.volume >= 0 && i.volume <= 1)) throw new InvalidInput('volume entre 0 et 1')
|
||||||
|
o.volume = i.volume
|
||||||
|
}
|
||||||
|
if ('language' in i) {
|
||||||
|
if (i.language !== 'fr' && i.language !== 'en') throw new InvalidInput('langue inconnue')
|
||||||
|
o.language = i.language
|
||||||
|
}
|
||||||
|
if ('scanners' in i) {
|
||||||
|
const s = i.scanners as Record<string, unknown>
|
||||||
|
if (!s || typeof s !== 'object') throw new InvalidInput('scanners invalides')
|
||||||
|
o.scanners = Object.fromEntries(SCANNERS.map((k) => [k, bool(s[k], 'scanner ' + k)])) as Settings['scanners']
|
||||||
|
}
|
||||||
|
if ('extraFolders' in i) {
|
||||||
|
if (!Array.isArray(i.extraFolders) || i.extraFolders.length > 20) throw new InvalidInput('dossiers : 20 max')
|
||||||
|
o.extraFolders = i.extraFolders.map((f) => str(f, 260, 'dossier'))
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
type GamePatch = Partial<Pick<Game, 'name' | 'favorite' | 'hidden' | 'exe' | 'args' | 'workDir' | 'collections'>>
|
||||||
|
|
||||||
|
export function gamePatch(p: unknown): GamePatch {
|
||||||
|
if (!p || typeof p !== 'object') throw new InvalidInput('modification invalide')
|
||||||
|
const i = p as Record<string, unknown>, o: GamePatch = {}
|
||||||
|
if ('name' in i) o.name = str(i.name, 200, 'nom').trim() || 'Sans nom'
|
||||||
|
if ('favorite' in i) o.favorite = bool(i.favorite, 'favori')
|
||||||
|
if ('hidden' in i) o.hidden = bool(i.hidden, 'masqué')
|
||||||
|
if ('exe' in i) o.exe = str(i.exe, 260, 'exécutable')
|
||||||
|
if ('args' in i) o.args = str(i.args, 1000, 'arguments')
|
||||||
|
if ('workDir' in i) o.workDir = str(i.workDir, 260, 'dossier de travail')
|
||||||
|
if ('collections' in i) {
|
||||||
|
if (!Array.isArray(i.collections) || i.collections.length > 100) throw new InvalidInput('collections invalides')
|
||||||
|
o.collections = i.collections.map((c) => str(c, 64, 'collection'))
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,14 @@
|
||||||
import { contextBridge } from 'electron'
|
/* Pont sécurisé : expose uniquement les canaux du contrat, rien de Node. */
|
||||||
|
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
||||||
|
import { CHANNELS, EVENTS, type EventName } from '../shared/ipc'
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('orbe', { version: process.versions.electron })
|
const api: Record<string, unknown> = Object.fromEntries(CHANNELS.map((c) => [c, (...args: unknown[]) => ipcRenderer.invoke(c, ...args)]))
|
||||||
|
|
||||||
|
api.on = (event: EventName, cb: (payload: unknown) => void) => {
|
||||||
|
if (!(EVENTS as readonly string[]).includes(event)) throw new Error('événement inconnu')
|
||||||
|
const listener = (_e: IpcRendererEvent, payload: unknown) => cb(payload)
|
||||||
|
ipcRenderer.on(event, listener)
|
||||||
|
return () => ipcRenderer.removeListener(event, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('orbe', api)
|
||||||
|
|
|
||||||
42
src/shared/ipc.ts
Normal file
42
src/shared/ipc.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
/* Contrat IPC : une seule source de vérité pour le preload (window.orbe) et les handlers du processus principal. */
|
||||||
|
import type { AppInfo, Collection, Game, Settings } from './types'
|
||||||
|
|
||||||
|
export interface Api {
|
||||||
|
'app:info': () => Promise<AppInfo>
|
||||||
|
'library:list': () => Promise<Game[]>
|
||||||
|
'library:update': (id: string, patch: Partial<Pick<Game, 'name' | 'favorite' | 'hidden' | 'exe' | 'args' | 'workDir' | 'collections'>>) => Promise<Game>
|
||||||
|
'collections:list': () => Promise<Collection[]>
|
||||||
|
'collections:create': (name: string) => Promise<Collection>
|
||||||
|
'collections:rename': (id: string, name: string) => Promise<void>
|
||||||
|
'collections:delete': (id: string) => Promise<void>
|
||||||
|
'settings:get': () => Promise<Settings>
|
||||||
|
'settings:set': (patch: Partial<Settings>) => Promise<Settings>
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Événements poussés par le processus principal vers l'interface */
|
||||||
|
export interface Events {
|
||||||
|
'library:changed': Game[]
|
||||||
|
'settings:changed': Settings
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Channel = keyof Api
|
||||||
|
export type EventName = keyof Events
|
||||||
|
|
||||||
|
export const CHANNELS = [
|
||||||
|
'app:info',
|
||||||
|
'library:list',
|
||||||
|
'library:update',
|
||||||
|
'collections:list',
|
||||||
|
'collections:create',
|
||||||
|
'collections:rename',
|
||||||
|
'collections:delete',
|
||||||
|
'settings:get',
|
||||||
|
'settings:set'
|
||||||
|
] as const satisfies readonly Channel[]
|
||||||
|
|
||||||
|
export const EVENTS = ['library:changed', 'settings:changed'] as const satisfies readonly EventName[]
|
||||||
|
|
||||||
|
/* Ce que l'interface voit dans window.orbe */
|
||||||
|
export type OrbeApi = Api & {
|
||||||
|
on: <E extends EventName>(event: E, cb: (payload: Events[E]) => void) => () => void
|
||||||
|
}
|
||||||
88
src/shared/types.ts
Normal file
88
src/shared/types.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
/* Types partagés entre le processus principal et l'interface */
|
||||||
|
|
||||||
|
export type Platform = 'steam' | 'epic' | 'gog' | 'ubisoft' | 'ea' | 'manual'
|
||||||
|
export type ScannerId = Exclude<Platform, 'manual'>
|
||||||
|
export const SCANNERS: ScannerId[] = ['steam', 'epic', 'gog', 'ubisoft', 'ea']
|
||||||
|
|
||||||
|
export interface GameMedia {
|
||||||
|
cover?: string // URL servie par orbe-media:// (cache) ou fichier choisi par l'utilisateur
|
||||||
|
hero?: string
|
||||||
|
logo?: string
|
||||||
|
icon?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameInfo {
|
||||||
|
description?: string
|
||||||
|
genres?: string[]
|
||||||
|
releaseDate?: string
|
||||||
|
developer?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Game {
|
||||||
|
id: string // `${platform}:${platformId}`
|
||||||
|
platform: Platform
|
||||||
|
platformId: string
|
||||||
|
name: string
|
||||||
|
installDir?: string
|
||||||
|
exe?: string // exécutable direct (GOG, manuel) ; sert aussi au suivi du processus
|
||||||
|
args?: string
|
||||||
|
workDir?: string
|
||||||
|
launchUri?: string // steam://, com.epicgames.launcher://, uplay://…
|
||||||
|
installed: boolean
|
||||||
|
favorite: boolean
|
||||||
|
hidden: boolean
|
||||||
|
addedAt: number
|
||||||
|
lastPlayed?: number
|
||||||
|
playtime: number // secondes
|
||||||
|
collections: string[]
|
||||||
|
media: GameMedia
|
||||||
|
info?: GameInfo
|
||||||
|
steamAppId?: string // utile pour les médias même hors Steam
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
gameId: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Collection {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Language = 'fr' | 'en'
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
theme: string
|
||||||
|
steamGridDbKey: string
|
||||||
|
scanners: Record<ScannerId, boolean>
|
||||||
|
extraFolders: string[] // dossiers supplémentaires où chercher des exe (ajout rapide)
|
||||||
|
autostart: boolean
|
||||||
|
fullscreen: boolean
|
||||||
|
kiosk: boolean
|
||||||
|
volume: number // 0…1
|
||||||
|
vibration: boolean
|
||||||
|
language: Language
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
|
theme: 'orbe',
|
||||||
|
steamGridDbKey: '',
|
||||||
|
scanners: { steam: true, epic: true, gog: true, ubisoft: true, ea: true },
|
||||||
|
extraFolders: [],
|
||||||
|
autostart: false,
|
||||||
|
fullscreen: true,
|
||||||
|
kiosk: false,
|
||||||
|
volume: 0.6,
|
||||||
|
vibration: true,
|
||||||
|
language: 'fr'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppInfo {
|
||||||
|
version: string
|
||||||
|
platform: string
|
||||||
|
dataDir: string
|
||||||
|
dev: boolean
|
||||||
|
}
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
|
|
||||||
describe('projet', () => {
|
|
||||||
it('lance les tests', () => expect(1 + 1).toBe(2))
|
|
||||||
})
|
|
||||||
33
tests/store.test.ts
Normal file
33
tests/store.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { mkdtempSync, readFileSync, writeFileSync, readdirSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { Store } from '../src/main/store'
|
||||||
|
import { DEFAULT_SETTINGS } from '../src/shared/types'
|
||||||
|
|
||||||
|
const tmp = () => mkdtempSync(join(tmpdir(), 'orbe-'))
|
||||||
|
|
||||||
|
describe('Store', () => {
|
||||||
|
it('part des réglages par défaut et sauvegarde', () => {
|
||||||
|
const d = tmp(), s = new Store(d)
|
||||||
|
expect(s.settings).toEqual(DEFAULT_SETTINGS)
|
||||||
|
s.settings.volume = 0.2
|
||||||
|
s.flush()
|
||||||
|
expect(JSON.parse(readFileSync(join(d, 'settings.json'), 'utf8')).volume).toBe(0.2)
|
||||||
|
expect(new Store(d).settings.volume).toBe(0.2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('met de côté un fichier abîmé sans planter', () => {
|
||||||
|
const d = tmp()
|
||||||
|
writeFileSync(join(d, 'library.json'), '{pas du json')
|
||||||
|
const s = new Store(d)
|
||||||
|
expect(s.list()).toEqual([])
|
||||||
|
expect(readdirSync(d).some((f) => f.startsWith('library.json.bad-'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('complète les scanners manquants', () => {
|
||||||
|
const d = tmp()
|
||||||
|
writeFileSync(join(d, 'settings.json'), JSON.stringify({ scanners: { steam: false } }))
|
||||||
|
expect(new Store(d).settings.scanners).toEqual({ ...DEFAULT_SETTINGS.scanners, steam: false })
|
||||||
|
})
|
||||||
|
})
|
||||||
16
tests/validate.test.ts
Normal file
16
tests/validate.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { gamePatch, InvalidInput, settingsPatch } from '../src/main/validate'
|
||||||
|
|
||||||
|
describe('validation IPC', () => {
|
||||||
|
it('ignore les champs inconnus', () => {
|
||||||
|
expect(settingsPatch({ volume: 0.5, evil: 1 })).toEqual({ volume: 0.5 })
|
||||||
|
})
|
||||||
|
it('refuse les mauvais types', () => {
|
||||||
|
expect(() => settingsPatch({ volume: 3 })).toThrow(InvalidInput)
|
||||||
|
expect(() => settingsPatch({ language: 'de' })).toThrow(InvalidInput)
|
||||||
|
expect(() => gamePatch({ name: 'x'.repeat(500) })).toThrow(InvalidInput)
|
||||||
|
})
|
||||||
|
it('remplace un nom vide', () => {
|
||||||
|
expect(gamePatch({ name: ' ' }).name).toBe('Sans nom')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue