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
Some checks are pending
CI / windows (push) Waiting to run
This commit is contained in:
parent
bcc96d5fe5
commit
f68ed4112f
8 changed files with 179 additions and 7 deletions
|
|
@ -2,6 +2,7 @@
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { app, BrowserWindow } from 'electron'
|
import { app, BrowserWindow } from 'electron'
|
||||||
import { emit, handle, registerCore } from './ipc'
|
import { emit, handle, registerCore } from './ipc'
|
||||||
|
import { registerLibrary } from './library'
|
||||||
import { initLog, log } from './log'
|
import { initLog, log } from './log'
|
||||||
import { Store } from './store'
|
import { Store } from './store'
|
||||||
import { registerThemeScheme, Themes } from './themes'
|
import { registerThemeScheme, Themes } from './themes'
|
||||||
|
|
@ -56,19 +57,22 @@ app.on('second-instance', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
registerCore({
|
const ctx = {
|
||||||
store,
|
store,
|
||||||
dataDir,
|
dataDir,
|
||||||
win: () => win,
|
win: () => win,
|
||||||
onSettings: (s, before) => {
|
onSettings: (s: typeof store.settings, before: typeof store.settings) => {
|
||||||
if (win && s.fullscreen !== before.fullscreen) win.setFullScreen(s.fullscreen)
|
if (win && s.fullscreen !== before.fullscreen) win.setFullScreen(s.fullscreen)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
registerCore(ctx)
|
||||||
|
const library = registerLibrary(ctx)
|
||||||
themes.registerProtocol()
|
themes.registerProtocol()
|
||||||
handle('themes:list', async () => themes.list())
|
handle('themes:list', async () => themes.list())
|
||||||
themes.watch((id) => emit(win, 'themes:changed', { id, themes: themes.list() }))
|
themes.watch((id) => emit(win, 'themes:changed', { id, themes: themes.list() }))
|
||||||
win = createWindow()
|
win = createWindow()
|
||||||
L.info('démarrage', app.getVersion())
|
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())
|
app.on('before-quit', () => store.flush())
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,10 @@ export function registerCore(ctx: Ctx): void {
|
||||||
handle('library:update', async (id, patch) => {
|
handle('library:update', async (id, patch) => {
|
||||||
const g = store.games.get(str(id, 300, 'id'))
|
const g = store.games.get(str(id, 300, 'id'))
|
||||||
if (!g) throw new Error('jeu introuvable')
|
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()
|
changed()
|
||||||
return g
|
return g
|
||||||
})
|
})
|
||||||
|
|
|
||||||
68
src/main/library.ts
Normal file
68
src/main/library.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
|
@ -60,3 +60,16 @@ export function gamePatch(p: unknown): GamePatch {
|
||||||
}
|
}
|
||||||
return o
|
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,17 @@ const fire = <E extends EventName>(e: E, p: Events[E]) => listeners.get(e)?.forE
|
||||||
|
|
||||||
let settings: Settings = { ...DEFAULT_SETTINGS }
|
let settings: Settings = { ...DEFAULT_SETTINGS }
|
||||||
const collections: Collection[] = []
|
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<ThemeEntry[]> {
|
async function themes(): Promise<ThemeEntry[]> {
|
||||||
const out: ThemeEntry[] = []
|
const out: ThemeEntry[] = []
|
||||||
|
|
@ -33,6 +43,15 @@ export const mock: OrbeApi = {
|
||||||
fire('library:changed', games)
|
fire('library:changed', games)
|
||||||
return g
|
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:list': async () => collections,
|
||||||
'collections:create': async (name) => {
|
'collections:create': async (name) => {
|
||||||
const c = { id: String(Date.now()), name, createdAt: Date.now() }
|
const c = { id: String(Date.now()), name, createdAt: Date.now() }
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,27 @@
|
||||||
/* Contrat IPC : une seule source de vérité pour le preload (window.orbe) et les handlers du processus principal. */
|
/* 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'
|
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 {
|
export interface ThemeEntry {
|
||||||
manifest: ThemeManifest
|
manifest: ThemeManifest
|
||||||
base: string // URL de base des fichiers du thème (orbe-theme://<id>/)
|
base: string // URL de base des fichiers du thème (orbe-theme://<id>/)
|
||||||
|
|
@ -13,6 +33,10 @@ export interface Api {
|
||||||
'app:info': () => Promise<AppInfo>
|
'app:info': () => Promise<AppInfo>
|
||||||
'library:list': () => Promise<Game[]>
|
'library:list': () => Promise<Game[]>
|
||||||
'library:update': (id: string, patch: Partial<Pick<Game, 'name' | 'favorite' | 'hidden' | 'exe' | 'args' | 'workDir' | 'collections'>>) => Promise<Game>
|
'library:update': (id: string, patch: Partial<Pick<Game, 'name' | 'favorite' | 'hidden' | 'exe' | 'args' | 'workDir' | 'collections'>>) => Promise<Game>
|
||||||
|
'library:rescan': () => Promise<ScanReport>
|
||||||
|
'library:addManual': (input: ManualGameInput) => Promise<Game>
|
||||||
|
'library:remove': (id: string) => Promise<void>
|
||||||
|
'fs:list': (dir: string, filter: FsFilter) => Promise<FsListing>
|
||||||
'collections:list': () => Promise<Collection[]>
|
'collections:list': () => Promise<Collection[]>
|
||||||
'collections:create': (name: string) => Promise<Collection>
|
'collections:create': (name: string) => Promise<Collection>
|
||||||
'collections:rename': (id: string, name: string) => Promise<void>
|
'collections:rename': (id: string, name: string) => Promise<void>
|
||||||
|
|
@ -25,6 +49,7 @@ export interface Api {
|
||||||
/* Événements poussés par le processus principal vers l'interface */
|
/* Événements poussés par le processus principal vers l'interface */
|
||||||
export interface Events {
|
export interface Events {
|
||||||
'library:changed': Game[]
|
'library:changed': Game[]
|
||||||
|
'scan:state': { running: boolean; report?: ScanReport }
|
||||||
'settings:changed': Settings
|
'settings:changed': Settings
|
||||||
'themes:changed': { id: string; themes: ThemeEntry[] }
|
'themes:changed': { id: string; themes: ThemeEntry[] }
|
||||||
}
|
}
|
||||||
|
|
@ -36,6 +61,10 @@ export const CHANNELS = [
|
||||||
'app:info',
|
'app:info',
|
||||||
'library:list',
|
'library:list',
|
||||||
'library:update',
|
'library:update',
|
||||||
|
'library:rescan',
|
||||||
|
'library:addManual',
|
||||||
|
'library:remove',
|
||||||
|
'fs:list',
|
||||||
'collections:list',
|
'collections:list',
|
||||||
'collections:create',
|
'collections:create',
|
||||||
'collections:rename',
|
'collections:rename',
|
||||||
|
|
@ -45,7 +74,7 @@ export const CHANNELS = [
|
||||||
'themes:list'
|
'themes:list'
|
||||||
] as const satisfies readonly Channel[]
|
] 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 */
|
/* Ce que l'interface voit dans window.orbe */
|
||||||
export type OrbeApi = Api & {
|
export type OrbeApi = Api & {
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ export interface Game {
|
||||||
media: GameMedia
|
media: GameMedia
|
||||||
info?: GameInfo
|
info?: GameInfo
|
||||||
steamAppId?: string // utile pour les médias même hors Steam
|
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 {
|
export interface Session {
|
||||||
|
|
|
||||||
34
tests/merge.test.ts
Normal file
34
tests/merge.test.ts
Normal file
|
|
@ -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>): 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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue