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

@ -12,8 +12,8 @@
"preview": "electron-vite preview",
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "vitest run",
"dist": "electron-vite build && electron-builder --win nsis portable",
"dist:dir": "electron-vite build && electron-builder --dir"
"dist": "electron-vite build && electron-builder --win nsis portable --publish never",
"dist:dir": "electron-vite build && electron-builder --dir --publish never"
},
"devDependencies": {
"@types/node": "^22.20.5",

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

View file

@ -1,3 +1,31 @@
export function App() {
return <main>Orbe Launcher</main>
import { Background } from './components/Background'
import { SettingsProvider, useSettings } from './state/settings'
import { ThemeProvider, useTheme } from './theme/theme'
import './styles/base.css'
function Preview() {
const { themes, theme } = useTheme()
const { update } = useSettings()
return (
<main style={{ padding: 'var(--pad)' }}>
<Background paused={false} />
<p className="kicker">Thèmes</p>
<h1 className="hero-title">Orbe <mark>Launcher</mark></h1>
<div style={{ display: 'flex', gap: 12, marginTop: 32 }}>
{themes.map((t) => (
<button key={t.manifest.id} className={'btn' + (t === theme ? ' btn--primary' : '')} onClick={() => update({ theme: t.manifest.id })}>{t.manifest.name}</button>
))}
</div>
</main>
)
}
export function App() {
return (
<SettingsProvider>
<ThemeProvider>
<Preview />
</ThemeProvider>
</SettingsProvider>
)
}

12
src/renderer/src/api.ts Normal file
View file

@ -0,0 +1,12 @@
/* Accès à l'API du processus principal. Hors Electron (aperçu navigateur), une maquette en mémoire prend le relais. */
import type { OrbeApi } from '@shared/ipc'
import { mock } from './dev/mock'
declare global {
interface Window {
orbe?: OrbeApi
}
}
export const api: OrbeApi = window.orbe ?? mock
export const isMock = !window.orbe

View file

@ -0,0 +1,19 @@
/* Fond défini par le thème : orbe, image, vidéo ou rien. La vidéo est mise en pause pendant un jeu. */
import { useEffect, useRef } from 'react'
import { useTheme } from '../theme/theme'
import { Orb } from './Orb'
export function Background({ paused }: { paused: boolean }) {
const { theme, url } = useTheme()
const video = useRef<HTMLVideoElement>(null)
useEffect(() => { if (video.current) paused ? video.current.pause() : video.current.play().catch(() => {}) }, [paused])
const bg = theme?.manifest.background
if (!bg || bg.type === 'none') return null
return (
<div className="bg-layer" aria-hidden>
{bg.type === 'orb' && <Orb />}
{bg.type === 'image' && <img src={url(bg.src)} alt="" />}
{bg.type === 'video' && <video ref={video} src={url(bg.src)} autoPlay muted loop playsInline />}
</div>
)
}

View file

@ -0,0 +1,47 @@
/* Orbe de particules (sphère de Fibonacci en rotation), reprise du site. S'arrête quand l'appli est en pause. */
import { useEffect, useRef } from 'react'
interface Props {
count?: number
radius?: number // part de la plus petite dimension
x?: number // centre, 0…1
y?: number
className?: string
}
export function Orb({ count = 520, radius = 0.36, x = 0.72, y = 0.5, className }: Props) {
const ref = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const cv = ref.current!, ctx = cv.getContext('2d')!
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#d3fd50'
const text = getComputedStyle(document.documentElement).getPropertyValue('--text').trim() || '#f5f5f0'
const pts: [number, number, number, boolean][] = []
for (let i = 0; i < count; i++) {
const yy = 1 - (i / (count - 1)) * 2, r = Math.sqrt(1 - yy * yy), th = i * 2.39996323
pts.push([Math.cos(th) * r, yy, Math.sin(th) * r, i % 9 === 0])
}
let raf = 0, W = 0, H = 0
const size = () => { W = cv.width = cv.clientWidth * devicePixelRatio; H = cv.height = cv.clientHeight * devicePixelRatio }
size()
addEventListener('resize', size)
const t0 = performance.now()
const frame = (now: number) => {
raf = requestAnimationFrame(frame)
if (document.documentElement.dataset.paused !== undefined) return // jeu lancé : on ne dessine plus rien
const t = (now - t0) / 1000, a = t * 0.12, b = Math.sin(t * 0.17) * 0.3
const ca = Math.cos(a), sa = Math.sin(a), cb = Math.cos(b), sb = Math.sin(b), R = Math.min(W, H) * radius
ctx.clearRect(0, 0, W, H)
for (const [px, py, pz, hot] of pts) {
const x1 = px * ca - pz * sa, z1 = px * sa + pz * ca, y1 = py * cb - z1 * sb, z2 = py * sb + z1 * cb, d = (z2 + 1.4) / 2.4
ctx.globalAlpha = hot ? 0.2 + d * 0.7 : 0.06 + d * 0.42
ctx.fillStyle = hot ? accent : text
ctx.beginPath()
ctx.arc(W * x + x1 * R, H * y + y1 * R, (hot ? 2.4 : 1.5) * devicePixelRatio * (0.6 + d * 0.7), 0, 6.283)
ctx.fill()
}
}
raf = requestAnimationFrame(frame)
return () => { cancelAnimationFrame(raf); removeEventListener('resize', size) }
}, [count, radius, x, y])
return <canvas ref={ref} className={className} />
}

View file

@ -0,0 +1,61 @@
/* Maquette de l'API pour l'aperçu dans un navigateur (développement de l'interface, captures). Données fictives. */
import type { Events, EventName, OrbeApi, ThemeEntry } from '@shared/ipc'
import { validateTheme } from '@shared/theme'
import { DEFAULT_SETTINGS, type Collection, type Game, type Settings } from '@shared/types'
const listeners = new Map<string, Set<(p: never) => void>>()
const fire = <E extends EventName>(e: E, p: Events[E]) => listeners.get(e)?.forEach((cb) => (cb as (x: Events[E]) => void)(p))
let settings: Settings = { ...DEFAULT_SETTINGS }
const collections: Collection[] = []
const games: Game[] = []
async function themes(): Promise<ThemeEntry[]> {
const out: ThemeEntry[] = []
for (const id of ['orbe', 'papier']) {
const base = `./themes/${id}/`
try {
const c = validateTheme(await (await fetch(base + 'theme.json')).json(), id)
if (c.theme) out.push({ manifest: c.theme, base, builtin: true, warnings: c.warnings })
} catch {
/* thème absent de l'aperçu */
}
}
return out
}
export const mock: OrbeApi = {
'app:info': async () => ({ version: 'aperçu', platform: 'web', dataDir: '-', dev: true }),
'library:list': async () => games,
'library:update': async (id, patch) => {
const g = games.find((x) => x.id === id)!
Object.assign(g, patch)
fire('library:changed', games)
return g
},
'collections:list': async () => collections,
'collections:create': async (name) => {
const c = { id: String(Date.now()), name, createdAt: Date.now() }
collections.push(c)
return c
},
'collections:rename': async (id, name) => {
const c = collections.find((x) => x.id === id)
if (c) c.name = name
},
'collections:delete': async (id) => {
collections.splice(collections.findIndex((c) => c.id === id), 1)
},
'settings:get': async () => settings,
'settings:set': async (patch) => {
settings = { ...settings, ...patch }
fire('settings:changed', settings)
return settings
},
'themes:list': themes,
on: (event, cb) => {
if (!listeners.has(event)) listeners.set(event, new Set())
listeners.get(event)!.add(cb as (p: never) => void)
return () => listeners.get(event)!.delete(cb as (p: never) => void)
}
}

View file

@ -0,0 +1,25 @@
/* Réglages partagés dans l'interface, synchronisés avec le processus principal. */
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
import { DEFAULT_SETTINGS, type Settings } from '@shared/types'
import { api } from '../api'
interface Ctx {
settings: Settings
ready: boolean
update: (patch: Partial<Settings>) => Promise<void>
}
const SettingsCtx = createContext<Ctx>({ settings: DEFAULT_SETTINGS, ready: false, update: async () => {} })
export function SettingsProvider({ children }: { children: ReactNode }) {
const [settings, setSettings] = useState<Settings>(DEFAULT_SETTINGS)
const [ready, setReady] = useState(false)
useEffect(() => {
api['settings:get']().then((s) => { setSettings(s); setReady(true) })
return api.on('settings:changed', setSettings)
}, [])
const update = useCallback(async (patch: Partial<Settings>) => setSettings(await api['settings:set'](patch)), [])
return <SettingsCtx.Provider value={{ settings, ready, update }}>{children}</SettingsCtx.Provider>
}
export const useSettings = () => useContext(SettingsCtx)

