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 { 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())
|
||||
|
|
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue