Système de thèmes : theme.json validé, protocole orbe-theme://, variables CSS, changement et rechargement à chaud

This commit is contained in:
yasder5 2026-09-30 20:55:12 +02:00
parent 835f8cbbbb
commit acd9870890
14 changed files with 598 additions and 7 deletions

View file

@ -1,9 +1,10 @@
/* Processus principal : fenêtre plein écran, stockage, IPC. */
import { join } from 'node:path'
import { app, BrowserWindow } from 'electron'
import { registerCore } from './ipc'
import { emit, handle, registerCore } from './ipc'
import { initLog, log } from './log'
import { Store } from './store'
import { registerThemeScheme, Themes } from './themes'
// Toutes les données dans %APPDATA%/OrbeLauncher (réglages, bibliothèque, cache, logs)
const dataDir = join(app.getPath('appData'), 'OrbeLauncher')
@ -14,9 +15,11 @@ 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()
registerThemeScheme()
let win: BrowserWindow | null = null
const store = new Store(dataDir)
const themes = new Themes(dataDir)
function createWindow(): BrowserWindow {
const w = new BrowserWindow({
@ -61,6 +64,9 @@ app.whenReady().then(() => {
if (win && s.fullscreen !== before.fullscreen) win.setFullScreen(s.fullscreen)
}
})
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())
})

88
src/main/themes.ts Normal file
View file

@ -0,0 +1,88 @@
/* Thèmes : dossiers livrés (resources/themes) + dossiers de l'utilisateur (%APPDATA%/OrbeLauncher/themes, prioritaires).
Fichiers servis par le protocole orbe-theme://<id>/<chemin> ; rechargement à chaud quand un fichier change. */
import { existsSync, mkdirSync, readdirSync, readFileSync, watch, type FSWatcher } from 'node:fs'
import { join, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
import { app, net, protocol } from 'electron'
import type { ThemeEntry } from '../shared/ipc'
import { validateTheme, safeJoin } from '../shared/theme'
import { log } from './log'
const L = log('themes')
export class Themes {
private dirs = new Map<string, string>() // id -> dossier
private watchers: FSWatcher[] = []
readonly userDir: string
readonly builtinDir: string
constructor(dataDir: string) {
this.userDir = join(dataDir, 'themes')
this.builtinDir = app.isPackaged ? join(process.resourcesPath, 'themes') : join(app.getAppPath(), 'themes')
mkdirSync(this.userDir, { recursive: true })
}
list(): ThemeEntry[] {
const out = new Map<string, ThemeEntry>()
this.dirs.clear()
for (const [root, builtin] of [[this.builtinDir, true], [this.userDir, false]] as const) {
if (!existsSync(root)) continue
for (const d of readdirSync(root, { withFileTypes: true })) {
if (!d.isDirectory()) continue
const file = join(root, d.name, 'theme.json')
if (!existsSync(file)) continue
try {
const check = validateTheme(JSON.parse(readFileSync(file, 'utf8')), d.name)
if (!check.theme) { L.warn('thème refusé', d.name, check.errors); continue }
if (check.warnings.length) L.warn('thème', d.name, check.warnings)
out.set(d.name, { manifest: check.theme, base: `orbe-theme://${d.name}/`, builtin, warnings: check.warnings })
this.dirs.set(d.name, join(root, d.name))
} catch (e) {
L.error('theme.json illisible', file, e) // un thème cassé ne casse pas les autres
}
}
}
return [...out.values()]
}
registerProtocol(): void {
protocol.handle('orbe-theme', (req) => {
const u = new URL(req.url)
if (!this.dirs.size) this.list()
const root = this.dirs.get(u.hostname)
const file = root && safeJoin(root, u.pathname, sep)
if (!file || !existsSync(file)) return new Response('introuvable', { status: 404 })
return net.fetch(pathToFileURL(file).toString())
})
}
/* Surveille les dossiers ; cb(id) quand un thème change. Dossier livré surveillé seulement en dev. */
watch(cb: (id: string) => void): void {
const roots = app.isPackaged ? [this.userDir] : [this.builtinDir, this.userDir]
const timers = new Map<string, NodeJS.Timeout>()
for (const root of roots) {
if (!existsSync(root)) continue
try {
this.watchers.push(
watch(root, { recursive: true }, (_ev, name) => {
const id = String(name ?? '').split(/[\\/]/)[0]
if (!id) return
clearTimeout(timers.get(id))
timers.set(id, setTimeout(() => cb(id), 200)) // un enregistrement = plusieurs événements
})
)
} catch (e) {
L.warn('surveillance impossible', root, e)
}
}
}
}
/* À appeler avant app.ready */
export function registerThemeScheme(): void {
protocol.registerSchemesAsPrivileged([
{ scheme: 'orbe-theme', privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true, corsEnabled: true } },
{ scheme: 'orbe-media', privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true } }
])
}