Files
aiturk-hermes-ide/apps/desktop/src/themes/user-themes.ts
T

194 lines
6.1 KiB
TypeScript

/**
* User-installed desktop themes (currently: converted VS Code themes).
*
* This is the extensibility seam. The theme context reads the *merged* registry
* (built-ins + user themes) for `availableThemes` and for every skin lookup, so
* an installed theme shows up everywhere a built-in does — the Cmd-K palette,
* the Appearance settings grid, and `/skin` — with no per-surface wiring.
*
* Stored as a localStorage record so the boot-time paint (which runs before
* React mounts) can resolve a user theme synchronously, same as built-ins.
*/
import { atom, computed } from 'nanostores'
import { registry } from '@/contrib/registry'
import { $backendThemes } from './backend-sync'
import { BUILTIN_THEMES } from './presets'
import type { DesktopTheme, DesktopThemeColors } from './types'
const USER_THEMES_KEY = 'hermes-desktop-user-themes-v1'
// Marketplace imports stamp their description "VS Code · <publisher.extension>"
// (see `convertVscodeColorTheme`). This is the one place that convention is read
// back out, so every install surface can tell what's already installed.
const MARKETPLACE_DESC_PREFIX = 'VS Code · '
// The minimal set of color keys a stored theme must carry to be usable. We keep
// this loose — `applyTheme` tolerates missing optionals via fallbacks — but a
// theme with no background/foreground/primary is junk and gets dropped.
const REQUIRED_COLOR_KEYS: ReadonlyArray<keyof DesktopThemeColors> = ['background', 'foreground', 'primary']
function isValidTheme(value: unknown): value is DesktopTheme {
if (!value || typeof value !== 'object') {
return false
}
const theme = value as Partial<DesktopTheme>
if (typeof theme.name !== 'string' || typeof theme.label !== 'string' || !theme.colors) {
return false
}
const colors = theme.colors as unknown as Record<string, unknown>
return REQUIRED_COLOR_KEYS.every(key => typeof colors[key] === 'string')
}
function readStored(): Record<string, DesktopTheme> {
try {
const raw = window.localStorage.getItem(USER_THEMES_KEY)
if (!raw) {
return {}
}
const parsed: unknown = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {}
}
const out: Record<string, DesktopTheme> = {}
for (const [key, value] of Object.entries(parsed)) {
// Never let a stored theme shadow a built-in name.
if (!BUILTIN_THEMES[key] && isValidTheme(value)) {
out[key] = value
}
}
return out
} catch {
return {}
}
}
function persist(record: Record<string, DesktopTheme>) {
try {
window.localStorage.setItem(USER_THEMES_KEY, JSON.stringify(record))
} catch {
// Best-effort: a restricted storage context shouldn't break theming.
}
}
/** Reactive map of installed user themes, keyed by slug. */
export const $userThemes = atom<Record<string, DesktopTheme>>(typeof window === 'undefined' ? {} : readStored())
/** Install (or replace) a user theme. Returns the stored theme. */
export function installUserTheme(theme: DesktopTheme): DesktopTheme {
if (BUILTIN_THEMES[theme.name]) {
throw new Error(`"${theme.name}" collides with a built-in theme.`)
}
if (!isValidTheme(theme)) {
throw new Error('Theme is missing required colors.')
}
const next = { ...$userThemes.get(), [theme.name]: theme }
$userThemes.set(next)
persist(next)
return theme
}
/** Remove a user theme by slug. No-op for unknown / built-in names. */
export function removeUserTheme(name: string): void {
const current = $userThemes.get()
if (!current[name]) {
return
}
const next = { ...current }
delete next[name]
$userThemes.set(next)
persist(next)
}
export const isUserTheme = (name: string): boolean => Boolean($userThemes.get()[name])
/** The Marketplace extension id an installed theme came from, or null. */
export function marketplaceIdOf(theme: DesktopTheme): string | null {
return theme.description.startsWith(MARKETPLACE_DESC_PREFIX)
? theme.description.slice(MARKETPLACE_DESC_PREFIX.length)
: null
}
/**
* Reactive `extensionId → installed theme` map, so the install UIs can mark
* Marketplace rows you already have (and re-activate them without re-downloading)
* from one memoized source instead of re-deriving the set on every render.
*/
export const $marketplaceInstalls = computed($userThemes, themes => {
const map = new Map<string, DesktopTheme>()
for (const theme of Object.values(themes)) {
const id = marketplaceIdOf(theme)
if (id) {
map.set(id, theme)
}
}
return map
})
// ── Contributed themes — the `themes` registry area ─────────────────────────
// A data contribution IS a DesktopTheme. Same validity bar as an installed
// theme; built-in names can't be shadowed, and user-installed themes win over
// contributed ones of the same name (the user's explicit install is intent).
export const THEMES_AREA = 'themes'
export function contributedThemes(): DesktopTheme[] {
const seen = new Set<string>()
const out: DesktopTheme[] = []
for (const c of registry.getArea(THEMES_AREA)) {
const theme = c.data as DesktopTheme | undefined
if (theme && isValidTheme(theme) && !BUILTIN_THEMES[theme.name] && !seen.has(theme.name)) {
seen.add(theme.name)
out.push(theme)
}
}
return out
}
/** Resolve a theme by name across the merged set (built-in + user + backend + contributed). */
export function resolveTheme(name: string): DesktopTheme | undefined {
return (
BUILTIN_THEMES[name] ??
$userThemes.get()[name] ??
$backendThemes.get()[name] ??
contributedThemes().find(theme => theme.name === name)
)
}
/** Built-ins first (stable order), then contributed, then backend skins, then user installs. */
export function listAllThemes(): DesktopTheme[] {
const user = $userThemes.get()
const backend = $backendThemes.get()
const shadows = (theme: DesktopTheme) => user[theme.name] || backend[theme.name]
return [
...Object.values(BUILTIN_THEMES),
...contributedThemes().filter(theme => !shadows(theme)),
...Object.values(backend).filter(theme => !user[theme.name]),
...Object.values(user)
]
}