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,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>
);
}