mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat(themes): import a theme from a local .zip (#1012)
* feat(themes): validate and extract locally imported theme packages Add the backend + validation half of local theme import: users will be able to load a theme packaged as a .zip (manifest.json + theme.css). - `import_theme_zip` Tauri command unpacks only manifest.json + theme.css from the archive, outside the webview, with an archive-size cap, per-entry uncompressed caps, and path-traversal rejection. - `validateThemePackage` runs the full theme-store contract in-app: the manifest schema (field patterns copied from the repo schema), the CSS token whitelist with all core tokens required, color-scheme matching the declared mode, data-URI restricted to the arrow token, and a guard against ids that collide with built-in themes. The existing `validateThemeCss` containment guard is reused and runs again at injection time. The contract token list is a byte-identical copy of the themes repo's allowed-tokens.json. Covered by validateThemePackage tests for every rejection class. * feat(themes): add the Import a theme section to the Themes tab A dedicated section between the theme scheduler and the Theme Store lets users import a theme from a local .zip. After the package validates, a confirmation dialog names the theme and its author before it is installed. A rejected import shows a plain-language explanation aimed at end users; the raw contract diagnostics (token names, missing fields) are tucked into a collapsible "Technical details" block for theme authors. Fully localised across all nine languages. * docs(themes): changelog + credits for local theme import Fold the local .zip import (and the store pagination / refresh-scroll polish) into the still-unreleased Theme Store entry rather than a new section, and extend the credits line. PRs #1011 and #1012.
This commit is contained in:
+3
-1
@@ -56,10 +56,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Themes — community Theme Store
|
### Themes — community Theme Store
|
||||||
|
|
||||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1009](https://github.com/Psychotoxical/psysonic/pull/1009)**
|
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1009](https://github.com/Psychotoxical/psysonic/pull/1009), [#1011](https://github.com/Psychotoxical/psysonic/pull/1011), [#1012](https://github.com/Psychotoxical/psysonic/pull/1012)**
|
||||||
|
|
||||||
* New **Settings → Themes** tab: pick a theme, set the day/night scheduler, and browse a built-in **Theme Store** to install, update and uninstall community themes — with search, a dark/light filter, and full-size thumbnail previews.
|
* New **Settings → Themes** tab: pick a theme, set the day/night scheduler, and browse a built-in **Theme Store** to install, update and uninstall community themes — with search, a dark/light filter, and full-size thumbnail previews.
|
||||||
* The app now bundles six core themes (Catppuccin Mocha & Latte, Kanagawa Wave, Stark HUD, and the colour-blind-safe Vision Dark / Vision Navy); every other palette installs on demand from the [psysonic-themes](https://github.com/Psysonic/psysonic-themes) repo. Installed themes are saved locally and apply instantly at startup, even offline.
|
* The app now bundles six core themes (Catppuccin Mocha & Latte, Kanagawa Wave, Stark HUD, and the colour-blind-safe Vision Dark / Vision Navy); every other palette installs on demand from the [psysonic-themes](https://github.com/Psysonic/psysonic-themes) repo. Installed themes are saved locally and apply instantly at startup, even offline.
|
||||||
|
* **Import a theme from a local `.zip`** (manifest.json + theme.css): the package is fully validated against the theme contract, you confirm its name and author, then it installs like any other community theme.
|
||||||
|
* The store paginates large catalogues, and refreshing the list no longer jumps back to the top.
|
||||||
* Upgrading from an older build: an active or scheduled theme that has moved to the store and isn't installed falls back to Mocha (dark) / Latte (light).
|
* Upgrading from an older build: an active or scheduled theme that has moved to the store and isn't installed falls back to Mocha (dark) / Latte (light).
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ pub mod cli;
|
|||||||
mod cover_cache;
|
mod cover_cache;
|
||||||
mod library_analysis_backfill;
|
mod library_analysis_backfill;
|
||||||
mod lib_commands;
|
mod lib_commands;
|
||||||
|
mod theme_import;
|
||||||
|
|
||||||
pub use psysonic_integration::discord;
|
pub use psysonic_integration::discord;
|
||||||
|
|
||||||
@@ -593,6 +594,7 @@ pub fn run() {
|
|||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
greet,
|
greet,
|
||||||
|
theme_import::import_theme_zip,
|
||||||
backup_export_library_db,
|
backup_export_library_db,
|
||||||
backup_import_library_db,
|
backup_import_library_db,
|
||||||
backup_export_full,
|
backup_export_full,
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
//! Local theme-package import.
|
||||||
|
//!
|
||||||
|
//! Reads a user-picked `.zip` and returns its `manifest.json` + `theme.css`
|
||||||
|
//! to the frontend, which runs the full theme-store contract validation
|
||||||
|
//! (`src/utils/themes/validateThemePackage.ts`) before installing.
|
||||||
|
//!
|
||||||
|
//! Only those two entries are pulled out — the thumbnail is not needed (the UI
|
||||||
|
//! derives a swatch from the CSS). Parsing the untrusted archive happens here
|
||||||
|
//! in Rust, outside the webview, and every read is size-capped so a malformed
|
||||||
|
//! or hostile archive (lying header, zip-bomb, path traversal) cannot exhaust
|
||||||
|
//! memory or escape the archive.
|
||||||
|
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// On-disk archive cap. A real token-only theme zip is a few KB.
|
||||||
|
const MAX_ARCHIVE_BYTES: u64 = 4 * 1024 * 1024;
|
||||||
|
/// Per-entry uncompressed caps — mirror the frontend/CI limits
|
||||||
|
/// (`validateThemeCss` caps CSS at 64 KB; the manifest is tiny).
|
||||||
|
const MAX_MANIFEST_BYTES: usize = 64 * 1024;
|
||||||
|
const MAX_CSS_BYTES: usize = 256 * 1024;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ImportedThemeFiles {
|
||||||
|
pub manifest: String,
|
||||||
|
pub css: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn import_theme_zip(path: String) -> Result<ImportedThemeFiles, String> {
|
||||||
|
let file = std::fs::File::open(&path).map_err(|e| format!("cannot open file: {e}"))?;
|
||||||
|
let len = file
|
||||||
|
.metadata()
|
||||||
|
.map_err(|e| format!("cannot read file info: {e}"))?
|
||||||
|
.len();
|
||||||
|
if len > MAX_ARCHIVE_BYTES {
|
||||||
|
return Err(format!(
|
||||||
|
"archive is too large (> {} KB)",
|
||||||
|
MAX_ARCHIVE_BYTES / 1024
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut archive =
|
||||||
|
zip::ZipArchive::new(file).map_err(|_| "not a valid .zip archive".to_string())?;
|
||||||
|
|
||||||
|
let manifest = read_capped_entry(&mut archive, "manifest.json", MAX_MANIFEST_BYTES)?
|
||||||
|
.ok_or_else(|| "manifest.json was not found in the archive".to_string())?;
|
||||||
|
let css = read_capped_entry(&mut archive, "theme.css", MAX_CSS_BYTES)?
|
||||||
|
.ok_or_else(|| "theme.css was not found in the archive".to_string())?;
|
||||||
|
|
||||||
|
Ok(ImportedThemeFiles { manifest, css })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the first non-directory entry whose file name equals `wanted` (at the
|
||||||
|
/// archive root or under a single wrapping folder), reject path traversal, and
|
||||||
|
/// read it as UTF-8 text under `cap` bytes. Both the declared size and the
|
||||||
|
/// actual read are bounded, so a lying header cannot allocate past the cap.
|
||||||
|
fn read_capped_entry<R: Read + std::io::Seek>(
|
||||||
|
archive: &mut zip::ZipArchive<R>,
|
||||||
|
wanted: &str,
|
||||||
|
cap: usize,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
for i in 0..archive.len() {
|
||||||
|
let mut entry = archive
|
||||||
|
.by_index(i)
|
||||||
|
.map_err(|e| format!("corrupt archive entry: {e}"))?;
|
||||||
|
if entry.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// `enclosed_name()` is `None` for absolute paths or `..` traversal.
|
||||||
|
let base = match entry.enclosed_name() {
|
||||||
|
Some(p) => match p.file_name().and_then(|s| s.to_str()) {
|
||||||
|
Some(s) => s.to_string(),
|
||||||
|
None => continue,
|
||||||
|
},
|
||||||
|
None => return Err("archive contains an unsafe path".to_string()),
|
||||||
|
};
|
||||||
|
if base != wanted {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if entry.size() > cap as u64 {
|
||||||
|
return Err(format!("{wanted} is too large (> {} KB)", cap / 1024));
|
||||||
|
}
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
entry
|
||||||
|
.by_ref()
|
||||||
|
.take(cap as u64 + 1)
|
||||||
|
.read_to_end(&mut buf)
|
||||||
|
.map_err(|e| format!("cannot read {wanted}: {e}"))?;
|
||||||
|
if buf.len() > cap {
|
||||||
|
return Err(format!("{wanted} is too large (> {} KB)", cap / 1024));
|
||||||
|
}
|
||||||
|
return String::from_utf8(buf)
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|_| format!("{wanted} is not valid UTF-8"));
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Upload, X } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
|
import { useInstalledThemesStore } from '../../store/installedThemesStore';
|
||||||
|
import { validateThemePackage, type ValidatedTheme } from '../../utils/themes/validateThemePackage';
|
||||||
|
import { showToast } from '../../utils/ui/toast';
|
||||||
|
import ConfirmModal from '../ConfirmModal';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a community theme from a local `.zip` (manifest.json + theme.css).
|
||||||
|
* Rust extracts the two entries (size-capped, outside the webview); the full
|
||||||
|
* store contract validation then runs before the theme is persisted. Anything
|
||||||
|
* off-contract is rejected with the exact reasons listed.
|
||||||
|
*/
|
||||||
|
export function ThemeImportSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const install = useInstalledThemesStore(s => s.install);
|
||||||
|
const [importErrors, setImportErrors] = useState<string[] | null>(null);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
// A validated-but-not-yet-installed theme, awaiting the user's confirmation.
|
||||||
|
const [pending, setPending] = useState<ValidatedTheme | null>(null);
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
setImportErrors(null);
|
||||||
|
let selected: string | null = null;
|
||||||
|
try {
|
||||||
|
const picked = await open({
|
||||||
|
multiple: false,
|
||||||
|
directory: false,
|
||||||
|
filters: [{ name: 'Theme', extensions: ['zip'] }],
|
||||||
|
});
|
||||||
|
if (typeof picked === 'string') selected = picked;
|
||||||
|
} catch {
|
||||||
|
return; // dialog dismissed / unavailable
|
||||||
|
}
|
||||||
|
if (!selected) return;
|
||||||
|
|
||||||
|
setImporting(true);
|
||||||
|
try {
|
||||||
|
const files = await invoke<{ manifest: string; css: string }>('import_theme_zip', { path: selected });
|
||||||
|
const result = validateThemePackage(files.manifest, files.css);
|
||||||
|
if (!result.ok) {
|
||||||
|
setImportErrors(result.errors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Validated — confirm with the user (name + author) before persisting.
|
||||||
|
setPending(result.theme);
|
||||||
|
} catch (e) {
|
||||||
|
setImportErrors([String(e)]);
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmInstall = () => {
|
||||||
|
if (!pending) return;
|
||||||
|
install({ ...pending, installedAt: Date.now() });
|
||||||
|
showToast(t('settings.themeImportSuccess', { name: pending.name }), 4000, 'success');
|
||||||
|
setPending(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-card">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={importing}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
padding: '14px 16px',
|
||||||
|
border: '1px dashed var(--border)',
|
||||||
|
borderRadius: 'var(--radius-md, 10px)',
|
||||||
|
background: 'var(--bg-elevated)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
cursor: importing ? 'default' : 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Upload size={20} style={{ color: 'var(--accent)', flexShrink: 0 }} />
|
||||||
|
<span style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 13.5 }}>
|
||||||
|
{importing ? t('settings.themeImporting') : t('settings.themeImportButton')}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.themeImportHint')}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{importErrors && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
marginTop: '1rem',
|
||||||
|
padding: '10px 12px',
|
||||||
|
border: '1px solid var(--danger)',
|
||||||
|
borderRadius: 'var(--radius-md, 10px)',
|
||||||
|
background: 'var(--bg-elevated)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
|
||||||
|
<strong style={{ fontSize: 13, color: 'var(--danger)' }}>{t('settings.themeImportErrorTitle')}</strong>
|
||||||
|
<button
|
||||||
|
onClick={() => setImportErrors(null)}
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
style={{ background: 'none', border: 'none', color: 'var(--text-secondary)', cursor: 'pointer', padding: 0, lineHeight: 0 }}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: '6px 0 0', fontSize: 12.5, lineHeight: 1.5, color: 'var(--text-secondary)' }}>
|
||||||
|
{t('settings.themeImportErrorBody')}
|
||||||
|
</p>
|
||||||
|
{/* The raw contract diagnostics — useful to theme authors / bug reports,
|
||||||
|
tucked away so end users aren't confronted with token names. */}
|
||||||
|
<details style={{ marginTop: 8 }}>
|
||||||
|
<summary style={{ fontSize: 12, color: 'var(--text-muted)', cursor: 'pointer' }}>
|
||||||
|
{t('settings.themeImportErrorDetails')}
|
||||||
|
</summary>
|
||||||
|
<ul style={{ margin: '6px 0 0', paddingLeft: 18, color: 'var(--text-muted)' }}>
|
||||||
|
{importErrors.map((e, i) => (
|
||||||
|
<li key={i} style={{ fontSize: 11.5, lineHeight: 1.5 }}>{e}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
open={pending !== null}
|
||||||
|
title={t('settings.themeImportConfirmTitle')}
|
||||||
|
message={pending ? t('settings.themeImportConfirmBody', { name: pending.name, author: pending.author }) : ''}
|
||||||
|
confirmLabel={t('settings.themeStoreInstall')}
|
||||||
|
cancelLabel={t('common.cancel')}
|
||||||
|
onConfirm={confirmInstall}
|
||||||
|
onCancel={() => setPending(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Clock, Palette, Store } from 'lucide-react';
|
import { Clock, Palette, Store, Upload } from 'lucide-react';
|
||||||
import { useThemeStore } from '../../store/themeStore';
|
import { useThemeStore } from '../../store/themeStore';
|
||||||
import { useInstalledThemesStore } from '../../store/installedThemesStore';
|
import { useInstalledThemesStore } from '../../store/installedThemesStore';
|
||||||
import CustomSelect from '../CustomSelect';
|
import CustomSelect from '../CustomSelect';
|
||||||
import BackToTopButton from '../BackToTopButton';
|
import BackToTopButton from '../BackToTopButton';
|
||||||
import { FIXED_THEMES } from './fixedThemes';
|
import { FIXED_THEMES } from './fixedThemes';
|
||||||
import { InstalledThemes } from './InstalledThemes';
|
import { InstalledThemes } from './InstalledThemes';
|
||||||
|
import { ThemeImportSection } from './ThemeImportSection';
|
||||||
import { ThemeStoreSection } from './ThemeStoreSection';
|
import { ThemeStoreSection } from './ThemeStoreSection';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,6 +114,10 @@ export function ThemesTab() {
|
|||||||
</div>
|
</div>
|
||||||
</ThemesSection>
|
</ThemesSection>
|
||||||
|
|
||||||
|
<ThemesSection icon={<Upload size={16} />} title={t('settings.themeImportTitle')}>
|
||||||
|
<ThemeImportSection />
|
||||||
|
</ThemesSection>
|
||||||
|
|
||||||
<ThemesSection icon={<Store size={16} />} title={t('settings.themeStoreTitle')}>
|
<ThemesSection icon={<Store size={16} />} title={t('settings.themeStoreTitle')}>
|
||||||
<ThemeStoreSection />
|
<ThemeStoreSection />
|
||||||
</ThemesSection>
|
</ThemesSection>
|
||||||
|
|||||||
@@ -356,7 +356,7 @@ const CONTRIBUTOR_ENTRIES = [
|
|||||||
'Performance Probe: Monitor/Toggles redesign, live CPU/RSS/thread metrics, overlay pins and sparklines, macOS snapshots (PR #890)',
|
'Performance Probe: Monitor/Toggles redesign, live CPU/RSS/thread metrics, overlay pins and sparklines, macOS snapshots (PR #890)',
|
||||||
'Performance Probe: opt-in thread-group CPU poll toggle and includeThreadGroups IPC fix (PR #891)',
|
'Performance Probe: opt-in thread-group CPU poll toggle and includeThreadGroups IPC fix (PR #891)',
|
||||||
'Queue: switchable display mode — Queue (upcoming only) vs Playlist (full list), with header toggle and settings entry (PR #922)',
|
'Queue: switchable display mode — Queue (upcoming only) vs Playlist (full list), with header toggle and settings entry (PR #922)',
|
||||||
'Community Theme Store: semantic-token refactor, dedicated Themes tab with day/night scheduler, install/update/uninstall, and 80+ palettes moved to an on-demand CDN repo (PR #1009)',
|
'Community Theme Store: semantic-token refactor, dedicated Themes tab with day/night scheduler, install/update/uninstall, local .zip import with full contract validation, and 80+ palettes moved to an on-demand CDN repo (PR #1009, #1011, #1012)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -354,6 +354,16 @@ export const settings = {
|
|||||||
themeStorePagePrev: 'Vorherige Seite',
|
themeStorePagePrev: 'Vorherige Seite',
|
||||||
themeStorePageNext: 'Nächste Seite',
|
themeStorePageNext: 'Nächste Seite',
|
||||||
themeStorePageStatus: 'Seite {{page}} von {{total}}',
|
themeStorePageStatus: 'Seite {{page}} von {{total}}',
|
||||||
|
themeImportTitle: 'Theme importieren',
|
||||||
|
themeImportButton: 'Theme importieren…',
|
||||||
|
themeImporting: 'Importiere…',
|
||||||
|
themeImportHint: 'Lädt ein als .zip verpacktes Theme (manifest.json + theme.css).',
|
||||||
|
themeImportSuccess: '„{{name}}“ importiert.',
|
||||||
|
themeImportErrorTitle: 'Dieses Theme konnte nicht importiert werden',
|
||||||
|
themeImportErrorBody: 'Es entspricht nicht dem Psysonic-Theme-Format. Vielleicht wurde es für eine andere Version erstellt oder die Datei ist unvollständig — prüfe, ob du die richtige .zip gewählt hast, oder frag den Autor des Themes.',
|
||||||
|
themeImportErrorDetails: 'Technische Details',
|
||||||
|
themeImportConfirmTitle: 'Theme installieren?',
|
||||||
|
themeImportConfirmBody: '„{{name}}“ von {{author}} installieren?',
|
||||||
tabLibrary: 'Bibliothek',
|
tabLibrary: 'Bibliothek',
|
||||||
tabServers: 'Server',
|
tabServers: 'Server',
|
||||||
tabLyrics: 'Songtexte',
|
tabLyrics: 'Songtexte',
|
||||||
|
|||||||
@@ -421,6 +421,16 @@ export const settings = {
|
|||||||
themeStorePagePrev: 'Previous page',
|
themeStorePagePrev: 'Previous page',
|
||||||
themeStorePageNext: 'Next page',
|
themeStorePageNext: 'Next page',
|
||||||
themeStorePageStatus: 'Page {{page}} of {{total}}',
|
themeStorePageStatus: 'Page {{page}} of {{total}}',
|
||||||
|
themeImportTitle: 'Import a theme',
|
||||||
|
themeImportButton: 'Import theme…',
|
||||||
|
themeImporting: 'Importing…',
|
||||||
|
themeImportHint: 'Load a theme packaged as a .zip (manifest.json + theme.css).',
|
||||||
|
themeImportSuccess: '"{{name}}" imported.',
|
||||||
|
themeImportErrorTitle: 'This theme could not be imported',
|
||||||
|
themeImportErrorBody: "It doesn't match the Psysonic theme format. It may have been made for a different version, or the file is incomplete — check that you picked the right .zip, or ask the theme's author.",
|
||||||
|
themeImportErrorDetails: 'Technical details',
|
||||||
|
themeImportConfirmTitle: 'Install theme?',
|
||||||
|
themeImportConfirmBody: 'Install "{{name}}" by {{author}}?',
|
||||||
tabLibrary: 'Library',
|
tabLibrary: 'Library',
|
||||||
tabServers: 'Servers',
|
tabServers: 'Servers',
|
||||||
tabLyrics: 'Lyrics',
|
tabLyrics: 'Lyrics',
|
||||||
|
|||||||
@@ -352,6 +352,16 @@ export const settings = {
|
|||||||
themeStorePagePrev: 'Página anterior',
|
themeStorePagePrev: 'Página anterior',
|
||||||
themeStorePageNext: 'Página siguiente',
|
themeStorePageNext: 'Página siguiente',
|
||||||
themeStorePageStatus: 'Página {{page}} de {{total}}',
|
themeStorePageStatus: 'Página {{page}} de {{total}}',
|
||||||
|
themeImportTitle: 'Importar un tema',
|
||||||
|
themeImportButton: 'Importar tema…',
|
||||||
|
themeImporting: 'Importando…',
|
||||||
|
themeImportHint: 'Carga un tema empaquetado como .zip (manifest.json + theme.css).',
|
||||||
|
themeImportSuccess: '«{{name}}» importado.',
|
||||||
|
themeImportErrorTitle: 'No se pudo importar este tema',
|
||||||
|
themeImportErrorBody: 'No coincide con el formato de temas de Psysonic. Puede que se haya creado para otra versión o que el archivo esté incompleto: comprueba que elegiste el .zip correcto o pregunta al autor del tema.',
|
||||||
|
themeImportErrorDetails: 'Detalles técnicos',
|
||||||
|
themeImportConfirmTitle: '¿Instalar tema?',
|
||||||
|
themeImportConfirmBody: '¿Instalar «{{name}}» de {{author}}?',
|
||||||
tabLibrary: 'Biblioteca',
|
tabLibrary: 'Biblioteca',
|
||||||
tabServers: 'Servidores',
|
tabServers: 'Servidores',
|
||||||
tabLyrics: 'Letras',
|
tabLyrics: 'Letras',
|
||||||
|
|||||||
@@ -350,6 +350,16 @@ export const settings = {
|
|||||||
themeStorePagePrev: 'Page précédente',
|
themeStorePagePrev: 'Page précédente',
|
||||||
themeStorePageNext: 'Page suivante',
|
themeStorePageNext: 'Page suivante',
|
||||||
themeStorePageStatus: 'Page {{page}} sur {{total}}',
|
themeStorePageStatus: 'Page {{page}} sur {{total}}',
|
||||||
|
themeImportTitle: 'Importer un thème',
|
||||||
|
themeImportButton: 'Importer un thème…',
|
||||||
|
themeImporting: 'Importation…',
|
||||||
|
themeImportHint: 'Charge un thème empaqueté en .zip (manifest.json + theme.css).',
|
||||||
|
themeImportSuccess: '« {{name}} » importé.',
|
||||||
|
themeImportErrorTitle: "Ce thème n'a pas pu être importé",
|
||||||
|
themeImportErrorBody: "Il ne correspond pas au format de thème Psysonic. Il a peut-être été conçu pour une autre version, ou le fichier est incomplet — vérifiez que vous avez choisi le bon .zip, ou contactez l'auteur du thème.",
|
||||||
|
themeImportErrorDetails: 'Détails techniques',
|
||||||
|
themeImportConfirmTitle: 'Installer le thème ?',
|
||||||
|
themeImportConfirmBody: 'Installer « {{name}} » de {{author}} ?',
|
||||||
tabLibrary: 'Bibliothèque',
|
tabLibrary: 'Bibliothèque',
|
||||||
tabServers: 'Serveurs',
|
tabServers: 'Serveurs',
|
||||||
tabLyrics: 'Paroles',
|
tabLyrics: 'Paroles',
|
||||||
|
|||||||
@@ -353,6 +353,16 @@ export const settings = {
|
|||||||
themeStorePagePrev: 'Forrige side',
|
themeStorePagePrev: 'Forrige side',
|
||||||
themeStorePageNext: 'Neste side',
|
themeStorePageNext: 'Neste side',
|
||||||
themeStorePageStatus: 'Side {{page}} av {{total}}',
|
themeStorePageStatus: 'Side {{page}} av {{total}}',
|
||||||
|
themeImportTitle: 'Importer et tema',
|
||||||
|
themeImportButton: 'Importer tema…',
|
||||||
|
themeImporting: 'Importerer…',
|
||||||
|
themeImportHint: 'Laster inn et tema pakket som .zip (manifest.json + theme.css).',
|
||||||
|
themeImportSuccess: '«{{name}}» importert.',
|
||||||
|
themeImportErrorTitle: 'Dette temaet kunne ikke importeres',
|
||||||
|
themeImportErrorBody: 'Det samsvarer ikke med Psysonic-temaformatet. Det kan være laget for en annen versjon, eller filen er ufullstendig — sjekk at du valgte riktig .zip, eller spør temaets forfatter.',
|
||||||
|
themeImportErrorDetails: 'Tekniske detaljer',
|
||||||
|
themeImportConfirmTitle: 'Installere tema?',
|
||||||
|
themeImportConfirmBody: 'Installere «{{name}}» av {{author}}?',
|
||||||
tabStorage: 'Frakoblet & Cache',
|
tabStorage: 'Frakoblet & Cache',
|
||||||
inputKeybindingsTitle: 'Tastatursnarveier',
|
inputKeybindingsTitle: 'Tastatursnarveier',
|
||||||
aboutContributorsCount_one: '{{count}} bidrag',
|
aboutContributorsCount_one: '{{count}} bidrag',
|
||||||
|
|||||||
@@ -350,6 +350,16 @@ export const settings = {
|
|||||||
themeStorePagePrev: 'Vorige pagina',
|
themeStorePagePrev: 'Vorige pagina',
|
||||||
themeStorePageNext: 'Volgende pagina',
|
themeStorePageNext: 'Volgende pagina',
|
||||||
themeStorePageStatus: 'Pagina {{page}} van {{total}}',
|
themeStorePageStatus: 'Pagina {{page}} van {{total}}',
|
||||||
|
themeImportTitle: 'Een thema importeren',
|
||||||
|
themeImportButton: 'Thema importeren…',
|
||||||
|
themeImporting: 'Importeren…',
|
||||||
|
themeImportHint: 'Laadt een thema verpakt als .zip (manifest.json + theme.css).',
|
||||||
|
themeImportSuccess: '"{{name}}" geïmporteerd.',
|
||||||
|
themeImportErrorTitle: 'Dit thema kon niet worden geïmporteerd',
|
||||||
|
themeImportErrorBody: 'Het komt niet overeen met het Psysonic-themaformaat. Mogelijk is het voor een andere versie gemaakt of is het bestand onvolledig — controleer of je de juiste .zip hebt gekozen, of vraag de maker van het thema.',
|
||||||
|
themeImportErrorDetails: 'Technische details',
|
||||||
|
themeImportConfirmTitle: 'Thema installeren?',
|
||||||
|
themeImportConfirmBody: '"{{name}}" van {{author}} installeren?',
|
||||||
tabLibrary: 'Bibliotheek',
|
tabLibrary: 'Bibliotheek',
|
||||||
tabServers: 'Servers',
|
tabServers: 'Servers',
|
||||||
tabLyrics: 'Songteksten',
|
tabLyrics: 'Songteksten',
|
||||||
|
|||||||
@@ -356,6 +356,16 @@ export const settings = {
|
|||||||
themeStorePagePrev: 'Pagina anterioară',
|
themeStorePagePrev: 'Pagina anterioară',
|
||||||
themeStorePageNext: 'Pagina următoare',
|
themeStorePageNext: 'Pagina următoare',
|
||||||
themeStorePageStatus: 'Pagina {{page}} din {{total}}',
|
themeStorePageStatus: 'Pagina {{page}} din {{total}}',
|
||||||
|
themeImportTitle: 'Importă o temă',
|
||||||
|
themeImportButton: 'Importă tema…',
|
||||||
|
themeImporting: 'Se importă…',
|
||||||
|
themeImportHint: 'Încarcă o temă împachetată ca .zip (manifest.json + theme.css).',
|
||||||
|
themeImportSuccess: '„{{name}}” importată.',
|
||||||
|
themeImportErrorTitle: 'Această temă nu a putut fi importată',
|
||||||
|
themeImportErrorBody: 'Nu corespunde formatului de teme Psysonic. Poate a fost creată pentru altă versiune sau fișierul este incomplet — verifică dacă ai ales arhiva .zip corectă sau întreabă autorul temei.',
|
||||||
|
themeImportErrorDetails: 'Detalii tehnice',
|
||||||
|
themeImportConfirmTitle: 'Instalezi tema?',
|
||||||
|
themeImportConfirmBody: 'Instalezi „{{name}}” de {{author}}?',
|
||||||
tabLibrary: 'Librărie',
|
tabLibrary: 'Librărie',
|
||||||
tabServers: 'Servere',
|
tabServers: 'Servere',
|
||||||
tabLyrics: 'Versuri',
|
tabLyrics: 'Versuri',
|
||||||
|
|||||||
@@ -432,6 +432,16 @@ export const settings = {
|
|||||||
themeStorePagePrev: 'Предыдущая страница',
|
themeStorePagePrev: 'Предыдущая страница',
|
||||||
themeStorePageNext: 'Следующая страница',
|
themeStorePageNext: 'Следующая страница',
|
||||||
themeStorePageStatus: 'Страница {{page}} из {{total}}',
|
themeStorePageStatus: 'Страница {{page}} из {{total}}',
|
||||||
|
themeImportTitle: 'Импорт темы',
|
||||||
|
themeImportButton: 'Импортировать тему…',
|
||||||
|
themeImporting: 'Импорт…',
|
||||||
|
themeImportHint: 'Загружает тему, упакованную в .zip (manifest.json + theme.css).',
|
||||||
|
themeImportSuccess: '«{{name}}» импортирована.',
|
||||||
|
themeImportErrorTitle: 'Не удалось импортировать эту тему',
|
||||||
|
themeImportErrorBody: 'Он не соответствует формату тем Psysonic. Возможно, он создан для другой версии или файл неполный — проверьте, что выбрали правильный .zip, или обратитесь к автору темы.',
|
||||||
|
themeImportErrorDetails: 'Технические подробности',
|
||||||
|
themeImportConfirmTitle: 'Установить тему?',
|
||||||
|
themeImportConfirmBody: 'Установить «{{name}}» от {{author}}?',
|
||||||
tabLibrary: 'Библиотека',
|
tabLibrary: 'Библиотека',
|
||||||
tabServers: 'Серверы',
|
tabServers: 'Серверы',
|
||||||
tabLyrics: 'Тексты песен',
|
tabLyrics: 'Тексты песен',
|
||||||
|
|||||||
@@ -349,6 +349,16 @@ export const settings = {
|
|||||||
themeStorePagePrev: '上一页',
|
themeStorePagePrev: '上一页',
|
||||||
themeStorePageNext: '下一页',
|
themeStorePageNext: '下一页',
|
||||||
themeStorePageStatus: '第 {{page}} 页,共 {{total}} 页',
|
themeStorePageStatus: '第 {{page}} 页,共 {{total}} 页',
|
||||||
|
themeImportTitle: '导入主题',
|
||||||
|
themeImportButton: '导入主题…',
|
||||||
|
themeImporting: '正在导入…',
|
||||||
|
themeImportHint: '加载打包为 .zip 的主题(manifest.json + theme.css)。',
|
||||||
|
themeImportSuccess: '已导入“{{name}}”。',
|
||||||
|
themeImportErrorTitle: '无法导入此主题',
|
||||||
|
themeImportErrorBody: '它不符合 Psysonic 主题格式。可能是为其他版本制作的,或文件不完整——请确认选择了正确的 .zip,或联系主题作者。',
|
||||||
|
themeImportErrorDetails: '技术细节',
|
||||||
|
themeImportConfirmTitle: '安装主题?',
|
||||||
|
themeImportConfirmBody: '安装来自 {{author}} 的“{{name}}”?',
|
||||||
tabLibrary: '媒体库',
|
tabLibrary: '媒体库',
|
||||||
tabServers: '服务器',
|
tabServers: '服务器',
|
||||||
tabLyrics: '歌词',
|
tabLyrics: '歌词',
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"frozenAt": "2026-06-05",
|
||||||
|
"description": "The community Theme Store contract surface. A community theme.css may set ONLY these custom properties (plus color-scheme) on its single [data-theme='<id>'] selector. Frozen from the real token surface of the built-in themes (B0 semantic-token refactor). --ctp-* (Catppuccin palette) and the global design-system tokens (spacing/radii/elevation-shadows/transitions/fonts) are intentionally NOT part of the contract.",
|
||||||
|
"colorScheme": {
|
||||||
|
"required": true,
|
||||||
|
"values": ["dark", "light"],
|
||||||
|
"note": "Declared on the same selector (not a custom property). Must match manifest.mode. Drives OS-level form/scrollbar theming and the store dark/light filter."
|
||||||
|
},
|
||||||
|
"dataUriTokens": ["--select-arrow"],
|
||||||
|
"core": {
|
||||||
|
"--accent": "Primary accent / brand colour.",
|
||||||
|
"--accent-dim": "Low-alpha accent for fills/backgrounds (e.g. selected row).",
|
||||||
|
"--accent-glow": "Accent glow / focus halo colour.",
|
||||||
|
"--accent-2": "Secondary accent — second stop of two-colour gradient flourishes.",
|
||||||
|
"--bg-app": "Main app background.",
|
||||||
|
"--bg-sidebar": "Sidebar / navigation background.",
|
||||||
|
"--bg-card": "Card / panel surface.",
|
||||||
|
"--bg-hover": "Hover / elevated surface.",
|
||||||
|
"--bg-elevated": "Highest elevated surface.",
|
||||||
|
"--bg-player": "Player bar background.",
|
||||||
|
"--bg-deep": "Deepest background / gradient stop.",
|
||||||
|
"--bg-glass": "Translucent glass background (use rgba — sits over blurred surfaces).",
|
||||||
|
"--border": "Default border colour.",
|
||||||
|
"--border-subtle": "Subtle / hairline border.",
|
||||||
|
"--border-dropdown": "Border for dropdowns / popovers.",
|
||||||
|
"--text-primary": "Primary text.",
|
||||||
|
"--text-secondary": "Secondary text.",
|
||||||
|
"--text-muted": "Muted / tertiary text.",
|
||||||
|
"--text-on-accent": "Text/icon colour drawn on top of --accent.",
|
||||||
|
"--danger": "Error / destructive.",
|
||||||
|
"--positive": "Success / positive.",
|
||||||
|
"--warning": "Warning.",
|
||||||
|
"--highlight": "Highlight / rating colour.",
|
||||||
|
"--select-arrow": "Dropdown chevron, an inline SVG data-URI. The one token whose value is url(data:...). Recolour by changing the stroke=%23RRGGBB hex inside the URI.",
|
||||||
|
"--shadow-dropdown": "Drop-shadow colour for dropdowns/popovers (e.g. rgba(0,0,0,0.6))."
|
||||||
|
},
|
||||||
|
"optional": {
|
||||||
|
"--volume-accent": "Volume slider fill (falls back to --accent).",
|
||||||
|
"--waveform-played": "Waveform — played portion (falls back to --accent).",
|
||||||
|
"--waveform-unplayed": "Waveform — unplayed portion.",
|
||||||
|
"--waveform-buffered": "Waveform — buffered portion.",
|
||||||
|
"--player-title": "Player-bar track title colour (falls back to --text-primary).",
|
||||||
|
"--player-artist": "Player-bar artist colour (falls back to --text-secondary)."
|
||||||
|
},
|
||||||
|
"granular": {
|
||||||
|
"$comment": "Optional per-region override tokens (added 2026-06-06). Each defaults to a base token in the app, so omitting them all is fine — a theme that sets only the core tokens still looks complete. Set one to recolour that region independently, e.g. give the sidebar its own hover without touching every other hover. Colour values only, same as everything else.",
|
||||||
|
"--sidebar-item-hover": "Sidebar item hover background.",
|
||||||
|
"--sidebar-item-active-bg": "Sidebar active item background.",
|
||||||
|
"--sidebar-item-active-text": "Sidebar active item text/icon.",
|
||||||
|
"--sidebar-text": "Sidebar item text (resting).",
|
||||||
|
"--sidebar-trigger-bg": "Sidebar library-scope trigger background.",
|
||||||
|
"--sidebar-border": "Sidebar borders/dividers.",
|
||||||
|
"--player-control": "Player-bar transport icon (resting).",
|
||||||
|
"--player-control-hover": "Player-bar transport icon (hover).",
|
||||||
|
"--player-control-hover-bg": "Player-bar transport button hover background.",
|
||||||
|
"--player-time-toggle-hover": "Player-bar time-toggle hover background.",
|
||||||
|
"--player-time-toggle-active": "Player-bar time-toggle active background.",
|
||||||
|
"--player-border": "Player-bar top border.",
|
||||||
|
"--row-hover": "List/track row hover background.",
|
||||||
|
"--row-playing-bg": "Now-playing row background.",
|
||||||
|
"--row-playing-text": "Now-playing row title + indicator colour.",
|
||||||
|
"--row-divider": "Row divider line.",
|
||||||
|
"--row-text": "Row primary text (track title).",
|
||||||
|
"--row-text-secondary": "Row secondary text (artist).",
|
||||||
|
"--row-text-muted": "Row muted text (number, duration).",
|
||||||
|
"--col-header-text": "List column-header text.",
|
||||||
|
"--col-resize-active": "Column resize handle when active.",
|
||||||
|
"--card-hover-border": "Album/artist card hover border.",
|
||||||
|
"--card-title": "Card title text.",
|
||||||
|
"--card-subtitle": "Card subtitle/meta text.",
|
||||||
|
"--card-placeholder-bg": "Card cover placeholder background.",
|
||||||
|
"--menu-bg": "Dropdown / context-menu / modal background.",
|
||||||
|
"--menu-item-hover": "Menu item hover background.",
|
||||||
|
"--menu-item-active-text": "Menu item active/selected text.",
|
||||||
|
"--menu-divider": "Menu divider line.",
|
||||||
|
"--modal-bg": "Modal panel background.",
|
||||||
|
"--modal-scrim": "Modal backdrop scrim.",
|
||||||
|
"--input-bg": "Input / select background.",
|
||||||
|
"--input-border": "Input / select border.",
|
||||||
|
"--input-focus-border": "Input border on focus.",
|
||||||
|
"--button-hover": "Secondary / ghost button hover background.",
|
||||||
|
"--slider-track": "Range slider track.",
|
||||||
|
"--slider-thumb": "Range slider thumb.",
|
||||||
|
"--slider-thumb-hover": "Range slider thumb on hover.",
|
||||||
|
"--scrollbar-thumb": "Scrollbar thumb.",
|
||||||
|
"--scrollbar-thumb-hover": "Scrollbar thumb on hover.",
|
||||||
|
"--scrollbar-track": "Scrollbar track."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { validateThemePackage } from './validateThemePackage';
|
||||||
|
import tokens from './contract/allowed-tokens.json';
|
||||||
|
|
||||||
|
const CORE = Object.keys(tokens.core).filter((k) => !k.startsWith('$'));
|
||||||
|
|
||||||
|
interface CssOpts {
|
||||||
|
id?: string;
|
||||||
|
mode?: 'dark' | 'light';
|
||||||
|
omit?: string;
|
||||||
|
overrides?: Record<string, string>;
|
||||||
|
extraDecl?: string;
|
||||||
|
extraRule?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a complete, contract-valid theme.css (all core tokens), with knobs to
|
||||||
|
* break exactly one thing per test. */
|
||||||
|
function buildCss(o: CssOpts = {}): string {
|
||||||
|
const { id = 'my-theme', mode = 'dark', omit, overrides = {}, extraDecl, extraRule } = o;
|
||||||
|
const decls = CORE.filter((tok) => tok !== omit).map((tok) => {
|
||||||
|
if (overrides[tok] !== undefined) return `${tok}: ${overrides[tok]}`;
|
||||||
|
if (tok === '--select-arrow') {
|
||||||
|
return `${tok}: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg'/>")`;
|
||||||
|
}
|
||||||
|
return `${tok}: #abcdef`;
|
||||||
|
});
|
||||||
|
if (extraDecl) decls.push(extraDecl);
|
||||||
|
const rule = `[data-theme='${id}'] {\n color-scheme: ${mode};\n ${decls.join(';\n ')};\n}`;
|
||||||
|
return extraRule ? `${rule}\n${extraRule}` : rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
function manifest(over: Record<string, unknown> = {}): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
id: 'my-theme',
|
||||||
|
name: 'My Theme',
|
||||||
|
author: 'tester',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'A nice theme',
|
||||||
|
mode: 'dark',
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasError = (r: ReturnType<typeof validateThemePackage>, re: RegExp): boolean =>
|
||||||
|
!r.ok && r.errors.some((e) => re.test(e));
|
||||||
|
|
||||||
|
describe('validateThemePackage', () => {
|
||||||
|
it('accepts a fully contract-valid package', () => {
|
||||||
|
const r = validateThemePackage(manifest(), buildCss());
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) {
|
||||||
|
expect(r.theme.id).toBe('my-theme');
|
||||||
|
expect(r.theme.mode).toBe('dark');
|
||||||
|
expect(r.theme).not.toHaveProperty('tags');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves valid tags', () => {
|
||||||
|
const r = validateThemePackage(manifest({ tags: ['dark', 'neon'] }), buildCss());
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) expect(r.theme.tags).toEqual(['dark', 'neon']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid JSON', () => {
|
||||||
|
const r = validateThemePackage('{ not json', buildCss());
|
||||||
|
expect(hasError(r, /not valid JSON/)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-object manifest', () => {
|
||||||
|
const r = validateThemePackage('"a string"', buildCss());
|
||||||
|
expect(hasError(r, /must be a JSON object/)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown manifest properties', () => {
|
||||||
|
const r = validateThemePackage(manifest({ evil: true }), buildCss());
|
||||||
|
expect(hasError(r, /unknown property "evil"/)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a missing required field', () => {
|
||||||
|
const r = validateThemePackage(manifest({ name: undefined }), buildCss());
|
||||||
|
expect(hasError(r, /manifest\.name is required/)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an id that is not lowercase kebab-case', () => {
|
||||||
|
const r = validateThemePackage(manifest({ id: 'My_Theme' }), buildCss({ id: 'My_Theme' }));
|
||||||
|
expect(hasError(r, /kebab-case/)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an id that collides with a built-in theme', () => {
|
||||||
|
const r = validateThemePackage(manifest({ id: 'mocha' }), buildCss({ id: 'mocha' }));
|
||||||
|
expect(hasError(r, /collides with a built-in/)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a missing required core token', () => {
|
||||||
|
const r = validateThemePackage(manifest(), buildCss({ omit: '--accent' }));
|
||||||
|
expect(hasError(r, /missing required core token --accent/)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a token that is not in the whitelist', () => {
|
||||||
|
const r = validateThemePackage(manifest(), buildCss({ extraDecl: '--not-a-real-token: #fff' }));
|
||||||
|
expect(hasError(r, /--not-a-real-token is not in the contract whitelist/)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects color-scheme not matching manifest.mode', () => {
|
||||||
|
const r = validateThemePackage(manifest({ mode: 'light' }), buildCss({ mode: 'dark' }));
|
||||||
|
expect(hasError(r, /must match/)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a data-URI on a token other than --select-arrow', () => {
|
||||||
|
const r = validateThemePackage(
|
||||||
|
manifest(),
|
||||||
|
buildCss({ overrides: { '--accent': 'url("data:image/png;base64,AAAA")' } }),
|
||||||
|
);
|
||||||
|
expect(hasError(r, /data-URI values are only allowed on --select-arrow/)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an external url() value (containment)', () => {
|
||||||
|
const r = validateThemePackage(
|
||||||
|
manifest(),
|
||||||
|
buildCss({ overrides: { '--bg-app': 'url(https://evil.example/x.png)' } }),
|
||||||
|
);
|
||||||
|
// validateThemeCss flags this structurally (only data: URIs are allowed).
|
||||||
|
expect(hasError(r, /one safe \[data-theme/)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects more than one rule (structural)', () => {
|
||||||
|
const r = validateThemePackage(manifest(), buildCss({ extraRule: "[data-theme='other'] { --accent: #000 }" }));
|
||||||
|
expect(hasError(r, /one safe \[data-theme/)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import allowedTokens from './contract/allowed-tokens.json';
|
||||||
|
import { validateThemeCss } from './themeInjection';
|
||||||
|
import { FIXED_THEMES } from '../../components/settings/fixedThemes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full theme-package validation for locally imported themes (a .zip holding
|
||||||
|
* manifest.json + theme.css). This is the in-app mirror of the repo CI
|
||||||
|
* contract check (`scripts/validate-theme.mjs`): it enforces the exact same
|
||||||
|
* manifest schema and CSS token whitelist, using a byte-identical copy of
|
||||||
|
* `schema/allowed-tokens.json` and the schema's own field patterns.
|
||||||
|
*
|
||||||
|
* Layering: `validateThemeCss` (themeInjection) is the security/containment
|
||||||
|
* guard — it guarantees the CSS is a single, scoped, at-rule-free rule with
|
||||||
|
* data-URI-only `url()`. Once that holds, the declaration list is a flat
|
||||||
|
* `prop: value;` sequence we can extract safely (no nesting) and check against
|
||||||
|
* the contract here. The same `validateThemeCss` runs again at injection time,
|
||||||
|
* so a theme that slips past this is still contained at the boundary.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const noMeta = (o: Record<string, unknown>): string[] =>
|
||||||
|
Object.keys(o || {}).filter((k) => !k.startsWith('$'));
|
||||||
|
|
||||||
|
const CORE = noMeta(allowedTokens.core as Record<string, unknown>);
|
||||||
|
const ALLOWED = new Set([
|
||||||
|
...CORE,
|
||||||
|
...noMeta(allowedTokens.optional as Record<string, unknown>),
|
||||||
|
...noMeta(allowedTokens.granular as Record<string, unknown>),
|
||||||
|
]);
|
||||||
|
const DATA_URI_TOKENS = new Set(allowedTokens.dataUriTokens as string[]);
|
||||||
|
const SCHEME_VALUES = new Set(allowedTokens.colorScheme.values as string[]);
|
||||||
|
const BUILTIN_IDS = new Set(FIXED_THEMES.map((f) => f.id));
|
||||||
|
|
||||||
|
// Field patterns copied verbatim from schema/manifest.schema.json so the
|
||||||
|
// in-app check stays identical to CI without bundling a JSON-schema engine.
|
||||||
|
const ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||||
|
const AUTHOR_RE = /^[A-Za-z0-9](-?[A-Za-z0-9]){0,38}$/;
|
||||||
|
const SEMVER_RE =
|
||||||
|
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
||||||
|
const APP_VER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
||||||
|
const TAG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||||
|
const MANIFEST_KEYS = new Set([
|
||||||
|
'id', 'name', 'author', 'version', 'description', 'mode', 'tags', 'minAppVersion',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export interface ValidatedTheme {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
author: string;
|
||||||
|
version: string;
|
||||||
|
description: string;
|
||||||
|
mode: 'dark' | 'light';
|
||||||
|
tags?: string[];
|
||||||
|
css: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ValidateResult =
|
||||||
|
| { ok: true; theme: ValidatedTheme }
|
||||||
|
| { ok: false; errors: string[] };
|
||||||
|
|
||||||
|
function escapeRegExp(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The declarations inside the single `[data-theme='<id>']` rule, or null. */
|
||||||
|
function ruleBody(css: string, id: string): string | null {
|
||||||
|
const stripped = css.replace(/\/\*[\s\S]*?\*\//g, '').trim();
|
||||||
|
const sel = `\\[data-theme=(['"])${escapeRegExp(id)}\\1\\]`;
|
||||||
|
const m = stripped.match(new RegExp(`^${sel}\\s*\\{([^{}]*)\\}$`));
|
||||||
|
return m ? m[2] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a flat declaration body on top-level `;` only — never inside `()` or a
|
||||||
|
* quoted string, so a `url("data:image/svg+xml;...")` value (the `;` in the
|
||||||
|
* MIME type / SVG) stays intact.
|
||||||
|
*/
|
||||||
|
function splitDeclarations(body: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
let cur = '';
|
||||||
|
let depth = 0;
|
||||||
|
let quote: string | null = null;
|
||||||
|
for (const ch of body) {
|
||||||
|
if (quote) {
|
||||||
|
cur += ch;
|
||||||
|
if (ch === quote) quote = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"' || ch === "'") { quote = ch; cur += ch; continue; }
|
||||||
|
if (ch === '(') { depth++; cur += ch; continue; }
|
||||||
|
if (ch === ')') { if (depth > 0) depth--; cur += ch; continue; }
|
||||||
|
if (ch === ';' && depth === 0) { out.push(cur); cur = ''; continue; }
|
||||||
|
cur += ch;
|
||||||
|
}
|
||||||
|
if (cur.trim()) out.push(cur);
|
||||||
|
return out.map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateThemePackage(manifestText: string, css: string): ValidateResult {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// ---- manifest ----
|
||||||
|
let m: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(manifestText);
|
||||||
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||||
|
return { ok: false, errors: ['manifest.json must be a JSON object'] };
|
||||||
|
}
|
||||||
|
m = parsed as Record<string, unknown>;
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, errors: [`manifest.json is not valid JSON: ${(e as Error).message}`] };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const k of Object.keys(m)) {
|
||||||
|
if (!MANIFEST_KEYS.has(k)) errors.push(`manifest has an unknown property "${k}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const str = (k: string): string | null => (typeof m[k] === 'string' ? (m[k] as string) : null);
|
||||||
|
|
||||||
|
const id = str('id');
|
||||||
|
if (id === null) errors.push('manifest.id is required and must be a string');
|
||||||
|
else {
|
||||||
|
if (!ID_RE.test(id) || id.length < 2 || id.length > 48) {
|
||||||
|
errors.push('manifest.id must be lowercase kebab-case, 2–48 chars');
|
||||||
|
}
|
||||||
|
if (BUILTIN_IDS.has(id)) errors.push(`manifest.id "${id}" collides with a built-in theme`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = str('name');
|
||||||
|
if (name === null) errors.push('manifest.name is required and must be a string');
|
||||||
|
else if (name.length < 1 || name.length > 50) errors.push('manifest.name must be 1–50 chars');
|
||||||
|
|
||||||
|
const author = str('author');
|
||||||
|
if (author === null) errors.push('manifest.author is required and must be a string');
|
||||||
|
else if (!AUTHOR_RE.test(author)) errors.push('manifest.author must be a GitHub handle (no leading @)');
|
||||||
|
|
||||||
|
const version = str('version');
|
||||||
|
if (version === null) errors.push('manifest.version is required and must be a string');
|
||||||
|
else if (!SEMVER_RE.test(version)) errors.push('manifest.version must be a SemVer string (e.g. 1.0.0)');
|
||||||
|
|
||||||
|
const description = str('description');
|
||||||
|
if (description === null) errors.push('manifest.description is required and must be a string');
|
||||||
|
else if (description.length < 1 || description.length > 200) errors.push('manifest.description must be 1–200 chars');
|
||||||
|
|
||||||
|
const mode = str('mode');
|
||||||
|
if (mode === null) errors.push('manifest.mode is required and must be a string');
|
||||||
|
else if (mode !== 'dark' && mode !== 'light') errors.push('manifest.mode must be "dark" or "light"');
|
||||||
|
|
||||||
|
if (m.tags !== undefined) {
|
||||||
|
const tags = m.tags;
|
||||||
|
if (!Array.isArray(tags)) errors.push('manifest.tags must be an array');
|
||||||
|
else {
|
||||||
|
if (tags.length > 8) errors.push('manifest.tags allows at most 8 items');
|
||||||
|
if (new Set(tags).size !== tags.length) errors.push('manifest.tags must be unique');
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (typeof tag !== 'string' || !TAG_RE.test(tag) || tag.length > 24) {
|
||||||
|
errors.push(`manifest.tags has an invalid tag: ${JSON.stringify(tag)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m.minAppVersion !== undefined) {
|
||||||
|
if (typeof m.minAppVersion !== 'string' || !APP_VER_RE.test(m.minAppVersion)) {
|
||||||
|
errors.push('manifest.minAppVersion must be a version like 1.2.3');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- css ----
|
||||||
|
// The selector check needs a valid id; if the id is bad, skip the CSS rule
|
||||||
|
// checks (they would all fail for the wrong reason) but keep the manifest
|
||||||
|
// errors above.
|
||||||
|
const idForCss = id && ID_RE.test(id) ? id : null;
|
||||||
|
if (idForCss === null) {
|
||||||
|
errors.push('theme.css cannot be validated until manifest.id is valid');
|
||||||
|
return { ok: false, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validateThemeCss(css, idForCss) == null) {
|
||||||
|
errors.push(
|
||||||
|
`theme.css must be exactly one safe [data-theme='${idForCss}'] rule (no @-rules, no foreign/global selectors, url() may only be a data: URI)`,
|
||||||
|
);
|
||||||
|
return { ok: false, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = ruleBody(css, idForCss);
|
||||||
|
if (body === null) {
|
||||||
|
// Should not happen once validateThemeCss passed, but stay defensive.
|
||||||
|
errors.push('theme.css declarations could not be read');
|
||||||
|
return { ok: false, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let scheme: string | null = null;
|
||||||
|
for (const decl of splitDeclarations(body)) {
|
||||||
|
const idx = decl.indexOf(':');
|
||||||
|
if (idx < 0) { errors.push(`malformed declaration: "${decl}"`); continue; }
|
||||||
|
const prop = decl.slice(0, idx).trim();
|
||||||
|
const value = decl.slice(idx + 1).trim();
|
||||||
|
|
||||||
|
if (prop === 'color-scheme') {
|
||||||
|
scheme = value;
|
||||||
|
if (!SCHEME_VALUES.has(value)) errors.push(`color-scheme must be one of ${[...SCHEME_VALUES].join(' | ')} (got: ${value})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!prop.startsWith('--')) { errors.push(`only custom properties and color-scheme are allowed (found: ${prop})`); continue; }
|
||||||
|
if (!ALLOWED.has(prop)) { errors.push(`token ${prop} is not in the contract whitelist`); continue; }
|
||||||
|
if (seen.has(prop)) errors.push(`token ${prop} is declared more than once`);
|
||||||
|
seen.add(prop);
|
||||||
|
|
||||||
|
const urls = value.toLowerCase().match(/url\(([^)]*)\)/g) || [];
|
||||||
|
for (const u of urls) {
|
||||||
|
if (!/url\(\s*["']?\s*data:/.test(u)) errors.push(`${prop}: only url(data:...) is allowed`);
|
||||||
|
else if (!DATA_URI_TOKENS.has(prop)) errors.push(`${prop}: data-URI values are only allowed on ${[...DATA_URI_TOKENS].join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheme === null) errors.push('color-scheme is required in theme.css');
|
||||||
|
if (mode && scheme && mode !== scheme) errors.push(`manifest.mode "${mode}" must match theme.css color-scheme "${scheme}"`);
|
||||||
|
for (const t of CORE) {
|
||||||
|
if (!seen.has(t)) errors.push(`missing required core token ${t}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length) return { ok: false, errors };
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
theme: {
|
||||||
|
id: id as string,
|
||||||
|
name: name as string,
|
||||||
|
author: author as string,
|
||||||
|
version: version as string,
|
||||||
|
description: description as string,
|
||||||
|
mode: mode as 'dark' | 'light',
|
||||||
|
...(Array.isArray(m.tags) ? { tags: m.tags as string[] } : {}),
|
||||||
|
css,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user