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
+10
View File
@@ -354,6 +354,16 @@ export const settings = {
themeStorePagePrev: 'Vorherige Seite',
themeStorePageNext: 'Nächste Seite',
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',
tabServers: 'Server',
tabLyrics: 'Songtexte',
+10
View File
@@ -421,6 +421,16 @@ export const settings = {
themeStorePagePrev: 'Previous page',
themeStorePageNext: 'Next page',
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',
tabServers: 'Servers',
tabLyrics: 'Lyrics',
+10
View File
@@ -352,6 +352,16 @@ export const settings = {
themeStorePagePrev: 'Página anterior',
themeStorePageNext: 'Página siguiente',
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',
tabServers: 'Servidores',
tabLyrics: 'Letras',
+10
View File
@@ -350,6 +350,16 @@ export const settings = {
themeStorePagePrev: 'Page précédente',
themeStorePageNext: 'Page suivante',
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',
tabServers: 'Serveurs',
tabLyrics: 'Paroles',
+10
View File
@@ -353,6 +353,16 @@ export const settings = {
themeStorePagePrev: 'Forrige side',
themeStorePageNext: 'Neste side',
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',
inputKeybindingsTitle: 'Tastatursnarveier',
aboutContributorsCount_one: '{{count}} bidrag',
+10
View File
@@ -350,6 +350,16 @@ export const settings = {
themeStorePagePrev: 'Vorige pagina',
themeStorePageNext: 'Volgende pagina',
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',
tabServers: 'Servers',
tabLyrics: 'Songteksten',
+10
View File
@@ -356,6 +356,16 @@ export const settings = {
themeStorePagePrev: 'Pagina anterioară',
themeStorePageNext: 'Pagina următoare',
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',
tabServers: 'Servere',
tabLyrics: 'Versuri',
+10
View File
@@ -432,6 +432,16 @@ export const settings = {
themeStorePagePrev: 'Предыдущая страница',
themeStorePageNext: 'Следующая страница',
themeStorePageStatus: 'Страница {{page}} из {{total}}',
themeImportTitle: 'Импорт темы',
themeImportButton: 'Импортировать тему…',
themeImporting: 'Импорт…',
themeImportHint: 'Загружает тему, упакованную в .zip (manifest.json + theme.css).',
themeImportSuccess: '«{{name}}» импортирована.',
themeImportErrorTitle: 'Не удалось импортировать эту тему',
themeImportErrorBody: 'Он не соответствует формату тем Psysonic. Возможно, он создан для другой версии или файл неполный — проверьте, что выбрали правильный .zip, или обратитесь к автору темы.',
themeImportErrorDetails: 'Технические подробности',
themeImportConfirmTitle: 'Установить тему?',
themeImportConfirmBody: 'Установить «{{name}}» от {{author}}?',
tabLibrary: 'Библиотека',
tabServers: 'Серверы',
tabLyrics: 'Тексты песен',
+10
View File
@@ -349,6 +349,16 @@ export const settings = {
themeStorePagePrev: '上一页',
themeStorePageNext: '下一页',
themeStorePageStatus: '第 {{page}} 页,共 {{total}} 页',
themeImportTitle: '导入主题',
themeImportButton: '导入主题…',
themeImporting: '正在导入…',
themeImportHint: '加载打包为 .zip 的主题(manifest.json + theme.css)。',
themeImportSuccess: '已导入“{{name}}”。',
themeImportErrorTitle: '无法导入此主题',
themeImportErrorBody: '它不符合 Psysonic 主题格式。可能是为其他版本制作的,或文件不完整——请确认选择了正确的 .zip,或联系主题作者。',
themeImportErrorDetails: '技术细节',
themeImportConfirmTitle: '安装主题?',
themeImportConfirmBody: '安装来自 {{author}} 的“{{name}}”?',
tabLibrary: '媒体库',
tabServers: '服务器',
tabLyrics: '歌词',