Contrat IPC typé, stockage JSON atomique, journal fichier et validation des entrées
Some checks are pending
CI / windows (push) Waiting to run

This commit is contained in:
yasder5 2026-09-28 11:40:31 +02:00
parent afb305b3e8
commit 835f8cbbbb
11 changed files with 487 additions and 15 deletions

View file

@ -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
View 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
View 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
View 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
View 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
}

View file

@ -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
View 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
View 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
}

View file

@ -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
View 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
View 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')
})
})