From f68ed4112f813b595dab34d86fa5cc654728387d Mon Sep 17 00:00:00 2001 From: yasder5 <102179445+Yasder5@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:47:19 +0200 Subject: [PATCH] =?UTF-8?q?Fusion=20des=20scans=20(d=C3=A9sinstall=C3=A9s,?= =?UTF-8?q?=20doublons,=20ajouts=20manuels),=20rescan,=20ajout=20manuel=20?= =?UTF-8?q?et=20explorateur=20de=20fichiers=20pour=20la=20manette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 10 ++++-- src/main/ipc.ts | 5 ++- src/main/library.ts | 68 ++++++++++++++++++++++++++++++++++++ src/main/validate.ts | 13 +++++++ src/renderer/src/dev/mock.ts | 21 ++++++++++- src/shared/ipc.ts | 33 +++++++++++++++-- src/shared/types.ts | 2 ++ tests/merge.test.ts | 34 ++++++++++++++++++ 8 files changed, 179 insertions(+), 7 deletions(-) create mode 100644 src/main/library.ts create mode 100644 tests/merge.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index 67d20a6..7ded7dc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2,6 +2,7 @@ import { join } from 'node:path' import { app, BrowserWindow } from 'electron' import { emit, handle, registerCore } from './ipc' +import { registerLibrary } from './library' import { initLog, log } from './log' import { Store } from './store' import { registerThemeScheme, Themes } from './themes' @@ -56,19 +57,22 @@ app.on('second-instance', () => { }) app.whenReady().then(() => { - registerCore({ + const ctx = { store, dataDir, win: () => win, - onSettings: (s, before) => { + onSettings: (s: typeof store.settings, before: typeof store.settings) => { if (win && s.fullscreen !== before.fullscreen) win.setFullScreen(s.fullscreen) } - }) + } + registerCore(ctx) + const library = registerLibrary(ctx) themes.registerProtocol() handle('themes:list', async () => themes.list()) themes.watch((id) => emit(win, 'themes:changed', { id, themes: themes.list() })) win = createWindow() L.info('démarrage', app.getVersion()) + 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()) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 92e3f90..6bc6e0f 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -49,7 +49,10 @@ export function registerCore(ctx: Ctx): void { 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)) + const p = gamePatch(patch) + if (p.name !== undefined && p.name !== g.name) g.nameLocked = true + if (p.args !== undefined || p.workDir !== undefined) g.argsLocked = true + Object.assign(g, p) changed() return g }) diff --git a/src/main/library.ts b/src/main/library.ts new file mode 100644 index 0000000..a658a13 --- /dev/null +++ b/src/main/library.ts @@ -0,0 +1,68 @@ +/* Bibliothèque : scan, ajout manuel, suppression, explorateur de fichiers pour la manette. */ +import { randomUUID } from 'node:crypto' +import { readdirSync, statSync } from 'node:fs' +import { dirname, join, parse } from 'node:path' +import type { FsFilter, FsListing, ScanReport } from '../shared/ipc' +import { emit, handle, type Ctx } from './ipc' +import { log } from './log' +import { gameId, mergeScan } from './scanners/merge' +import { runScanners } from './scanners' +import { manualInput, str } from './validate' + +const L = log('library') +const EXT: Record = { exe: /\.(exe|bat|cmd|lnk|url)$/i, image: /\.(png|jpe?g|webp|gif)$/i, dir: /^$/ } + +export function listDir(dir: string, filter: FsFilter): FsListing { + if (!dir) { + // racine : lecteurs disponibles (Windows) ou / ailleurs + if (process.platform !== 'win32') return listDir('/', filter) + const drives = 'CDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map((d) => d + ':\\').filter((d) => { try { statSync(d); return true } catch { return false } }) + return { dir: '', parent: null, entries: drives.map((d) => ({ name: d, path: d, dir: true })) } + } + const entries = readdirSync(dir, { withFileTypes: true }) + .filter((e) => !e.name.startsWith('.') && !e.name.startsWith('$') && (e.isDirectory() || EXT[filter].test(e.name))) + .map((e) => ({ name: e.name, path: join(dir, e.name), dir: e.isDirectory() })) + .sort((a, b) => Number(b.dir) - Number(a.dir) || a.name.localeCompare(b.name, 'fr', { sensitivity: 'base' })) + const root = parse(dir).root + return { dir, parent: dir === root ? '' : dirname(dir), entries: entries.slice(0, 2000) } +} + +export function registerLibrary(ctx: Ctx): { rescan: () => Promise } { + const { store } = ctx + let running: Promise | null = null + + const rescan = () => + (running ??= (async () => { + emit(ctx.win(), 'scan:state', { running: true }) + const res = await runScanners(store.settings) + const { games, stats } = mergeScan(store.list(), res) + store.games = new Map(games.map((g) => [g.id, g])) + store.save() + const report = { ...stats, failed: res.failed, total: games.length } + L.info('scan terminé', report) + emit(ctx.win(), 'library:changed', store.list()) + emit(ctx.win(), 'scan:state', { running: false, report }) + return report + })().finally(() => { running = null })) + + handle('library:rescan', rescan) + handle('library:addManual', async (input) => { + const m = manualInput(input) + const g = { id: gameId({ platform: 'manual', platformId: randomUUID() }), platform: 'manual' as const, platformId: '', ...m, installDir: dirname(m.exe), installed: true, favorite: false, hidden: false, addedAt: Date.now(), playtime: 0, collections: [], media: {}, nameLocked: true, argsLocked: true } + g.platformId = g.id.slice('manual:'.length) + store.games.set(g.id, g) + store.save() + emit(ctx.win(), 'library:changed', store.list()) + return g + }) + handle('library:remove', async (id) => { + const g = store.games.get(str(id, 300, 'id')) + if (!g) return + if (g.platform === 'manual') store.games.delete(g.id) + else g.hidden = true // un jeu détecté reviendrait au prochain scan : on le masque + store.save() + emit(ctx.win(), 'library:changed', store.list()) + }) + handle('fs:list', async (dir, filter) => listDir(str(dir, 1000, 'dossier'), filter in EXT ? filter : 'exe')) + return { rescan } +} diff --git a/src/main/validate.ts b/src/main/validate.ts index 4edf008..8b46800 100644 --- a/src/main/validate.ts +++ b/src/main/validate.ts @@ -60,3 +60,16 @@ export function gamePatch(p: unknown): GamePatch { } return o } + +export function manualInput(p: unknown): { name: string; exe: string; args?: string; workDir?: string } { + if (!p || typeof p !== 'object') throw new InvalidInput('jeu invalide') + const i = p as Record + const exe = str(i.exe, 260, 'exécutable') + if (!/\.(exe|bat|cmd|lnk|url)$/i.test(exe)) throw new InvalidInput('exécutable .exe, .bat, .cmd, .lnk ou .url attendu') + return { + name: str(i.name ?? '', 200, 'nom').trim() || exe.split(/[\\/]/).pop()!.replace(/\.\w+$/, ''), + exe, + args: i.args === undefined ? undefined : str(i.args, 1000, 'arguments'), + workDir: i.workDir === undefined || i.workDir === '' ? undefined : str(i.workDir, 260, 'dossier de travail') + } +} diff --git a/src/renderer/src/dev/mock.ts b/src/renderer/src/dev/mock.ts index 444cd65..13ccbdd 100644 --- a/src/renderer/src/dev/mock.ts +++ b/src/renderer/src/dev/mock.ts @@ -8,7 +8,17 @@ const fire = (e: E, p: Events[E]) => listeners.get(e)?.forE let settings: Settings = { ...DEFAULT_SETTINGS } const collections: Collection[] = [] -const games: Game[] = [] +const SAMPLE: [string, string, string][] = [ + ['367520', 'Hollow Knight', 'steam'], ['1245620', 'ELDEN RING', 'steam'], ['1145360', 'Hades', 'steam'], ['504230', 'Celeste', 'steam'], + ['413150', 'Stardew Valley', 'steam'], ['1091500', 'Cyberpunk 2077', 'gog'], ['292030', 'The Witcher 3: Wild Hunt', 'gog'], ['620', 'Portal 2', 'steam'], + ['753640', 'Outer Wilds', 'epic'], ['632470', 'Disco Elysium', 'steam'], ['374320', 'DARK SOULS III', 'steam'], ['1086940', "Baldur's Gate 3", 'steam'], + ['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 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: {} +})) async function themes(): Promise { const out: ThemeEntry[] = [] @@ -33,6 +43,15 @@ export const mock: OrbeApi = { fire('library:changed', games) return g }, + 'library:rescan': async () => ({ added: 0, updated: games.length, uninstalled: 0, merged: 0, failed: [], total: games.length }), + 'library:addManual': async (input) => { + const g: Game = { id: 'manual:' + Date.now(), platform: 'manual', platformId: String(Date.now()), ...input, installed: true, favorite: false, hidden: false, addedAt: Date.now(), playtime: 0, collections: [], media: {} } + games.push(g) + fire('library:changed', games) + return g + }, + '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 }] }), 'collections:list': async () => collections, 'collections:create': async (name) => { const c = { id: String(Date.now()), name, createdAt: Date.now() } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index c16c542..a664283 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -1,7 +1,27 @@ /* 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' +import type { AppInfo, Collection, Game, ScannerId, Settings } from './types' import type { ThemeManifest } from './theme' +export interface ScanReport { + added: number + updated: number + uninstalled: number + merged: number + failed: ScannerId[] + total: number +} + +export interface ManualGameInput { + name: string + exe: string + args?: string + workDir?: string +} + +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 interface ThemeEntry { manifest: ThemeManifest base: string // URL de base des fichiers du thème (orbe-theme:///) @@ -13,6 +33,10 @@ export interface Api { 'app:info': () => Promise 'library:list': () => Promise 'library:update': (id: string, patch: Partial>) => Promise + 'library:rescan': () => Promise + 'library:addManual': (input: ManualGameInput) => Promise + 'library:remove': (id: string) => Promise + 'fs:list': (dir: string, filter: FsFilter) => Promise 'collections:list': () => Promise 'collections:create': (name: string) => Promise 'collections:rename': (id: string, name: string) => Promise @@ -25,6 +49,7 @@ export interface Api { /* Événements poussés par le processus principal vers l'interface */ export interface Events { 'library:changed': Game[] + 'scan:state': { running: boolean; report?: ScanReport } 'settings:changed': Settings 'themes:changed': { id: string; themes: ThemeEntry[] } } @@ -36,6 +61,10 @@ export const CHANNELS = [ 'app:info', 'library:list', 'library:update', + 'library:rescan', + 'library:addManual', + 'library:remove', + 'fs:list', 'collections:list', 'collections:create', 'collections:rename', @@ -45,7 +74,7 @@ export const CHANNELS = [ 'themes:list' ] as const satisfies readonly Channel[] -export const EVENTS = ['library:changed', 'settings:changed', 'themes:changed'] as const satisfies readonly EventName[] +export const EVENTS = ['library:changed', 'scan:state', 'settings:changed', 'themes:changed'] as const satisfies readonly EventName[] /* Ce que l'interface voit dans window.orbe */ export type OrbeApi = Api & { diff --git a/src/shared/types.ts b/src/shared/types.ts index b4114a7..8cf3df6 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -38,6 +38,8 @@ export interface Game { media: GameMedia info?: GameInfo 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 } export interface Session { diff --git a/tests/merge.test.ts b/tests/merge.test.ts new file mode 100644 index 0000000..06f2867 --- /dev/null +++ b/tests/merge.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { mergeScan } from '../src/main/scanners/merge' +import type { Game } from '../src/shared/types' + +const base = (o: Partial): Game => ({ id: '', platform: 'steam', platformId: '', name: '', installed: true, favorite: false, hidden: false, addedAt: 0, playtime: 0, collections: [], media: {}, ...o }) + +describe('mergeScan', () => { + it('ajoute, met à jour et garde les données utilisateur', () => { + const old = [base({ id: 'steam:1', platformId: '1', name: 'Vieux nom', favorite: true, playtime: 500, collections: ['c'] })] + const { games, stats } = mergeScan(old, { ok: { steam: [{ platform: 'steam', platformId: '1', name: 'Nouveau', installDir: 'C:\\A' }, { platform: 'steam', platformId: '2', name: 'B' }] } }, 42) + expect(stats).toMatchObject({ added: 1, updated: 1 }) + const a = games.find((g) => g.id === 'steam:1')! + expect(a).toMatchObject({ name: 'Nouveau', favorite: true, playtime: 500, collections: ['c'], installDir: 'C:\\A' }) + expect(games.find((g) => g.id === 'steam:2')!.addedAt).toBe(42) + }) + it('ne renomme pas un jeu renommé par l’utilisateur', () => { + const { games } = mergeScan([base({ id: 'steam:1', platformId: '1', name: 'Mon nom', nameLocked: true })], { ok: { steam: [{ platform: 'steam', platformId: '1', name: 'Officiel' }] } }) + expect(games[0].name).toBe('Mon nom') + }) + it('marque désinstallé, sans toucher aux scanners en échec', () => { + const old = [base({ id: 'steam:1', platformId: '1' }), base({ id: 'epic:x', platform: 'epic', platformId: 'x' })] + const { games, stats } = mergeScan(old, { ok: { steam: [] } }) + expect(stats.uninstalled).toBe(1) + expect(games.find((g) => g.id === 'steam:1')!.installed).toBe(false) + expect(games.find((g) => g.id === 'epic:x')!.installed).toBe(true) + }) + it('dédoublonne et fusionne un ajout manuel dans le jeu détecté', () => { + const old = [base({ id: 'manual:m', platform: 'manual', platformId: 'm', exe: 'D:\\GOG Games\\W3\\bin\\w3.exe', playtime: 100, favorite: true })] + const { games, stats } = mergeScan(old, { ok: { gog: [{ platform: 'gog', platformId: '9', name: 'W3', installDir: 'D:\\GOG Games\\W3' }, { platform: 'gog', platformId: '9', name: 'W3' }] } }) + expect(games).toHaveLength(1) + expect(stats.merged).toBe(1) + expect(games[0]).toMatchObject({ id: 'gog:9', playtime: 100, favorite: true }) + }) +})