Système de thèmes : theme.json validé, protocole orbe-theme://, variables CSS, changement et rechargement à chaud
This commit is contained in:
parent
835f8cbbbb
commit
acd9870890
14 changed files with 598 additions and 7 deletions
|
|
@ -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
88
src/main/themes.ts
Normal 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 } }
|
||||
])
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue