mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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:
@@ -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 { 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 { useInstalledThemesStore } from '../../store/installedThemesStore';
|
||||
import CustomSelect from '../CustomSelect';
|
||||
import BackToTopButton from '../BackToTopButton';
|
||||
import { FIXED_THEMES } from './fixedThemes';
|
||||
import { InstalledThemes } from './InstalledThemes';
|
||||
import { ThemeImportSection } from './ThemeImportSection';
|
||||
import { ThemeStoreSection } from './ThemeStoreSection';
|
||||
|
||||
/**
|
||||
@@ -113,6 +114,10 @@ export function ThemesTab() {
|
||||
</div>
|
||||
</ThemesSection>
|
||||
|
||||
<ThemesSection icon={<Upload size={16} />} title={t('settings.themeImportTitle')}>
|
||||
<ThemeImportSection />
|
||||
</ThemesSection>
|
||||
|
||||
<ThemesSection icon={<Store size={16} />} title={t('settings.themeStoreTitle')}>
|
||||
<ThemeStoreSection />
|
||||
</ThemesSection>
|
||||
|
||||
Reference in New Issue
Block a user