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
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
])
|
||||
}
|
||||
|
|
@ -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
12
src/renderer/src/api.ts
Normal 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
|
||||
19
src/renderer/src/components/Background.tsx
Normal file
19
src/renderer/src/components/Background.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
src/renderer/src/components/Orb.tsx
Normal file
47
src/renderer/src/components/Orb.tsx
Normal 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} />
|
||||
}
|
||||
61
src/renderer/src/dev/mock.ts
Normal file
61
src/renderer/src/dev/mock.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
25
src/renderer/src/state/settings.tsx
Normal file
25
src/renderer/src/state/settings.tsx
Normal 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)
|
||||
49
src/renderer/src/styles/base.css
Normal file
49
src/renderer/src/styles/base.css
Normal 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; }
|
||||
54
src/renderer/src/theme/theme.tsx
Normal file
54
src/renderer/src/theme/theme.tsx
Normal 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)
|
||||
|
|
@ -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
141
src/shared/theme.ts
Normal 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
50
tests/theme.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue