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:
Psychotoxical
2026-06-07 11:35:52 +02:00
committed by GitHub
parent 9fb0d5638b
commit b3573e7107
18 changed files with 802 additions and 3 deletions
@@ -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);
});
});
+238
View File
@@ -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, 248 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 150 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 1200 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,
},
};
}