View file

@ -0,0 +1,49 @@
/* Base de l'interface : uniquement des variables (définies par le thème ; valeurs Orbe par défaut ici). */
:root {
--bg: #0a0a0a; --surface: #121212; --surface-2: #1a1a19; --text: #f5f5f0; --muted: #8f8f8a; --line: #262624;
--accent: #d3fd50; --on-accent: #0a0a0a; --danger: #ff6b5a;
--font-sans: system-ui, sans-serif; --font-mono: ui-monospace, monospace;
--radius-s: 4px; --radius-m: 4px; --radius-l: 6px;
--shadow-card: 0 18px 40px rgba(0, 0, 0, .55); --focus-ring: 0 0 0 2px var(--accent); --focus-scale: 1.06;
--dur-fast: 150ms; --dur-med: 300ms; --dur-slow: 600ms; --ease: cubic-bezier(.2, .8, .2, 1);
--pad: clamp(24px, 4vw, 64px);
color-scheme: dark;
}
:root[data-paused] *, :root[data-paused] *::before, :root[data-paused] *::after { animation-play-state: paused !important; }
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body { background: var(--bg); color: var(--text); font: 400 18px/1.5 var(--font-sans); overflow: hidden; user-select: none; -webkit-font-smoothing: antialiased; }
:root[data-input='pad'] body, :root[data-input='pad'] * { cursor: none !important; }
button, input { font: inherit; color: inherit; }
button { background: none; border: 0; padding: 0; cursor: pointer; }
img { display: block; }
:focus { outline: none; }
/* Typo */
.title { font-size: clamp(34px, 4vw, 64px); margin: 0; font-weight: 800; }
.hero-title { font-size: clamp(48px, 7vw, 120px); margin: 0; font-weight: 900; }
.kicker { font-size: 13px; color: var(--accent); margin: 0 0 12px; }
mark { background: none; color: var(--accent); }
.muted { color: var(--muted); }
.mono { font-family: var(--font-mono); }
/* Boutons */
.btn { display: inline-flex; align-items: center; gap: 10px; padding: 14px 22px; border-radius: var(--radius-s); background: var(--surface-2); color: var(--text); border: 1px solid var(--line);
transition: background var(--dur-fast), color var(--dur-fast), box-shadow var(--dur-fast), transform var(--dur-fast) var(--ease); white-space: nowrap; }
.btn--primary { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.btn--danger.is-focused { background: var(--danger); border-color: var(--danger); color: var(--on-accent); }
.btn.is-focused { box-shadow: var(--focus-ring); transform: translateY(-2px); }
.btn:disabled { opacity: .4; }
/* Cartes de jeu */
.card { position: relative; border-radius: var(--radius-m); background: var(--surface); overflow: hidden; aspect-ratio: 2 / 3;
transition: transform var(--dur-med) var(--ease), box-shadow var(--dur-med) var(--ease), border-color var(--dur-med); }
.card img { width: 100%; height: 100%; object-fit: cover; }
.card.is-focused { transform: scale(var(--focus-scale)); box-shadow: var(--focus-ring), var(--shadow-card); z-index: 2; }
/* En-tête */
.topbar { position: fixed; inset: 0 0 auto 0; height: 76px; display: flex; align-items: center; gap: 28px; padding: 0 var(--pad); z-index: 20; }
/* Fond animé */
.bg-layer { position: fixed; inset: 0; z-index: -1; pointer-events: none; }
.bg-layer canvas, .bg-layer video, .bg-layer img { width: 100%; height: 100%; object-fit: cover; }

View file

@ -0,0 +1,54 @@
/* Application du thème actif : variables CSS sur :root, polices, feuille theme.css. Changement et rechargement à chaud. */
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import type { ThemeEntry } from '@shared/ipc'
import { api } from '../api'
import { useSettings } from '../state/settings'
interface Ctx {
theme: ThemeEntry | null
themes: ThemeEntry[]
url: (rel?: string) => string | undefined // chemin du thème -> URL
}
const ThemeCtx = createContext<Ctx>({ theme: null, themes: [], url: () => undefined })
let applied: string[] = []
function apply(t: ThemeEntry, version: number): void {
const root = document.documentElement, m = t.manifest
for (const k of applied) root.style.removeProperty(k)
for (const [k, v] of Object.entries(m.vars)) root.style.setProperty(k, v)
applied = Object.keys(m.vars)
root.dataset.theme = m.id
root.dataset.layout = m.layout.library
const fonts = el('style', 'theme-fonts') as HTMLStyleElement
fonts.textContent = m.fonts
.map((f) => `@font-face{font-family:"${f.family}";src:url("${t.base + f.src}");font-weight:${f.weight ?? '400'};font-style:${f.style ?? 'normal'};font-display:swap;${f.unicodeRange ? `unicode-range:${f.unicodeRange};` : ''}}`)
.join('\n')
const link = el('link', 'theme-css') as HTMLLinkElement
link.rel = 'stylesheet'
if (m.css) link.href = `${t.base}${m.css}?v=${version}` // ?v= force le rechargement après modification
else link.removeAttribute('href')
}
function el(tag: string, id: string): HTMLElement {
let n = document.getElementById(id)
if (!n) { n = document.createElement(tag); n.id = id; document.head.append(n) }
return n
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const { settings, ready } = useSettings()
const [themes, setThemes] = useState<ThemeEntry[]>([])
const [version, setVersion] = useState(Date.now())
useEffect(() => {
api['themes:list']().then(setThemes)
return api.on('themes:changed', ({ themes }) => { setThemes(themes); setVersion(Date.now()) })
}, [])
const theme = themes.find((t) => t.manifest.id === settings.theme) ?? themes.find((t) => t.manifest.id === 'orbe') ?? themes[0] ?? null
useEffect(() => { if (theme && ready) apply(theme, version) }, [theme, version, ready])
const url = (rel?: string) => (rel && theme ? theme.base + rel : undefined)
return <ThemeCtx.Provider value={{ theme, themes, url }}>{children}</ThemeCtx.Provider>
}
export const useTheme = () => useContext(ThemeCtx)

View file

@ -1,5 +1,13 @@
/* 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 { ThemeManifest } from './theme'
export interface ThemeEntry {
manifest: ThemeManifest
base: string // URL de base des fichiers du thème (orbe-theme://<id>/)
builtin: boolean
warnings: string[]
}
export interface Api {
'app:info': () => Promise<AppInfo>
@ -11,12 +19,14 @@ export interface Api {
'collections:delete': (id: string) => Promise<void>
'settings:get': () => Promise<Settings>
'settings:set': (patch: Partial<Settings>) => Promise<Settings>
'themes:list': () => Promise<ThemeEntry[]>
}
/* Événements poussés par le processus principal vers l'interface */
export interface Events {
'library:changed': Game[]
'settings:changed': Settings
'themes:changed': { id: string; themes: ThemeEntry[] }
}
export type Channel = keyof Api
@ -31,10 +41,11 @@ export const CHANNELS = [
'collections:rename',
'collections:delete',
'settings:get',
'settings:set'
'settings:set',
'themes:list'
] as const satisfies readonly Channel[]
export const EVENTS = ['library:changed', 'settings:changed'] as const satisfies readonly EventName[]
export const EVENTS = ['library:changed', 'settings:changed', 'themes:changed'] as const satisfies readonly EventName[]
/* Ce que l'interface voit dans window.orbe */
export type OrbeApi = Api & {

141
src/shared/theme.ts Normal file
View file

@ -0,0 +1,141 @@
/* Format d'un thème (themes/<id>/theme.json) et validation. Pur : utilisé par le processus principal et les tests. */
export type LibraryLayout = 'grid' | 'carousel' | 'list'
export type SoundName = 'move' | 'select' | 'back' | 'error' | 'launch'
export const SOUND_NAMES: SoundName[] = ['move', 'select', 'back', 'error', 'launch']
export interface ThemeFont {
family: string
src: string // chemin relatif au dossier du thème
weight?: string // "400" ou plage "100 900"
style?: string
unicodeRange?: string // ex. "U+0000-00FF" quand une police est découpée en plusieurs fichiers
}
export type ThemeBackground =
| { type: 'none' }
| { type: 'orb' } // orbe de particules animée (thème Orbe)
| { type: 'image'; src: string }
| { type: 'video'; src: string }
export interface ThemeSplash {
type: 'orb' | 'logo' | 'image' | 'video' | 'none'
src?: string
duration: number // ms
}
export interface ThemeManifest {
id: string
name: string
author?: string
version?: string
description?: string
vars: Record<string, string> // variables CSS posées sur :root
fonts: ThemeFont[]
css?: string // feuille de style du thème (theme.css par défaut)
layout: { library: LibraryLayout }
background: ThemeBackground
splash: ThemeSplash
sounds: Partial<Record<SoundName, string>>
}
/* Variables que l'interface utilise : un thème qui en oublie hérite de celles d'Orbe */
export const REQUIRED_VARS = [
'--bg', '--surface', '--surface-2', '--text', '--muted', '--line', '--accent', '--on-accent', '--danger',
'--font-sans', '--font-mono',
'--radius-s', '--radius-m', '--radius-l',
'--shadow-card', '--focus-ring', '--focus-scale',
'--dur-fast', '--dur-med', '--dur-slow', '--ease'
] as const
export interface ThemeCheck {
theme: ThemeManifest | null
errors: string[] // bloquant : thème refusé
warnings: string[] // non bloquant : thème chargé avec des valeurs par défaut
}
const SAFE_PATH = /^(?!.*\.\.)[\w\-./ ]{1,200}$/ // chemin relatif sans remontée
const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
export function validateTheme(raw: unknown, dirName: string): ThemeCheck {
const errors: string[] = [], warnings: string[] = []
if (!isObj(raw)) return { theme: null, errors: ['theme.json : objet attendu'], warnings }
const id = typeof raw.id === 'string' ? raw.id : dirName
if (!/^[a-z0-9-]{1,40}$/.test(id)) errors.push('id : minuscules, chiffres et tirets uniquement')
if (id !== dirName) warnings.push(`id « ${id} » différent du dossier « ${dirName} », le dossier fait foi`)
const name = typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim().slice(0, 60) : dirName
const vars: Record<string, string> = {}
if (isObj(raw.vars)) {
for (const [k, v] of Object.entries(raw.vars)) {
if (!/^--[a-z0-9-]{1,40}$/.test(k)) warnings.push(`variable ignorée : ${k}`)
else if (typeof v !== 'string' || v.length > 300 || /[;{}<>]|url\(/i.test(v)) warnings.push(`valeur ignorée pour ${k}`)
else vars[k] = v
}
} else warnings.push('vars absent : couleurs du thème Orbe utilisées')
const missing = REQUIRED_VARS.filter((k) => !(k in vars))
if (missing.length && isObj(raw.vars)) warnings.push('variables manquantes (valeurs Orbe) : ' + missing.join(', '))
const path = (v: unknown, what: string): string | undefined => {
if (v === undefined) return undefined
if (typeof v === 'string' && SAFE_PATH.test(v)) return v
errors.push(`${what} : chemin relatif invalide`)
return undefined
}
const fonts: ThemeFont[] = []
if (Array.isArray(raw.fonts)) {
for (const f of raw.fonts) {
if (!isObj(f) || typeof f.family !== 'string' || !/^[\w\- ]{1,60}$/.test(f.family)) { warnings.push('police ignorée (family invalide)'); continue }
const src = path(f.src, 'police ' + f.family)
if (src) fonts.push({ family: f.family, src, weight: typeof f.weight === 'string' && /^\d{3}( \d{3})?$/.test(f.weight) ? f.weight : undefined, style: f.style === 'italic' ? 'italic' : undefined, unicodeRange: typeof f.unicodeRange === 'string' && /^[U+0-9A-Fa-f?, -]{1,400}$/.test(f.unicodeRange) ? f.unicodeRange : undefined })
}
}
const layoutRaw = isObj(raw.layout) ? raw.layout.library : undefined
const library: LibraryLayout = layoutRaw === 'carousel' || layoutRaw === 'list' ? layoutRaw : 'grid'
if (layoutRaw !== undefined && layoutRaw !== library) warnings.push('layout.library inconnu, grille utilisée')
let background: ThemeBackground = { type: 'none' }
if (isObj(raw.background)) {
const t = raw.background.type
if (t === 'orb') background = { type: 'orb' }
else if (t === 'image' || t === 'video') {
const src = path(raw.background.src, 'fond')
if (src) background = { type: t, src }
} else if (t !== 'none') warnings.push('fond inconnu, aucun fond')
}
let splash: ThemeSplash = { type: 'logo', duration: 1200 }
if (isObj(raw.splash)) {
const t = raw.splash.type
const type = t === 'orb' || t === 'image' || t === 'video' || t === 'none' || t === 'logo' ? t : 'logo'
const d = typeof raw.splash.duration === 'number' ? Math.min(Math.max(raw.splash.duration, 0), 6000) : 1200
splash = { type, duration: d, src: type === 'image' || type === 'video' ? path(raw.splash.src, 'écran de démarrage') : undefined }
if ((type === 'image' || type === 'video') && !splash.src) splash = { type: 'logo', duration: d }
}
const sounds: ThemeManifest['sounds'] = {}
if (isObj(raw.sounds)) for (const n of SOUND_NAMES) { const s = path(raw.sounds[n], 'son ' + n); if (s) sounds[n] = s }
const css = raw.css === undefined ? 'theme.css' : path(raw.css, 'css')
const meta = (k: string) => (typeof raw[k] === 'string' ? (raw[k] as string).slice(0, 200) : undefined)
if (errors.length) return { theme: null, errors, warnings }
return {
theme: { id: dirName, name, author: meta('author'), version: meta('version'), description: meta('description'), vars, fonts, css, layout: { library }, background, splash, sounds },
errors,
warnings
}
}
/* Résout un chemin demandé dans un thème ; null si on sort du dossier */
export function safeJoin(root: string, rel: string, sep = '/'): string | null {
const parts: string[] = []
for (const p of decodeURIComponent(rel).split(/[\\/]+/)) {
if (!p || p === '.') continue
if (p === '..' || p.includes(':')) return null
parts.push(p)
}
return parts.length ? root.replace(/[\\/]+$/, '') + sep + parts.join(sep) : null
}

50
tests/theme.test.ts Normal file
View file

@ -0,0 +1,50 @@
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { REQUIRED_VARS, safeJoin, validateTheme } from '../src/shared/theme'
const themesDir = join(__dirname, '..', 'themes')
describe('validateTheme', () => {
it('valide les thèmes livrés sans avertissement et avec toutes les variables', () => {
for (const id of readdirSync(themesDir)) {
const c = validateTheme(JSON.parse(readFileSync(join(themesDir, id, 'theme.json'), 'utf8')), id)
expect(c.errors, id).toEqual([])
expect(c.warnings, id).toEqual([])
for (const v of REQUIRED_VARS) expect(c.theme!.vars[v], `${id} ${v}`).toBeTruthy()
}
})
it('applique des valeurs par défaut sûres', () => {
const c = validateTheme({ name: 'X', layout: { library: 'spirale' }, splash: { type: 'video' } }, 'x')
expect(c.theme!.layout.library).toBe('grid')
expect(c.theme!.splash.type).toBe('logo')
expect(c.theme!.css).toBe('theme.css')
expect(c.warnings.length).toBeGreaterThan(0)
})
it('refuse les chemins qui sortent du dossier', () => {
expect(validateTheme({ background: { type: 'image', src: '../../secret.png' } }, 'x').theme).toBeNull()
expect(validateTheme({ sounds: { move: 'C:/Windows/x.wav' } }, 'x').theme).toBeNull()
})
it('ignore les variables dangereuses', () => {
const c = validateTheme({ vars: { '--bg': 'red; } body { display:none', '--ok': '#fff', bad: '1', '--img': 'url(http://x)' } }, 'x')
expect(c.theme!.vars).toEqual({ '--ok': '#fff' })
})
it('refuse un id invalide', () => {
expect(validateTheme({}, 'Mon Thème').theme).toBeNull()
})
})
describe('safeJoin', () => {
it('reste dans le dossier', () => {
expect(safeJoin('/t/orbe', '/fonts/a.woff2')).toBe('/t/orbe/fonts/a.woff2')
expect(safeJoin('/t/orbe', '/../x')).toBeNull()
expect(safeJoin('/t/orbe', '/%2e%2e/x')).toBeNull()
expect(safeJoin('C:\\t\\orbe', '/a/b.css', '\\')).toBe('C:\\t\\orbe\\a\\b.css')
expect(safeJoin('/t/orbe', '/c:/x')).toBeNull()
expect(safeJoin('/t/orbe', '/')).toBeNull()
})
})