From 835f8cbbbb0c50f42d5a97ea86c8bc49af8ea8bc Mon Sep 17 00:00:00 2001 From: yasder5 <102179445+Yasder5@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:40:31 +0200 Subject: [PATCH] =?UTF-8?q?Contrat=20IPC=20typ=C3=A9,=20stockage=20JSON=20?= =?UTF-8?q?atomique,=20journal=20fichier=20et=20validation=20des=20entr?= =?UTF-8?q?=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 59 ++++++++++++++++++++++++---- src/main/ipc.ts | 84 ++++++++++++++++++++++++++++++++++++++++ src/main/log.ts | 31 +++++++++++++++ src/main/store.ts | 67 ++++++++++++++++++++++++++++++++ src/main/validate.ts | 62 +++++++++++++++++++++++++++++ src/preload/index.ts | 15 ++++++- src/shared/ipc.ts | 42 ++++++++++++++++++++ src/shared/types.ts | 88 ++++++++++++++++++++++++++++++++++++++++++ tests/smoke.test.ts | 5 --- tests/store.test.ts | 33 ++++++++++++++++ tests/validate.test.ts | 16 ++++++++ 11 files changed, 487 insertions(+), 15 deletions(-) create mode 100644 src/main/ipc.ts create mode 100644 src/main/log.ts create mode 100644 src/main/store.ts create mode 100644 src/main/validate.ts create mode 100644 src/shared/ipc.ts create mode 100644 src/shared/types.ts delete mode 100644 tests/smoke.test.ts create mode 100644 tests/store.test.ts create mode 100644 tests/validate.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index 59e7803..752040b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,26 +1,69 @@ +/* Processus principal : fenêtre plein écran, stockage, IPC. */ import { join } from 'node:path' 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 { - const win = new BrowserWindow({ + const w = new BrowserWindow({ width: 1600, height: 900, - fullscreen: !process.env.ELECTRON_RENDERER_URL, + fullscreen: store.settings.fullscreen && !process.env.ELECTRON_RENDERER_URL, + kiosk: store.settings.kiosk, backgroundColor: '#0a0a0a', autoHideMenuBar: true, show: false, + title: 'Orbe Launcher', webPreferences: { preload: join(__dirname, '../preload/index.js'), contextIsolation: true, nodeIntegration: false, - sandbox: true + sandbox: true, + backgroundThrottling: true } }) - win.once('ready-to-show', () => win.show()) - if (process.env.ELECTRON_RENDERER_URL) win.loadURL(process.env.ELECTRON_RENDERER_URL) - else win.loadFile(join(__dirname, '../renderer/index.html')) - return win + w.once('ready-to-show', () => w.show()) + // aucune navigation ni nouvelle fenêtre : les liens externes ne s'ouvrent pas dans le launcher + w.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) + 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()) diff --git a/src/main/ipc.ts b/src/main/ipc.ts new file mode 100644 index 0000000..92e3f90 --- /dev/null +++ b/src/main/ipc.ts @@ -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(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(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 + }) +} diff --git a/src/main/log.ts b/src/main/log.ts new file mode 100644 index 0000000..a812d12 --- /dev/null +++ b/src/main/log.ts @@ -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) +}) diff --git a/src/main/store.ts b/src/main/store.ts new file mode 100644 index 0000000..4b51095 --- /dev/null +++ b/src/main/store.ts @@ -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(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() + 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(join(dir, 'library.json'), { version: 1, games: [], collections: [] }) + for (const g of lib.games) this.games.set(g.id, g) + this.collections = lib.collections + this.sessions = readJson(join(dir, 'sessions.json'), []) + const s = readJson>(join(dir, 'settings.json'), {}) + 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) + } +} diff --git a/src/main/validate.ts b/src/main/validate.ts new file mode 100644 index 0000000..4edf008 --- /dev/null +++ b/src/main/validate.ts @@ -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 { + if (!p || typeof p !== 'object') throw new InvalidInput('réglages invalides') + const i = p as Record, o: Partial = {} + 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 + 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> + +export function gamePatch(p: unknown): GamePatch { + if (!p || typeof p !== 'object') throw new InvalidInput('modification invalide') + const i = p as Record, 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 +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 7fbaec6..6e50bef 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 = 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) diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts new file mode 100644 index 0000000..9b5a5f3 --- /dev/null +++ b/src/shared/ipc.ts @@ -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 + 'library:list': () => Promise + 'library:update': (id: string, patch: Partial>) => Promise + 'collections:list': () => Promise + 'collections:create': (name: string) => Promise + 'collections:rename': (id: string, name: string) => Promise + 'collections:delete': (id: string) => Promise + 'settings:get': () => Promise + 'settings:set': (patch: Partial) => Promise +} + +/* É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: (event: E, cb: (payload: Events[E]) => void) => () => void +} diff --git a/src/shared/types.ts b/src/shared/types.ts new file mode 100644 index 0000000..b4114a7 --- /dev/null +++ b/src/shared/types.ts @@ -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 +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 + 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 +} diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts deleted file mode 100644 index 2f89d03..0000000 --- a/tests/smoke.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { describe, expect, it } from 'vitest' - -describe('projet', () => { - it('lance les tests', () => expect(1 + 1).toBe(2)) -}) diff --git a/tests/store.test.ts b/tests/store.test.ts new file mode 100644 index 0000000..942bb46 --- /dev/null +++ b/tests/store.test.ts @@ -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 }) + }) +}) diff --git a/tests/validate.test.ts b/tests/validate.test.ts new file mode 100644 index 0000000..070c88b --- /dev/null +++ b/tests/validate.test.ts @@ -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') + }) +})