Fusion des scans (désinstallés, doublons, ajouts manuels), rescan, ajout manuel et explorateur de fichiers pour la manette
Some checks are pending
CI / windows (push) Waiting to run

This commit is contained in:
yasder5 2026-10-07 20:47:19 +02:00
parent bcc96d5fe5
commit f68ed4112f
8 changed files with 179 additions and 7 deletions

View file

@ -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())

View file

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

68
src/main/library.ts Normal file
View file

@ -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<FsFilter, RegExp> = { 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<ScanReport> } {
const { store } = ctx
let running: Promise<ScanReport> | 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 }
}

View file

@ -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<string, unknown>
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')
}
}