JPKCom Desktop — Anleitung & Tipps

JPKCom Desktop macht aus einer Website einen Desktop mit Fenstern, Dock und Apps — Vorlage, Konfiguration, eigene Apps, Datenschutz, PWA und Deployment mit strenger CSP.

JPKCom Desktop ist eine Weboberfläche im Stil eines klassischen Desktops — Fenster, Menüleiste, Dock und Apps im Browser, geschrieben in reinem JavaScript, für alle, die ihre Website als Desktop präsentieren wollen. Es besteht nur aus statischen Dateien: native ES-Module, kein Build-Schritt, keine Laufzeit-Abhängigkeiten und eine strenge Content Security Policy.

Anleitung

Was drin steckt

Der Desktop liegt auf einem beliebigen Webserver, in der Wurzel oder in einem Unterordner. Wie das Ergebnis aussieht, zeigt die Live-Demo der unveränderten Beispiel-Website (öffnet in neuem Tab). Diese Anleitung bezieht sich auf Version 1.4.0; was in welchem Release dazukam, steht im Changelog.

Bereich Umfang
Fenster Verschieben, Größe ändern, Zoom per Doppelklick auf die Titelleiste, Andocken an Rand oder Hälfte mit Vorschau, Fensterübersicht (F3 oder Strg+↑), Fenster wiederherstellen beim nächsten Besuch (Webfenster auf der zuletzt gezeigten Seite, auch außerhalb des Desktop-Ordners), Deep-Links #app=<id>, #app=<id>&path=/<pfad> (Web-Apps mit linkPaths: true), #search=<wörter>, #/pfad
Rund um die Fenster Menüleiste mit Markenmenü, Seitenmenüs, Suche, Sprachwahl, Wetter und Uhr (öffnet den Kalender); Dock; Symbole auf dem Schreibtisch; „Alle Apps“; Suche mit Strg/⌘+K oder /; Kontextmenüs per Rechtsklick, langem Druck oder Umschalt+F10
Apps (optional, in apps) Editor, Notizen, Aufgaben, Rechner, Terminal, Audio-/Videoplayer, Glückskeks
Kern Einstellungen, Hintergrund, Sicherung, Papierkorb, „Über diesen Desktop“, „Bedienung“
Module (optional, in modules) Reader (Seiten deiner Website, bereinigt, ohne iframe), Bildbetrachter, Katalog (Sammlungen), Suche, Kalender mit Kalenderwochen, Feiertage (lokal berechnet), Wetter (Open-Meteo oder Bright Sky), Mitteilungen aus einem JSON Feed, Tresor für verschlüsselte private Lesezeichen
Darstellung Dunkel, hell oder automatisch; Akzentfarben mit Kontrastprüfung; Kachelfarben; Hintergründe aus Farben, Verläufen, Bildern und erzeugten Motiven
Icons Tabler (nur die benutzten) plus eigene Site-Icon-Sets, auch zweifarbig
Sprachen Beliebig viele; Deutsch und Englisch liegen bei
Offline Installierbar als PWA, Service Worker mit Schnellstart; Feeds und Daten kommen immer frisch vom Server

Barrierefreiheit ist eingebaut: Die Menüleiste geht mit den Pfeiltasten, Fenster sind benannte Dialoge, der Fokus wird geführt und zurückgegeben, Änderungen werden über eine Live-Region angesagt, und prefers-reduced-motion wird respektiert.

Voraussetzungen

  • Ein Webserver. Jeder statische Server funktioniert. index.html direkt per file:// zu öffnen klappt nicht: Browser laden ES-Module nicht von file://, und fetch(), Service Worker und gespeicherte Einstellungen brauchen einen echten Origin.
  • HTTPS für Service Worker (Offline und Installation), Tresor (Web Crypto) und „Mein Standort“. Über HTTP läuft der Desktop trotzdem, nur ohne diese Funktionen. http://localhost und http://127.0.0.1 gelten bei der Entwicklung als sicher.
  • Node.js 24 oder neuer und Git — nur für die Werkzeuge. Der Desktop selbst braucht beides nicht. Entwicklungsabhängigkeiten sind nur die Tabler-Icon-Quellen und playwright-core für Headless-Checks (exakte Versionen in package.json).

Eine eigene Kopie anlegen

Der Leitfaden „Deine eigene Website in 10 Minuten“ (docs/quickstart.de.md) führt in fünf Schritten durch: Kopie holen, Name/Autor/Logo, Inhalte, lokal prüfen, veröffentlichen. Für die Kopie gibt es drei Wege:

  1. Mit der Vorlage (empfohlen): Auf der Repository-Seite (öffnet in neuem Tab) Use this template → Create a new repository wählen und das neue Repository klonen.
  2. Direkt klonen: git clone https://github.com/JPKCom/jpkcom-desktop.git my-desktop
  3. Ohne Git herunterladen:
curl -fsSL -o jpkcom-desktop.zip https://github.com/JPKCom/jpkcom-desktop/archive/refs/heads/main.zip
unzip jpkcom-desktop.zip
cd jpkcom-desktop-main

Lokal starten

sfw npm ci         # nur Entwicklungswerkzeuge: Tabler-Icon-Quellen, Browser-Checks
npm run serve      # http://127.0.0.1:8080/ mit den Sicherheits-Headern der Produktion

Einen Build-Schritt gibt es nicht: Datei ändern, Seite neu laden. npm run serve selbst braucht keine Pakete — node tools/serve.mjs läuft auch ohne npm ci. sfw ist Socket Firewall Free (öffnet in neuem Tab) und blockiert bösartige Pakete schon bei der Installation; ein einfaches npm ci geht auch.

Optionen gibst du nach -- weiter, etwa npm run serve -- --port 3000 --host 0.0.0.0:

Option Standard Wirkung
--port 8080 Port
--host 127.0.0.1 0.0.0.0 macht den Server im LAN erreichbar
--base / Unterordner testen, z. B. --base /desktop/ (wie auf GitHub Pages)
--root Projektordner Auszuliefernder Ordner
--connect — Zusätzliche connect-src-Origins (kommagetrennt, nur https://) für eingeschaltete Online-Dienste
--frame — Zusätzliche frame-src-Origins (kommagetrennt, nur https://) für web-Apps auf anderen Origins
--wasm — Ergänzt 'wasm-unsafe-eval' (nur für Pagefind)
--geolocation — geolocation=(self) in der Permissions-Policy (nur mit services.geolocation: true)
--extra — Liefert einzelne Dateien von außerhalb des Projekts unter <base><url-pfad> aus, z. B. --extra site/config.js=/tmp/cfg.js,site/icon-sets/t.json=/tmp/t.json — nur für Tests und Versuche, auf echten Servern gibt es das nicht

Der Testserver beantwortet nur GET/HEAD, liefert für Ordner die index.html (ohne Schrägstrich am Ende erst nach einer Weiterleitung) und gibt Dotfiles, node_modules, tools und tests nie aus. Er sendet die Header über HTTP, also ohne upgrade-insecure-requests und HSTS.

Der Ordner site/

Die Grundregel des Projekts: site/ gehört dir, src/ ist das Projekt. Lässt du src/ unverändert, aktualisierst du später durch Ersetzen von src/, locales/ und sw.js und erzeugst danach Icons und Vorlade-Hinweise neu (siehe „Eine bestehende Website aktualisieren“).

Pfad Inhalt
site/config.js Klassisches Skript, setzt window.DESKTOP_CONFIG — jeder Schlüssel ist kommentiert
site/apps.js Manifest als ES-Modul: apps, collections, menus, files
site/theme.css Haus-Theme (Token-Overrides in @layer themes), wird ohne Regeln ausgeliefert
site/content/<lang>/ HTML-Seiten für den Reader, ein Ordner je Sprache (eine Datei je Seite), dazu Text-Handbücher fürs Terminal (Beispiel: manuals/writing-pages.md) — für den Service Worker sind das Daten
site/data/ Mitteilungs-Feeds (feed.<lang>.json), Glückskeks-Sprüche (fortunes/<lang>.json) und Daten, die eigene Module zur Laufzeit holen (site/data/<modul-id>/); der Service Worker holt sie immer zuerst vom Server
site/icon-sets/ Eigene Icon-Sets als JSON (optional, keins mitgeliefert; im öffentlichen Repository per .gitignore ausgeschlossen)
site/wallpapers/ Hintergrundbilder (leer ausgeliefert)
site/vault/ Versiegelte Tresor-Dateien (keine ausgeliefert)
site/modules/ Eigene Apps und Module, Beispiel-App hello/

Die mitgelieferten Inhalte sind eine Beispiel-Website (Über, Handbuch, Versionshinweise, Impressum, Datenschutz, Lesezeichen, Schaufenster), die du nach und nach ersetzt.

Konfiguration in site/config.js

Jeder Schlüssel ist optional. Fehlende Werte kommen aus den DEFAULTS in src/core/config.js; ungültige Werte meldet die Browser-Konsole und ersetzt sie durch den Standard — die Seite bricht nie. Beim Zusammenführen gilt:

  • Objekte werden Schlüssel für Schlüssel mit den Standards gemischt.
  • Arrays und einfache Werte ersetzen den Standard komplett — modules: [...] lässt jedes nicht genannte Modul weg.
  • Sprach-Maps wie { de: '…', en: '…' } ersetzen als Ganzes, ebenso terminal.manUrl. Beim Nachschlagen fällt der Wert von der Sprache auf die Basissprache, defaultLang, 'en' und schließlich den ersten Eintrag zurück.
  • In theme.accents, theme.tints und services entfernt null einen Standard-Eintrag.

Die wichtigsten Bereiche: brand, author, credit; site (home, legal, hosts, routes, description); about; languages und defaultLang; theme, wallpaper, iconSets (eigene Icon-Sets) und iconReplace (eigene Glyphen für die Symbole des Desktops); wm, session, dock, desktop, boot, power, ui; modules und apps (was fehlt, wird nicht einmal heruntergeladen); services; dazu eigene Abschnitte für Module und Apps (reader mit keepStyles, styleScope und styleVars, search, notify mit label, weather, vault, fortune mit local und texts, terminal …) und für den Offline-Betrieb (pwa, offline mit legacyCaches).

Name, Logo und Credit

Für deinen eigenen Auftritt ersetzt du brand:

js
	brand: {
		name: 'My Desktop',            // Dokumenttitel, „Über diesen Desktop“, Terminal
		shortName: 'My Desktop',       // gleich short_name in manifest.webmanifest
		menuLabel: 'My Site',          // zugänglicher Name des Markenmenüs
		glyph: 'ti-device-desktop',    // Tabler-Icon oder Icon aus deinem Site-Icon-Set (z. B. 'acme-logo')
		logo: null,                    // kein ausführliches Logo
		asciiLogo: ['My Site'],        // Terminal-Bild (neofetch), ein String pro Zeile
		host: null,
		themeColor: '#1c2935'
	},

Dein Name kommt in about.copyright: { holder: 'Erika Mustermann', since: 2026 }. author.name, author.brand, author.url und credit: true bleiben bitte, wie sie sind — sie erzeugen die Credit-Zeile „JPKCom Desktop by Jean Pierre Kolb“. Aus author.links werden Apps im Hilfe-Menü und im Dock; du kannst sie behalten, ersetzen oder links: [] setzen — dann ziehst du die Lesezeichen mit, die auf sie verweisen (siehe Tipps & Tricks).

Zum vollständigen Rebranding gehören außerdem:

  1. wallpaper.motifs ohne 'author-monogram' und 'author-emblem': ['author-blueprint', 'waves', 'dunes', 'aurora', 'orbit', 'horizon', 'graphite'].
  2. Eigene assets/icons/favicon.svg und maskable.svg, danach (nach sfw npm ci) einmal npm run browsers und dann npm run icons:pwa. npm test prüft die Dateien wörtlich: Beide beginnen mit <svg samt viewBox="0 0 512 512" (keine XML-Deklaration, kein Kommentar davor); maskable.svg hat als Hintergrund genau <rect width="512" height="512" fill="url(#g)"/> (Verlauf mit der ID g), nirgends rx= und hält das Motiv in den mittleren 80 %. Ein Platzhalter-Paar zum Kopieren steht im Schnellstart (Schritt 2).
  3. Die festen Zeilen in manifest.webmanifest (name, short_name, description, theme_color, background_color) und in index.html (<html lang>, <title>, <h1 id="desk-title">, Beschreibung, theme-color, apple-mobile-web-app-title, <noscript> mit einer Zeile je Sprache). Die Meta-Tags author und generator bleiben.

Apps, Seiten und Sammlungen in site/apps.js

Jede App braucht eine id aus [a-z0-9-] (1–64 Zeichen) und eine Art (kind):

kind Öffnet Braucht Modul
page Eine Seite aus site/content/ im Reader reader
web Eine Seite im iframe-Fenster (gleicher Origin oder per frame-src erlaubt); optional mit scope und linkPaths — (eingebaut)
link Eine externe Seite im neuen Tab (https://, http:// nur mit allowHttp: true) —
collection Ein Katalog-Fenster mit Gruppen catalog
image Ein Bild im Bildbetrachter viewer

Eine eigene Seite trägst du so ein — die Hilfsfunktion page() oben in der Datei baut den Pfad für jede Sprache:

		{
			id: 'services', kind: 'page', icon: 'ti-file-text', tint: 'blue', desktop: true, dock: true,
			name: { en: 'Services', de: 'Leistungen' },
			url: page('services.html', 'leistungen.html')
		},

Die Seite selbst ist schlichtes HTML ohne Skripte, Styles oder style-Attribute. Der Reader nimmt das erste main article, dessen h1 als Titel und den Absatz .lead; der Alternate-Link sagt ihm, welche Seite er nach einem Sprachwechsel zeigt:

html
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Leistungen | My Site</title>
<link rel="alternate" hreflang="en" href="../en/services.html">
<link rel="stylesheet" href="../content.css">
</head>
<body><main><article>
<h1>Leistungen</h1>
<p class="lead">Das Angebot in ein, zwei Sätzen.</p>
<p>Text, Links, Listen, Tabellen …</p>
</article></main></body>
</html>

Weitere Bausteine:

  • Override-Einträge: Eine App, die ein Modul mitbringt, änderst du mit ihrer id und nur den gewünschten Feldern, ohne kind: { id: 'notes', dock: true }.
  • Aliase: { id: 'alter-name', alias: 'neuer-name', hidden: true } hält alte Links nach einer Umbenennung am Leben. Seit 1.3.0 steht ein Alias überall dort, wo der Desktop einer App einen Platz gibt, für sein Ziel: Gemerkte Dock-Listen und dock.pins lösen Alias-IDs auf das Ziel auf (das Dock zeigt dessen Name, Icon und Farbe), und auf dem Schreibtisch erscheint kein zweites Symbol mehr. desktop: true und dock: true gehören deshalb an die App selbst oder an ihren Override-Eintrag, nicht an den Alias. Ein Ring aus Aliasen (a → b → a) ist ein Fehler.
  • Web-Apps mit Unterseiten: scope ist der Ordner, in dem ein Webfenster beim Wiederherstellen und in Links bleiben darf — relativ zum Desktop ('demos/clock/') oder, für einen Ordner außerhalb, ab der Wurzel der Website ('/wiki/'), nie die Wurzel des Desktops oder ein Ordner darüber. Ohne scope gilt der erste Ordner der Startseite unterhalb des Desktops bzw. bei einer Startseite außerhalb deren eigener Ordner. Mit linkPaths: true behalten „Link zu diesem Fenster kopieren“ und #app=<id>&path=/… die Unterseite; standardmäßig ist das aus, und für Inhalte, die du nicht selbst kontrollierst, bleibt es aus (die gehören in eine Sandbox). Beispiel: { id: 'wiki', kind: 'web', icon: 'ti-book', url: '/wiki/start/', scope: '/wiki/', linkPaths: true, name: { en: 'Wiki', de: 'Wiki' } }. Einträge einer Sammlung kennen scope und linkPaths ebenso.
  • Sammlungen (collections): Jeder Eintrag in items wird zur App <prefix>-<slug>; die Sammlung bekommt ohne Code ein Katalog-Fenster, eine Suchgruppe und Menüeinträge. itemKind: 'auto' entscheidet pro Eintrag: http(s):// → link, Bildendung → image, sonst web. Der Knopf „Übersicht im Web“ des Katalogs startet die App aus webApp (sie hat Vorrang vor webUrl). Liegt die Übersichtsseite genau unter dem basePath der Sammlung, legst du dafür eine versteckte Web-App an — { id: 'tools-web', kind: 'web', hidden: true, url: 'tools/' } — und setzt webApp: 'tools-web'; ein webUrl, das dem basePath gleicht, öffnet sonst einen neuen Tab. Für eine Übersicht im Reader genügt webUrl: 'tools/index.html'.
  • Handbuchseiten fürs Terminal (man): man <eintrag> im Terminal zeigt ein Text-Handbuch (.md, .markdown oder .txt auf deiner Website). man steht an einem Eintrag (ein Pfad, { lang: pfad } oder false) oder an der ganzen Sammlung als Vorlage mit {slug} oder {id} (weitere Platzhalter: {collection}, {lang}), z. B. man: 'site/content/{lang}/manuals/{slug}.md'. Es gilt zuerst das man des Eintrags, dann das der Sammlung, dann terminal.manUrl in site/config.js — das nimmt jetzt auch eine Map { lang: vorlage } und gilt nur für die Sammlungen deiner Website, nie für die Lesezeichen des Tresors. Die Beispiel-Website zeigt es am Eintrag „Seiten schreiben“: man: page('manuals/writing-pages.md'), aufgerufen mit man writing-pages. Fehlt eine Datei, ist das kein Fehler: Das Terminal meldet, dass der Eintrag keine Handbuchseite hat, und bietet die Dokumentation an. Handbücher legst du am besten unter site/content/ oder site/data/ ab, dann behandelt der Service Worker sie als Daten.
  • Menüs (menus): Einträge sind App-IDs, '-' als Trenner, { collection: id }, { label, url } oder ein Untermenü — Untermenüs gehen nur eine Ebene tief.
  • Terminal-Dateien (files): Dateien, die cat im Terminal zeigt, z. B. license: 'LICENSE'; nur relative Pfade auf demselben Origin, .md wird als Markdown dargestellt.

Nach jeder Änderung prüft npm run validate das Manifest gegen site/config.js — IDs, Arten und nötige Module, Verweise, URLs, Icons, Kachelfarben und Texte für jede konfigurierte Sprache, außerdem die Site-Icon-Sets (IDs, Dateien, reservierte Präfixe, Definitionen, Größe), man und terminal.manUrl (fehlende Handbuchdateien zählen als Warnungen), scope und linkPaths, webApp, webUrl, allLabel und webLabel (auch an Override-Einträgen von Katalog-Apps) sowie fortune.local und fortune.texts (auch Platzhalter, die ein Schlüssel nicht kennt) und jedes Paar in iconReplace. Exit-Code 0 heißt in Ordnung, 1 Fehler (mit --strict bzw. npm run validate:strict auch Warnungen), 2 Manifest oder Konfiguration nicht ladbar. Ein Tabler-Icon, das der Desktop noch nicht verwendet (jeder Name 'ti-…'/'tif-…', siehe Tabler Icons (öffnet in neuem Tab)), braucht danach einmal npm run icons (nach sfw npm ci); bis dahin bleibt es leer, und npm run validate warnt. Icons aus einem Site-Icon-Set brauchen keinen Neubau.

Sprachen

languages: ['de', 'en'] legt die angebotenen Sprachen in Menü-Reihenfolge fest (zwei ergeben einen Umschalter, drei oder mehr ein Menü); defaultLang: 'en' greift, wenn der Browser keine davon verlangt. Beim ersten Besuch zählt seit 1.3.0 die Reihenfolge der Browsersprachen: Für jeden Eintrag von navigator.languages sucht der Desktop erst den exakten Tag, dann den gekürzten, dann eine andere Region derselben Sprache, bevor er zum nächsten Eintrag geht — mit de-AT, en startet eine de/en-Website also auf Deutsch. Eine gespeicherte Wahl und ?lang= gehen immer vor.

Eine neue Sprache ist ein Ordner mit Übersetzungen:

cp -r locales/en locales/fr
# locales/fr/_meta.js: export default { name: 'Français', intl: 'fr-FR', dir: 'ltr', yes: '^(o|oui|y|yes)$' };
# site/config.js: languages: ['de', 'en', 'fr']
npm run i18n:check -- fr
npm run validate

Übersetze die Werte jeder Namespace-Datei und lass Schlüssel und {platzhalter} stehen. Plurale sind Objekte mit den Kategorien von Intl.PluralRules (other ist Pflicht). Danach brauchen alle Sprach-Maps in site/config.js und site/apps.js einen fr-Wert — auch die Seitenpfade: Die Hilfsfunktion page() in site/apps.js baut nur en und de, also erweiterst du sie um einen dritten Parameter oder schreibst die URLs als { en, de, fr }, sonst warnt npm run validate („has no address for 'fr'“). Dasselbe gilt für die Texte in fortune.texts (prüft npm run validate) und für man-Maps der Form { lang: pfad }. Außerdem brauchen Site-Module mit eigenen Texten eine locales/fr/-Datei, und index.html eine <p lang="fr">-Zeile im <noscript>. Nach einem Update meldet npm run i18n:check die neuen Schlüssel, die deinen eigenen Sprachordnern noch fehlen — mit 1.2.0 sind das fortune.noSource, fortune.denyOnline, terminal.noManualPage und terminal.manOpen, mit 1.3.0 kommt notify.meta dazu. Optional: site/content/fr/, site/data/fortunes/fr.json (plus 'fr' in fortune.langs) und site/data/feed.fr.json (plus notify.feeds.fr). Fehlende Schlüssel brechen nie etwas — sie fallen auf Basissprache, defaultLang und Englisch zurück; debug: true listet sie in der Konsole.

Erscheinungsbild und Haus-Theme

Modus, Akzent und Fensterknöpfe stellst du in site/config.js ein; Besucherinnen und Besucher können danach in den Einstellungen selbst wählen:

js
theme: {
	default: 'dark',                                  // 'dark' | 'light' | 'auto' (folgt dem System)
	accent: 'blue',
	allowCustomAccent: true,
	accents: { brand: '#0f6b8f', pink: null },        // weißer Text braucht ≥ 4.5:1, null entfernt
	tints: { brand: ['#3fb6e0', '#0f6b8f'] },         // Kachelverlauf [oben, unten]; Apps: tint: 'brand'
	windowControls: { side: 'left', style: 'classic' } // 'left' | 'right', 'classic' (Punkte) | 'minimal'
},

Eigene Akzente bekommen ihren Namen als accent.<id> in locales/<lang>/settings.js, sonst zeigen die Einstellungen die ID.

Hintergründe: wallpaper.default ist ein Verlauf, eine Farbe ({ type: 'color', color }), ein Motiv ({ type: 'svg', id }) oder ein Bild ({ type: 'image', id }). Bilder (WebP oder AVIF, etwa 2560 × 1600) legst du in site/wallpapers/ und trägst sie in wallpaper.images ein: { id, src: 'site/wallpapers/hafen.webp', name, credit, tone }. src muss ein relativer oder Wurzelpfad sein (CSP img-src 'self'); tone: 'light' gibt der Menüleiste bei hellen Bildern dunkleres Glas.

Haus-Theme: Rundungen, Glas, Schatten und Farben überschreibst du als Tokens in site/theme.css, im Layer themes. Der steht in der Layer-Reihenfolge ganz hinten und gewinnt unabhängig von der Spezifität gegen alle anderen. index.html lädt die Datei direkt nach dem Kern-CSS und vor dem ersten Paint; einen Build-Schritt gibt es auch hier nicht.

css
@layer themes {
	:root { --radius-control: 2px; --radius-panel: 4px; --radius-win: 4px; }
	body.compact { --radius-win: 4px; }
	:root[data-theme="light"] { --win-bg: #fbfaf7; }
	:root[data-theme="dark"], [data-island="dark"] { --win-bg: #1b2430; }
}

Die Regeln aus der Theming-Referenz (docs/theming.md): Dunkle Werte gehören immer auch auf [data-island="dark"] — Menüleiste, Desktop-Symbole, Terminal, Rechner, Code-Blöcke und Startbild bleiben in beiden Modi dunkel. Familien-Tokens wie --radius-control setzt du auf :root; auf body.compact oder einer Insel erreichen sie die Teil-Tokens nicht — dort überschreibst du stattdessen die Teil-Tokens (z. B. --radius-win). Schatten und Ringe (--shadow-*, --ring-*) gehören immer auf :root, [data-island="dark"], weil die Inseln eigene deklarieren; Rundungs- und Glas-Tokens auf :root erreichen sie ohnehin. Akzent, Kachelfarben, Verlauf des Hintergrunds und --anim setzt der Desktop inline per JavaScript — die änderst du in site/config.js, nicht im CSS. Die mitgelieferte site/theme.css enthält ein auskommentiertes Beispiel „eckig und flach“ zum Ausprobieren.

Eigene Icons: Site-Icon-Sets

Neben Tabler kann deine Website eigene Icons mitbringen, etwa ein Set, für das du eine Lizenz hast. Ein Site-Icon-Set ist eine JSON-Datei, die du in site/config.js einträgst — höchstens acht, Pfade relativ zur Installation, nur Buchstaben, Ziffern und . _ - /:

iconSets: ['site/icon-sets/duotone.json'],
json
{ "format": "jpkcom-desktop-icons/1",
  "name": "Acme duotone",
  "license": "Acme Icons 2.1 — kommerzielle Lizenz",
  "icons": {
    "acme-rocket": { "k": "d", "vb": "0 0 512 512", "e": ["M…"], "e2": ["M…"] },
    "acme-logo":   { "k": "f", "vb": "0 0 448 512", "e": ["M…"] } } }
  • IDs haben die Form <präfix>-<name>: ein Präfix aus 2–12 Kleinbuchstaben und Ziffern, beginnend mit einem Buchstaben, nicht ti, tif, wc, tile oder jpk, insgesamt höchstens 64 Zeichen. In site/apps.js, in Tresor-Daten und in brand.glyph verwendest du sie wie Tabler-IDs.
  • Arten (k): 'o' Outline (im 24er-Raster; auf einem anderen Raster gibst du in a eine eigene stroke-width an, zusammen mit fill: 'none', stroke: 'currentColor' und runden Linienenden und -ecken — a ersetzt die Attribute der Art), 'f' gefüllt, 'd' zweifarbig mit der zweiten Ebene in e2.
  • Zweifarbige Icons stimmst du in site/theme.css mit --icon-duo-opacity (Standard 0.4) und --icon-duo-color (Standard currentColor) ab. Die Tokens setzt du auf einen Container, etwa .tile { --icon-duo-opacity: 0.5 } in @layer themes — ein Selektor mit Vorfahren wie .tile .i-duo erreicht die Kopie hinter <use> nie.
  • Nur die benutzten Icons gehören ins Set; die Teilmenge schreibt dein Konverter. npm run validate warnt ab 256 KiB; über 2 MiB oder 5000 Icons lehnt der Desktop das Set ab und startet ohne es.
  • Sets führen keinen Code aus: Jede Definition läuft durch eine Allowlist für SVG-Tags und -Attribute (kein g, kein style, kein url(), kein var()). npm run icons baut weiterhin nur Tabler — für Set-Icons brauchst du keinen Neubau.
  • Lizenz: Das Projekt liefert kein Set mit, und site/icon-sets/* steht in der .gitignore. Ein kommerziell lizenziertes Set gehört nie in ein öffentliches Repository oder einen Fork. Ein privates Site-Repository, das sein Set mit versioniert, löscht diese Zeile oder ergänzt !site/icon-sets/<name>.json. Auf GitHub Pages veröffentlicht eine öffentliche Kopie der Vorlage kein ignoriertes Set — ein lizenziertes Set legst du dort also gar nicht erst ab.

Die Symbole des Desktops ersetzen

Seit 1.3.0 zeichnet der Desktop auch seine eigenen Glyphen — Menüleiste, Fensterknöpfe, Einstellungen, Dock, Wetterlagen — auf Wunsch mit Icons aus deinem Set, damit alles in einem Stil erscheint. Dafür ordnest du in site/config.js jeder ID eines Desktop-Symbols ein Ziel zu:

iconReplace: { 'ti-settings': 'acme-cog', 'ti-sun': 'acme-sun', 'wc-close': 'acme-xmark' },
  • Schlüssel sind IDs mit ti-, tif-, wc- oder tile- (das Monogramm jpk bleibt), Ziele jedes bekannte Icon, meist aus deinem Set. Ersetzt wird in einem Schritt, nie verkettet; höchstens 500 Paare.
  • icon() und symbolHref() lösen die Zuordnung an einer Stelle auf — Core, Module, Apps und Site-Module folgen ihr ohne Änderung.
  • Danach npm run icons und npm run validate. Die ersetzten Tabler-Icons bleiben absichtlich in src/icons/tabler.js: Lädt das Set nicht, zeigt der Desktop das Original. Ein Paar, dessen Schlüssel oder Ziel der Browser nicht kennt, meldet die Konsole beim Start und lässt es weg.
  • Eine ID mit zwei Bedeutungen wird an beiden Stellen ersetzt — die Wetterlagen Schneeregen und Hagel teilen sich ti-cloud-snow.

Eine eigene App schreiben

Eigene Apps leben in site/modules/<id>/ neben deinen Inhalten; dein Code berührt src/ nicht — nur npm run preload erzeugt src/boot/preload.js neu (nach jedem Update, das src/ ersetzt, erneut ausführen). Ausgangspunkt ist die Beispiel-App „Hallo“ in site/modules/hello/ (index.js, model.js, hello.css, locales/de/hello.js, locales/en/hello.js). Sie zeigt ein Fenster, Texte mit Platzhalter und Plural, eigenes CSS, einen gespeicherten Wert mit Validierung, Sicherung und Zurücksetzen sowie das Terminal-Kommando hello.

  1. Ordner kopieren: site/modules/hello/ → site/modules/<id>/. Die ID beginnt mit einem Buchstaben, besteht aus a–z, 0–9 und - und hat höchstens 32 Zeichen.
  2. Jedes hello im Ordner ersetzen, auch in Dateinamen: ID, i18n-Namespace, @hello.…-Schlüssel, Speicherschlüssel, Terminal-Kommando und CSS-Klassen. Ein zweites Kommando gleichen Namens wird abgelehnt. Den Anzeigenamen Hallo/Hello erfasst die Suche nach hello nicht: Schreib die Texte in locales/<lang>/<id>.js neu (appName, appDesc, Grüße, cmd, cmdMan).
  3. In site/config.js eintragen: apps: [ …, { id: 'my-app', src: 'site/modules/my-app/index.js' } ].
  4. Prüfen und Vorlade-Hinweise neu erzeugen:
npm run icons        # nur bei einem neuen Tabler-Icon
npm run i18n:check   # jede Sprache aus locales/ braucht ihre Datei in deinem locales/<lang>/
npm run validate
npm run preload      # schreibt src/boot/preload.js neu

Beispiel nicht gewünscht? { id: 'hello', src: 'site/modules/hello/index.js' } aus apps in site/config.js und den Ordner löschen, danach npm run preload ausführen und tests/site-hello.test.mjs löschen (oder auf das model.js deiner App umstellen).

Der Deskriptor ist der Default-Export von index.js. Die Kernfelder am Beispiel „Hallo“:

js
export default {
	id: 'hello',                    // muss gleich der ID in site/config.js sein
	kind: 'app',
	i18n: ['hello'],
	locales: 'locales/',            // Texte: locales/<lang>/hello.js neben dem Code
	styles: ['hello.css'],
	app: { icon: 'ti-mood-smile', tint: 'green', size: [420, 360], name: '@hello.appName', desc: '@hello.appDesc' },
	storage: { [KEY]: { type: 'json', backup: true, reset: KEY, label: '@hello.appName', validate: clean } },
	resetGroups: [{ id: KEY, label: '@hello.appName', hint: '@hello.resetHint', order: 60 }],
	terminal: { [KEY]: { run: command, help: '@hello.cmd', usage: 'hello [name]', man: '@hello.cmdMan' } },
	mount,
	focus: win => win.state[KEY].field.focus({ preventScroll: true }),
	relabel: win => win.state[KEY].draw(),
	unmount: win => win.state[KEY].off()
};

Für größere Apps lohnt Fenstercode bei Bedarf (neu in 1.1.0): app.load: () => import('./window.js') lädt DOM, CSS (windowStyles) und Bibliotheken erst beim ersten Öffnen. Der Import muss als Literal dastehen, und der Deskriptor darf die Fenster-Datei nie statisch importieren — sonst landet sie wieder im Start. Vorbild ist src/apps/calc/. Für DOM und Styles gelten die CSP-Regeln des Projekts: kein innerHTML, kein style=""; DOM baust du mit Desk.h() und textContent, Styles per CSSOM, CSS liegt in @layer apps mit Tokens statt Farbliteralen.

Seit 1.2.0 kommen drei Regeln für Site-Module dazu:

  • Icons baust du nur mit Desk.icons.icon(id). Brauchst du die <use>-Referenz in einer eigenen Grafik, liefert Desk.icons.symbolHref(id) sie ('#i-<id>' oder null). Schreib nie href="#ti-…" von Hand: Sprite-Symbole haben jetzt die DOM-ID i-<icon-id>, und Element-IDs mit i- am Anfang sind reserviert. Desk.icons.appGlyph(app, { cls, fallback }) gibt dir das Icon einer App ohne Kachel.
  • Daten, die dein Modul zur Laufzeit liest (JSON, Markdown), gehören nach site/data/<id>/ und kommen per Desk.net.getJson bzw. getText, nie per Import. Sie sind immer aktuell; Dateien im Modulordner sind Code. Ändere Datenformate verträglich: Felder ergänzen statt umbenennen oder entfernen, für ein unverträgliches Format ein neuer Dateiname.
  • Eine Online-Quelle für den Glückskeks bringst du über fortuneProviders im Deskriptor mit (siehe „Glückskeks: nur online, eigene Quelle, eigene Texte“).

Seit 1.4.0 kommen zwei Werkzeuge dazu:

  • Importe mit berechnetem Namen (import(`./parts/${name}.js`)) sieht der Service Worker nicht, denn er baut die Offline-Kopie aus den Importen, die als Literal im Quelltext stehen (Importe in Kommentaren zählen nicht). Solche Dateien listest du im Deskriptorfeld precache: ['parts/a.js', 'parts/b.js'] auf — Literale relativ zur Deskriptor-Datei, .js/.mjs, .css oder .json, jede Datei, die der Import erreichen kann. Ohne das Feld kommt eine Datei erst in die Kopie, wenn sie einmal online geladen wurde, und fehlt offline bis dahin. So hält das Feiertagsmodul seine Regionsdateien offline bereit. Der Loader selbst ignoriert das Feld.
  • Speicherschlüssel: Eigener Code, der Schlüssel über das Präfix <namespace>- sucht, nimmt Desk.store.names() oder Desk.store.owns(key) — das Präfix allein trennt einen Desktop nicht von einem zweiten mit längerem Namespace (siehe Tipps & Tricks). Schlüssel außerhalb der storage-Deklaration, etwa eine ganze Familie von Namen, meldet dein Modul mit Desk.store.claim() an.

Online-Dienste und Datenschutz

Der Desktop kommt ohne Tracking, Analytics, Cookies und Code von Dritten aus. Einstellungen, Notizen, Aufgaben, Editor-Entwurf und Terminal-Verlauf bleiben in localStorage und IndexedDB auf dem Gerät; lokale Dateien werden nie hochgeladen. Online-Dienste haben zwei Schranken, beide standardmäßig zu: Die Website muss den Dienst in services einschalten, und jede Besucherin und jeder Besucher muss vor der ersten Anfrage zustimmen (widerrufbar unter Einstellungen → Online-Dienste). Anfragen an andere Origins gehen ohne Cookies und ohne Referrer raus.

Dienst In site/config.js In die CSP des Servers
Wetter (Open-Meteo, weltweit) 'weather' in modules, services.weather: true, weather.provider: 'open-meteo' connect-src https://api.open-meteo.com
Wetter (Bright Sky, nur Deutschland) wie oben, weather.provider: 'brightsky' connect-src https://api.brightsky.dev
„Mein Standort“ fürs Wetter wie Wetter, zusätzlich services.geolocation: true Permissions-Policy: geolocation=(self)
dig/host im Terminal services.dns: true, terminal.doh: { url: 'https://dns.google/resolve', name: 'dns.google' } connect-src https://dns.google
Glückskeks online services.fortune: true, fortune.remote: 'jokeapi' oder 'uselessfacts' connect-src https://v2.jokeapi.dev bzw. https://uselessfacts.jsph.pl
Glückskeks, Online-Quelle aus deinem eigenen Modul services.fortune: true, fortune.remote: '<id>' connect-src https://<deine Hosts>
Pagefind-Volltextsuche search.pagefind: { path: 'pagefind/pagefind.js' } script-src 'wasm-unsafe-eval' (Policy des Desktops und von pagefind-worker.js)

Fehlt der Host in der CSP, blockiert der Browser die Anfrage („Refused to connect“). Bei Pagefind gehört 'wasm-unsafe-eval' in die Policy, sobald search.pagefind gesetzt ist: Die Suche kompiliert ihr WebAssembly im Worker pagefind-worker.js und weicht auf die Seite des Desktops aus, wenn der Worker scheitert oder nicht binnen fünf Sekunden startet — dafür genügt schon eine langsame Verbindung. Die mitgelieferten Server-Konfigurationen senden die Policy des Desktops mit jeder Datei, also auch mit dem Worker. Erlaubt ist damit nur das Kompilieren von WebAssembly, kein eval. Lokal testest du das mit npm run serve -- --connect https://api.open-meteo.com --wasm --geolocation. Beim Standort speichert der Desktop nur die auf etwa 1 km gerundete Position. Impressum und Datenschutzerklärung der Beispiel-Website (site/content/<lang>/imprint.html, privacy.html) sind Vorlagen — füll sie vor dem Veröffentlichen aus oder entferne sie aus site.legal und site/apps.js.

Die Zustimmung zur Online-Quelle des Glückskekses ist an den Anbieter gebunden (ID und Hosts): Wechselst du ihn, fragt der Desktop erneut. Besucherinnen und Besucher, die der Quelle vor 1.2.0 zugestimmt haben, fragt er nach dem Update einmal neu.

Glückskeks: nur online, eigene Quelle, eigene Texte

  • Nur online: fortune.local: false betreibt die App ohne eingebaute Sprüche — nichts wird vorab gespeichert, und das Terminal-Kommando fortune ist ausgeblendet. Das braucht ein remote; fehlt es, warnt die Konsole und es gilt wieder true.
  • Umbenannt: Gibst du der App per Override in site/apps.js einen anderen Namen und ein anderes Icon ({ id: 'fortune', name: {…}, icon: '…' }), ersetzt fortune.texts die Texte, die die App beim Namen nennen; die Karte zeigt automatisch Logo, Zeichen oder Icon der App. Ersetzbare Schlüssel: next, prev, copy, copied, loading, empty, localError, noSource, sourceLocal, askTitle, allow, deny, denyOnline, service, serviceHint, cmd, cmdMan — seit 1.3.0 außerdem die Zustimmungsfrage (askText, askText2), der Satz zur Sprache der Texte (askLang), der Tastenhinweis (keys), „Mehr erfahren“ (web), die Fehlermeldung (error) und die Bezeichnung unter Sicherung und Zurücksetzen (storageLabel). Platzhalter füllt die App je Schlüssel: askText kennt {provider} und {host}, askLang {language}, keys {space}, {back} und {next}, error {host}. Einen Platzhalter, den der Schlüssel nicht kennt (etwa {hots}), meldet npm run validate. Eine ersetzte Zustimmungsfrage muss weiterhin nennen, wer die Anfrage bekommt — {host} oder den Host ausgeschrieben —, sonst warnen Konsole und Validator.
fortune: { remote: 'example', local: false, texts: { next: { en: 'Next fact', de: 'Nächster Fakt' } } },
  • Eigene Quelle: Ein Site-Modul bringt sie deklarativ mit — als schlichtes Objekt-Literal, mit einem bis acht Hosts und ohne requires: ['fortune']; danach npm run preload:
fortuneProviders: [{
	id: 'example', name: 'Example facts', hosts: ['api.example.org'],
	url: ({ cat }) => `https://api.example.org/random${cat ? `?category=${encodeURIComponent(cat)}` : ''}`,
	parse: (j, { plain }) => ({ text: plain(j.text), lang: 'en' })
}]

Alle Details stehen in der Glückskeks-Referenz (öffnet in neuem Tab).

Private Lesezeichen: der Tresor

Der Tresor zeigt verschlüsselte Lesezeichen, nachdem jemand login im Terminal eingibt. Aus Benutzername und Passwort leitet PBKDF2-HMAC-SHA-256 einen AES-256-GCM-Schlüssel und den Dateinamen ab; falsche Zugangsdaten fragen schlicht eine nicht vorhandene Datei an.

node tools/seal-vault.mjs --new-salt              # Salt in site/config.js eintragen
npm run seal -- --in ~/private/bookmarks.json     # erzeugt site/vault/<32 hex>.bin
node tools/seal-vault.mjs --list                  # versiegelte Dateien und Parameter

Dazu 'vault' in modules und vault: { salt: '<dein Salt>', iterations: 600000 } in site/config.js. Die Klartext-JSON (groups und items, höchstens 20 Gruppen und 500 Einträge) gehört außerhalb des Projekts und jedes Web-Roots (z. B. nach ~/private/); das Werkzeug verweigert sie unter site/, im Zielordner und im zugehörigen Web-Root (standardmäßig das Projekt). Der Server darf site/vault/ nicht auflisten und muss .bin mit Cache-Control: no-cache über HTTPS ausliefern. Der Schutz hängt an Passwortstärke und PBKDF2-Kosten: gedacht für private Links, nicht für Geheimnisse. Unter Einstellungen → Zurücksetzen erscheint die Zeile „Private Lesezeichen“ seit 1.3.0 nur, solange der Tresor entsperrt ist oder ein Login auf dem Gerät gemerkt ist; wer den Desktop nur besucht, erfährt also nicht, dass es einen Tresor gibt.

Tresor-Daten dürfen Icons aus Site-Icon-Sets verwenden. Ein Icon, das nur im Tresor vorkommt, muss trotzdem im Set stehen — und die Set-Datei ist öffentlich, sie verrät also, welche Icons der Tresor nutzt; nimm dort lieber neutrale Icons. npm run seal liest iconSets unterhalb des Web-Roots von --out: Versiegelst du direkt in ein Deployment (--out <web>/site/vault/), muss das Set auch in <web>/site/icon-sets/ liegen. Webfenster erreichen den Tresor-Ordner übrigens nie, auch nicht über Prozent-Kodierung im Pfad.

Offline und Installation

Mit pwa: { enabled: true } (Standard), HTTPS und dem Manifest-Link in index.html registriert der Desktop sw.js; installieren lässt er sich unter Einstellungen → Allgemein oder über das Browser-Menü. Mit offline.fastStart: true startet er aus der Offline-Kopie — das gilt für den Code des Desktops. Einige Sekunden nach dem Start vergleicht der Service Worker im Hintergrund jede Code-Datei mit dem Server, lädt bei Änderungen eine vollständige neue Kopie und bietet dann an, die Seite neu zu laden. Code wechselt so nur als Ganzes und mischt sich nie; ein Code-Update sehen Besucherinnen und Besucher erst einen Reload später.

Daten dagegen sind immer aktuell: Feeds (notify.feeds, auch außerhalb des Desktop-Ordners), die Glückskeks-Dateien und alles unter site/data/ und site/content/ holt der Service Worker immer zuerst vom Server (offline: die letzte Kopie). Die Prüfung im Hintergrund überspringt sie, ein neuer Feed-Eintrag erscheint also sofort und löst nie „Eine neue Version des Desktops ist bereit“ aus. Zum Code zählen dagegen site/config.js, site/apps.js, site/theme.css, Icon-Sets, Site-Module und Hintergrundbilder. Ein Pagefind-Bundle legst du außerhalb von site/ ab (z. B. pagefind/), sonst gilt jeder Neubau des Suchindex als neue Version.

pwa: { enabled: true },
offline: { maxPages: 80, timeoutMs: 4000, fastStart: true, legacyCaches: [] }   // fastStart: false = Netzwerk zuerst

Seit 1.4.0 kündigt der Desktop jede Änderung am Code an, die er ausführt — auch ein neues sw.js oder site/config.js, das den Cache-Namen behält (ein Kommentar, offline.timeoutMs, offline.legacyCaches). Findet die Prüfung eine Änderung, lässt sie den Browser zuerst sw.js vergleichen: Ein neuer Worker installiert sich sofort, die Prüfung hört auf — auch mitten im Laden der Kopie —, und die halb geladene Kopie wird gelöscht, nie als vollständig markiert. So wartet ein neuer Worker nie auf eine Kopie, die er ohnehin verwerfen würde. Antwortet der Server mit einem Serverfehler oder einem Rate-Limit (5xx, 408, 429), zählt das wie ein Netzwerkausfall, sodass nie ein Update mit der alten Fassung einer geänderten Datei fertig wird; Dateien mit no-store oder private dagegen fehlen einfach in der Kopie. Und die Kopie ergänzt sich selbst: Fehlen ihr Dateien — etwa weil ein anderer Service Worker deiner Website jeden Cache löscht, den er nicht kennt — oder erreichte eine Installation nicht jede Datei, vervollständigt sie die nächste Prüfung (mit fastStart, dem Standard). Hat der Server noch denselben Code, kommen die fehlenden Dateien still hinzu, ohne „neue Version“. Auch die Regionsdatei der Feiertage (holidays.region) gehört seit 1.4.0 zur Kopie, der Kalender zeigt Feiertage also auch offline.

Wechselst du fastStart, beginnt eine frische Offline-Kopie. pwa: { enabled: false } meldet einen vorhandenen Service Worker beim nächsten Besuch ab und löscht seine Caches — das klappt nur, solange sw.js hochgeladen bleibt, denn der Browser bemerkt die Änderung, indem er die Datei neu lädt.

Einen früheren Service Worker ablösen: Hatte deine Website schon vor dem Desktop einen Service Worker, trägst du dessen Cache-Namen in offline.legacyCaches ein — exakte Namen oder ein Präfix mit * am Ende (davor mindestens vier Zeichen, höchstens 32 Einträge):

offline: { legacyCaches: ['oldsite-shell-*', 'oldsite-pages'] }

Der Desktop löscht sie, wenn der neue Worker übernimmt, etwa 30 Sekunden später noch einmal, bei jedem Start und über Einstellungen → Zurücksetzen → „Offline-Kopien“; die eigenen Caches des Desktops rührt er dabei nie an. Seit 1.4.0 löscht nur der Worker, der gerade zuständig ist: Sobald ein neuerer installiert wird, wartet oder übernimmt, hört er damit auf — gehst du zurück zum alten Worker (alte Dateien, altes sw.js unter derselben URL), bleiben dessen Caches also stehen. Ersetzt wird der alte Worker nur, wenn sw.js denselben Scope hat oder unter der alten Skript-URL im Installationsordner liegt — sonst stellst du unter der alten URL einen Worker bereit, der sich selbst abmeldet. Trag nur Namen ein, die dein alter Code angelegt hat, denn ein Präfix trifft jeden Cache des Origins. Sobald keine Besucherinnen und Besucher der alten Version mehr zurückkommen, nimmst du die Einträge wieder heraus. Nötig ist das nur für Websites, die einen anderen Worker ablösen; die Einzelheiten stehen in docs/deploy.md §11 (öffnet in neuem Tab).

Veröffentlichen

Der Desktop läuft in der Wurzel oder in jedem Unterordner, ohne dass du eine Datei änderst — alle Pfade sind relativ zum Ordner mit der index.html. Hochladen: index.html, manifest.webmanifest, sw.js, assets/, locales/, site/, src/, LICENSE und CREDITS.md. Nicht hochladen: node_modules/, tools/, tests/, docs/, .git/, package.json, package-lock.json — die Server-Konfigurationen sperren nur Dotfiles.

Auf GitHub Pages

  1. Änderungen auf den Branch main pushen — beide Workflows reagieren nur auf Pushes nach main (CI zusätzlich auf Pull Requests; beide lassen sich von Hand starten). Nach einem direkten Klon legst du zuerst ein leeres Repository auf GitHub an (ohne README, ohne Lizenz), dann git remote set-url origin https://github.com/<user>/<repo>.git und git push -u origin main. Nach einem Download: git init -b main, git add -A, git commit -m "…", git remote add origin https://github.com/<user>/<repo>.git und git push -u origin main.
  2. Settings → Pages → Build and deployment → Source: „GitHub Actions“.
  3. Settings → Secrets and variables → Actions → Variables: PAGES = true. Ohne die Variable überspringt eine Kopie der Vorlage den Pages-Job stillschweigend.
  4. Actions → Pages → Run workflow oder erneut pushen. Ergebnis: https://<user>.github.io/<repo>/.

Weil GitHub Pages keine Header senden kann, setzt .github/workflows/pages.yml die Basis-CSP als <meta http-equiv> direkt hinter die einzige Zeile <meta charset="utf-8"> in index.html. Ohne frame-ancestors, Permissions-Policy, HSTS und die übrigen Sicherheits-Header ist das der schwächere Weg. Schaltest du Online-Dienste ein, trägst du ihre Hosts (und ggf. 'wasm-unsafe-eval' für Pagefind bzw. frame-src-Origins) zusätzlich in die CSP-Zeile von pages.yml ein. Die Meta-CSP schützt nur die Desktop-Seite selbst, nicht den Service Worker oder andere Seiten auf demselben Origin. Der Workflow veröffentlicht den ganzen Checkout außer .git, .github, node_modules, tests und tools — anders als beim eigenen Server also auch docs/, package.json, package-lock.json und die READMEs (nichts davon ist geheim).

Auf dem eigenen Server

Für jeden unterstützten Server liegt eine fertige Konfiguration in docs/server/:

Server Datei Ziel Prüfen und neu laden
Apache 2.4.10+ apache.htaccess .htaccess im Ordner der index.html wirkt sofort; apachectl configtest prüft nur die vhost-Konfiguration (AllowOverride), ein Fehler in der .htaccess zeigt sich als 500 im Error-Log
nginx 1.19+ nginx.conf /etc/nginx/conf.d/desktop.conf nginx -t && nginx -s reload
Caddy 2.7+ Caddyfile /etc/caddy/Caddyfile caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy
Ferron 3 (Release Candidate, geprüft mit 3.0.0-rc.9) ferron.conf /etc/ferron/ferron.conf ferron validate -c /etc/ferron/ferron.conf, dann neu starten
Ferron 2 (stabile Linie, geprüft mit 2.8.1) ferron.kdl /etc/ferron.kdl neu starten
static-web-server 2.x static-web-server.toml /etc/static-web-server/config.toml static-web-server --config-file /etc/static-web-server/config.toml

Anzupassen sind jeweils nur Hostname, Root und Zertifikatspfade; bei Apache gar nichts, dort braucht der vhost aber AllowOverride FileInfo Indexes Options=Indexes (sonst antwortet Apache mit 500). Jede Konfiguration sendet diese Basis-Policy und dazu Permissions-Policy, X-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, Cross-Origin-Opener-Policy: same-origin und HSTS:

Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' blob:; frame-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests

Außerdem leiten alle Konfigurationen /desktop auf /desktop/ weiter, listen keine Verzeichnisse, liefern .js als text/javascript und senden Cache-Control: no-cache für HTML, JS, CSS, JSON, Manifest und sw.js (Bilder, Schriften und Medien: public, max-age=86400). Seit 1.3.0 setzt die nginx-Konfiguration dazu expires off;, damit ein expires aus dem http { }-Block keinen zweiten Cache-Control-Header samt Expires an den Code hängt; die Apache-.htaccess schaltet mod_expires mit ExpiresActive Off ab. Änderungen an Feeds und an Dateien unter site/data/ und site/content/ erreichen Besucherinnen und Besucher sofort, Code-Änderungen einen Reload später. Bringt ein Deployment ein neues Icon mit, lädst du deshalb erst den Code hoch, der das Icon kennt, und dann die Daten, die es nennen. Ein Deployment prüfst du mit:

curl -sI https://example.org/desktop/ | grep -iE '^(content-security|permissions|cache-control|x-|strict|referrer|cross-origin)'
curl -sI https://example.org/desktop | grep -iE '^(HTTP|location)'
curl -sI https://example.org/desktop/src/boot/main.js | grep -i '^content-type'
curl -s -o /dev/null -w '%{http_code}\n' https://example.org/desktop/site/vault/
curl -s -o /dev/null -w '%{http_code}\n' https://example.org/desktop/.git/config

Eine bestehende Website aktualisieren

Den neuen Stand holst du in einer Git-Kopie mit git remote add upstream https://github.com/JPKCom/jpkcom-desktop.git && git fetch upstream --tags und übernimmst die Dateien gezielt per git checkout v1.4.0 -- src sw.js locales tools package.json package-lock.json .npmrc .github/workflows — das klappt auch bei Kopien aus der Vorlage ohne gemeinsame Historie. Ohne Git lädst du https://github.com/JPKCom/jpkcom-desktop/archive/refs/tags/v1.4.0.zip herunter. Lies danach die „Upgrade notes“ jedes übersprungenen Release in CHANGELOG.md — kommst du von 1.0.x, gehören die Schritte für 1.1.0 dazu (site/theme.css, zwei neue Zeilen in index.html, Node.js 24). Anschließend npm run icons, npm run preload, npm run i18n:check, npm run validate und npm test ausführen. tests/ übernimmst du nur, wenn du die Tests der Beispiel-Website danach erneut anpasst.

Für 1.4.0 im Einzelnen:

  1. Das neue sw.js mit hochladen: Der Cache bekommt einmalig einen neuen Namen (neue Version), Besucherinnen und Besuchern wird ein Reload angeboten. Mehr braucht es für die Korrekturen nicht; src/boot/preload.js ist unverändert.
  2. site/config.js: Der Kommentar zu namespace hat sich geändert (eine Empfehlung) — übernimm ihn, wenn du eine eigene Datei pflegst.
  3. Mehrere Desktops auf einem Origin, deren Namespaces sich überschneiden (jpkdesk und jpkdesk-next): Bring jeden davon auf 1.4.0 — ein Desktop mit 1.3.0 oder älter zählt die Schlüssel des längeren Namespace noch zu seinen eigenen und löscht sie mit „Alles zurücksetzen“. Für neue Installationen wählst du Namespaces, bei denen keiner der andere plus -… ist (desk-a, desk-b); ein geänderter Namespace startet mit leeren Einstellungen.
  4. Site-Module, die eine Datei mit berechnetem Namen importieren (import(`./parts/${name}.js`)), können diese Dateien im neuen Deskriptorfeld precache: [...] aufführen, damit sie zur Offline-Kopie gehören; ohne das Feld kommen sie wie bisher beim ersten Laden online hinein.
  5. Eigener Code, der jeden localStorage-Schlüssel mit <namespace>- am Anfang gelesen hat, nimmt Desk.store.names() oder Desk.store.owns(key); Module mit Schlüsseln außerhalb ihrer storage-Deklaration melden sie mit Desk.store.claim() an.

Kommst du von 1.2.x, gilt zusätzlich für 1.3.0:

  1. Das neue sw.js mit hochladen: Der Cache bekommt einmalig einen neuen Namen, Besucherinnen und Besuchern wird ein Reload angeboten.
  2. npm run preload einmal ausführen, wenn du ein eigenes src/boot/preload.js pflegst — die Vorhersage der Startsprache darin folgt jetzt matchLanguage() aus src/core/i18n.js.
  3. Optional die kommentierten Einträge für iconReplace, notify.label und reader (keepStyles, styleScope, styleVars) sowie die überarbeiteten Kommentare zu search.pagefind und fortune.texts in dein site/config.js übernehmen. Ohne sie gelten die Standards.
  4. In eigenen Sprachordnern notify.meta ergänzen.
  5. nginx: expires off; neben add_header Cache-Control $desk_cache_control; in den Server-Block setzen. Apache (optional): den neuen Block <IfModule mod_expires.c> mit ExpiresActive Off aus docs/server/apache.htaccess übernehmen — AllowOverride FileInfo Indexes Options=Indexes deckt ihn bereits ab.
  6. Pagefind: 'wasm-unsafe-eval' in der Policy des Desktops lassen, solange search.pagefind gesetzt ist, auch wenn dein Bundle ohne Policy ausgeliefert wird.
  7. Override-Einträge wie { id: '<alias>', desktop: false }, die doppelte Symbole von Aliasen verhindern sollten, kannst du löschen. Gemerkte Dock-Listen mit Alias-IDs schreibt der Desktop beim ersten Start einmal um.
  8. Eigenes CSS, das die Tresor-Zeile unter Zurücksetzen versteckt hat (etwa .set-row:has(> input[data-key="rs-vault"])), entfernen — es würde die Zeile jetzt auch im entsperrten Zustand verbergen. Eigener Code mit storage.resetGroups() bekommt nur noch die angezeigten Gruppen; { all: true } liefert alle.
  9. Reader: Code-Blöcke mit Farben als style="color:…" zeigen diese jetzt; reader.keepStyles: false stellt den alten Stand her.
  10. Eigener Code mit i18n.displayName(code, inLang) bekommt den Namen jetzt in der Sprache inLang; für die Eigenbezeichnung rufst du displayName(code) auf.

Kommst du von 1.1.x, gilt zusätzlich für 1.2.0:

  1. Das neue sw.js mit hochladen: Der Cache bekommt einmalig einen neuen Namen, Besucherinnen und Besuchern wird ein Reload angeboten. index.html, manifest.webmanifest und die Workflows haben sich nicht geändert.
  2. npm run preload ausführen — der Boot-Graph enthält jetzt src/core/man.js und src/core/icon-sets.js, und preload:check in der CI braucht die neue Datei.
  3. Optional die neuen, kommentierten Schlüssel in dein site/config.js übernehmen: iconSets, fortune.local und fortune.texts, offline.legacyCaches. Ohne sie gelten die Standards, und nichts ändert sich.
  4. In eigenen Sprachordnern fortune.noSource, fortune.denyOnline, terminal.noManualPage und terminal.manOpen ergänzen.
  5. Hast du offline.fastStart: false nur wegen der Feeds gesetzt, kannst du das wieder entfernen.
  6. Laufzeitdaten gehören nach site/data/, Skripte nicht nach site/data/ oder site/content/, ein Pagefind-Bundle nicht nach site/.
  7. Ein webUrl, das dem basePath der Sammlung gleicht, öffnet jetzt einen neuen Tab. Soll die Übersicht in einem Fenster aufgehen, ergänzt du eine versteckte Web-App und webApp.
  8. scope-Werte, die mit / beginnen, wurden bisher ignoriert und wirken jetzt.
  9. Site-Module, die <use href="#ti-…"> selbst schreiben, stellst du auf Desk.icons.icon() oder Desk.icons.symbolHref() um.
  10. terminal.manUrl gilt nicht mehr für die Lesezeichen des Tresors. Empfohlen: .md/.txt aus docs nach man verschieben und docs auf die Seite zeigen lassen.
  11. npm run validate:strict zeigt womöglich neue Warnungen (z. B. fehlende Handbuchdateien oder webUrl gleich basePath).
  12. Besucherinnen und Besucher, die der Online-Quelle des Glückskekses zugestimmt haben, werden einmal neu gefragt.
  13. site/icon-sets/* in deine .gitignore aufnehmen — oder in einem privaten Repository, das sein Set mit versioniert, !site/icon-sets/<name>.json.

Sicherheitskorrekturen gibt es nur für das jeweils neueste Release der neuesten Minor-Version (derzeit 1.4.x); 1.3.x, 1.2.x, 1.1.x und 1.0.x werden nicht mehr unterstützt.

Entwickeln und prüfen

Befehl Zweck
npm run serve Testserver mit Produktions-Headern
npm test Unit-Tests (node --test "tests/*.test.mjs")
npm run validate / validate:strict site/apps.js gegen site/config.js, inklusive Site-Icon-Sets, Handbuchseiten, scope/linkPaths
npm run i18n:check Alle Sprachen gegen Englisch, inklusive site/modules/*/locales/
npm run icons / icons:check Icon-Teilmenge src/icons/tabler.js erzeugen bzw. prüfen (nur Tabler, Site-Icon-Sets baut es nicht)
npm run preload / preload:check src/boot/preload.js erzeugen bzw. prüfen
npm run icons:pwa PNG-Icons aus favicon.svg und maskable.svg rendern
npm run browsers Headless-Chromium für Icons und Browser-Checks holen
npm run check:browser Desktop im Headless-Browser laden; scheitert an Konsolen-, CSP- und Request-Fehlern
npm run seal Tresor-Datei versiegeln; Tresor-Icons dürfen aus Site-Icon-Sets stammen

Die CI (.github/workflows/ci.yml) führt auf Node 24 nach npm ci --ignore-scripts und npm audit signatures aus: npm test, npm run i18n:check, npm run icons:check, npm run preload:check und node tools/validate-manifest.mjs. Die .npmrc setzt ignore-scripts=true, save-exact=true und engine-strict=true; installierende Befehle laufen am besten über sfw.

Wichtig für eigene Kopien: Einige Tests prüfen die mitgelieferte Beispiel-Website und schlagen fehl, sobald du sie ersetzt — der Block „The example site“ am Ende von tests/p11-site.test.mjs, in derselben Datei der Test „validator CLI: the example site has no errors and no warnings“ (läuft mit --strict — behebe lieber die Warnungen als den Test), „manifest: … brand defaults“ in tests/p12-deploy.test.mjs, tests/site-hello.test.mjs, sobald du die Beispiel-App hello entfernst oder umbenennst, in tests/theming.test.mjs die Prüfung „site/theme.css … ships without rules“ samt den Literal-Wächtern über site/theme.css und site/modules/ und in tests/hygiene.test.mjs der Test „hygiene: no file in site/icon-sets/ is tracked by git“ — er schlägt in einem privaten Site-Repository fehl, das sein Icon-Set mit versioniert, und prüft außerdem, dass die .gitignore site/icon-sets/* enthält. Der Validator-Test scheitert seit 1.2.0 auch an fehlenden Handbuchdateien und den übrigen neuen Warnungen. Pass die Tests an deine Website an oder lösche sie, sonst bleibt auch die CI deiner Kopie rot.

Lizenz und Brand-Assets

Der Code steht unter der MIT-Lizenz (© 2026 Jean Pierre Kolb) — einschließlich der Bausteine, die Logos und Symbole zeichnen, der Fensterknopf-Glyphen und der neutralen Hintergrundmotive. Ausgenommen sind das JPK-Monogramm und das JPKCom-Logo (© 1996–2026, alle Rechte vorbehalten, keine eingetragene Marke): die Glyphe jpk, das Logo jpkcom, das Standard-asciiLogo, assets/icons/favicon.svg und maskable.svg samt der daraus gerenderten PNGs sowie die Motive author-monogram und author-emblem. Unverändert als Standard-Marke zeigen darfst du sie, auch in Forks und Deployments, die noch nicht umgestaltet sind. Als eigenes Logo, für andere Projekte oder verändert verwenden darfst du sie ohne Erlaubnis nicht. Die Icons sind eine erzeugte Teilmenge der Tabler Icons (MIT).

Tipps & Tricks

  • Mehrere Desktops auf einem Origin: Gib jedem einen eigenen namespace (Standard 'jpkdesk', Muster [a-z][a-z0-9-], höchstens 24 Zeichen). Er ist das Präfix jedes localStorage-Schlüssels, IndexedDB-Namens, Cache-Namens und DOM-Events. Wähl sie am besten so, dass keiner der andere plus -… ist — desk-a und desk-b, nicht jpkdesk und jpkdesk-next. Seit 1.4.0 trennt ein Desktop seine Schlüssel von denen eines solchen längeren Namespace, sobald der einen Schlüssel gespeichert hat, den er selbst auch kennt (eine Einstellung, eine Zustimmung), und lässt sie bei „Alles zurücksetzen“ und in der Speicheranzeige aus; ältere Versionen tun das nicht. Im Zweifel behält die Regel einen Schlüssel lieber, als einen fremden zu löschen. Der Namespace verhindert nur Kollisionen, keinen Zugriff: Alle Pages-Websites eines Kontos teilen sich https://<user>.github.io und können gegenseitig ihre Daten lesen, auch einen gemerkten Tresor-Schlüssel — dort hostest du nur vertrauenswürdigen Code.
  • Eigene Inhalte privat, Projekt öffentlich: Dein site/ kann in einem privaten Repository liegen, und ein kleines Build-Skript nimmt genau ein festgelegtes, getaggtes Release des öffentlichen Projekts und legt dein site/ dazu — so landet nichts Privates im öffentlichen Repository. Ein Update probierst du zuerst in einem eigenen Ordner mit Testdaten aus und aktualisierst danach den echten. So habe ich jpkc.com/desktop auf 1.4.0 aktualisiert: Lesezeichen (jetzt im verschlüsselten Tresor), der Zugang zu meinen Werkzeugen sowie Notizen, Aufgaben und Dock früherer Besuche sind geblieben.
  • Gleicher Origin heißt volles Vertrauen: Eine web-App auf dem Origin des Desktops kann gespeicherte Daten und einen gemerkten Tresor-Schlüssel nutzen — auch ein Unterordner trennt nichts. Gerahmte Seiten desselben Origins erreichen außerdem parent.JPKDesk und Zustände im Speicher, etwa einen entsperrten Tresor. Fremdes HTML gehört auf einen eigenen Origin (per frame-src erlaubt) oder bekommt sandbox: 'allow-scripts', nie zusammen mit allow-same-origin — und solche Web-Apps bekommen kein linkPaths.
  • Beispiel-Verweise mitziehen: Einige Schlüssel verweisen auf Apps der Beispiel-Website — site.legal: ['imprint', 'privacy'], notify.app: 'changelog' und site.defaultPageApp (Standard 'about', muss auf eine vorhandene page-App zeigen). Löschst du diese Apps aus site/apps.js, passe die Schlüssel an, sonst meldet npm run validate Fehler. Dasselbe gilt für die Alias-Einträge jpkcom-github und jpkcom-mastodon der Lesezeichen-Sammlung: Sie zeigen auf die aus author.links erzeugten Apps author-github und author-mastodon und müssen weg, sobald du author.links änderst oder leerst. Und der Beispiel-Feed site/data/feed.<lang>.json ist der Changelog von JPKCom Desktop — ersetze ihn durch deinen eigenen oder nimm 'notify' aus modules.
  • Routing ohne Code: site.routes leitet Links auf demselben Origin um, z. B. { match: '^/demo/?$', app: 'demo' }, { prefix: 'downloads/', tab: true } oder { match: '^/docs/', page: true }. Eine page-App mit Ordner-URL (page('docs/')) öffnet jede Seite darunter im selben Fenster — der Ordner braucht eine index.html.
  • Icons als vollständige Literale: npm run icons findet nur IDs wie 'ti-…' oder 'tif-…', die vollständig im Quelltext stehen; zur Laufzeit zusammengesetzte Namen bleiben leer. Icons, die nur in versiegelten Tresor-Daten vorkommen, kommen als JSON-Array in site/icons.json. IDs aus Site-Icon-Sets betrifft das nicht (kein Neubau); Tresor-Icons aus einem Set müssen aber in der Set-Datei stehen.
  • Reader-Seiten sind bereinigt: Skripte, Styles, style-Attribute, Formulare, iframes und Event-Handler fliegen raus; Klassen bekommen das Präfix c-. Eigene Stile für Seiteninhalte schreibst du deshalb als .reader-page .c-name.
  • Code-Farben im Reader: Die eine Ausnahme bei style-Attributen (seit 1.3.0) betrifft Syntax-Highlighting. Innerhalb von reader.styleScope (Standard pre, code) behält der Reader color, background-color, font-style, font-weight und text-decoration-line mit einfachen Farbwerten, verwirft Farben unter 2:1 Kontrast und setzt alles selbst per CSSOM — den Style-Text der Seite übernimmt er nie. reader.keepStyles: false schaltet das ab. Für Highlighter mit zwei Themes wie Shiki behält reader.styleVars: '--shiki-' auch deren Custom Properties; die dunkle Variante schaltest du dann mit einer !important-Regel in einem Stylesheet deiner Website um, für Text und Hintergrund gemeinsam (Beispiel in den Upgrade-Hinweisen zu 1.3.0 im Changelog).
  • Glückskeks-Dateien: site/data/fortunes/<lang>.json enthält lang, by, categories und items — nur reiner Text, höchstens 1000 Zeichen pro Spruch, in jeder Sprache dieselben Kategorie-IDs. Sprachen ohne Datei lässt du aus fortune.langs weg, dann gibt es keine 404. Mit fortune.local: false brauchst du gar keine Dateien, und fortune.texts passt die Texte einer umbenannten App an.
  • Mitteilungs-Feeds: JSON Feed 1.1 je Sprache auf demselben Origin; jeder Eintrag braucht einen Titel, ein date_published in der Vergangenheit und eine URL auf demselben Origin. Mit notify.app: null zeigt jedes Banner seit 1.3.0 Kachel und Namen der App, in der sein Artikel aufgeht; notify.label (Text oder { lang: text }, höchstens 60 Zeichen) stellt dem einen Namen voran, etwa „News · Blog“. Ein fester notify.app sieht aus wie bisher.
  • Nie lange Cache-Zeiten für Code: ES-Module importieren sich gegenseitig über ihren Namen. Ein langes Expires oder max-age von Host oder CDN auf .js, .css, .json, .html oder sw.js mischt alte und neue Dateien und zerlegt den Desktop. Bei nginx verhindert das expires off;, bei Apache ExpiresActive Off — beides steht in den mitgelieferten Konfigurationen.
  • Service Worker in der Wurzel: Dort sieht er jede Seite der Website. Persönliche oder angemeldete Seiten markierst du mit Cache-Control: no-store oder private, damit er sie nicht aufbewahrt.
  • Fehlerbilder schnell zuordnen: Leerer Desktop mit „MIME type“-Fehler → falscher Typ für .js. Leer unter /desktop, aber nicht unter /desktop/ → Weiterleitung fehlt (bei nginx kein try_files $uri $uri/ ergänzen). Pagefind-WebAssembly-Fehler → 'wasm-unsafe-eval' fehlt. Kein Installationsangebot → kein HTTPS, kein Manifest-Link oder pwa.enabled: false. „Eine neue Version des Desktops ist bereit“ nach der Änderung einer Datendatei → die Datei liegt außerhalb der Datenordner; Laufzeitdaten gehören nach site/data/ oder site/content/ (Modulordner, site/apps.js, site/theme.css und Hintergrundbilder sind absichtlich Code). „Eine neue Version des Desktops ist bereit“ nach jedem Neubau des Suchindex → das Pagefind-Bundle liegt in site/; verschieb es nach pagefind/.
  • Server-Eigenheiten: Bei nginx entfernt ein add_header innerhalb eines location-Blocks alle Header der Server-Ebene. Bei Caddy gehören keine Zeilen mit - oder ? in den Haupt-header-Block, sonst gehen Fehlerantworten ohne Sicherheits-Header raus. Hinter einem TLS-Proxy stellst du bei Apache die %{HTTPS}-Bedingungen auf X-Forwarded-Proto um.
  • Browser-Check mit Szenario: npm run check:browser -- --lang de-DE --mobile --screenshot /tmp/desk.png prüft die Smartphone-Ansicht auf Deutsch. Eigene Szenarien: export default async ({ page, desk, log, assert }) => { … }; --route pfad=datei liefert für einen Check eine lokale Datei aus, etwa ein Site-Modul, das nicht im Projekt liegt. Auf Rechnern mit wenig RAM serialisierst du Läufe mit flock /tmp/jpkcom-desktop-browser.lock node tools/browser-check.mjs ….
  • Theme prüfen: Nach jeder Änderung hell und dunkel umschalten, eine dunkle Insel (Menüleiste, Terminal) ansehen, die Smartphone-Ansicht (body.compact) und den Modus mit erzwungenen Farben kontrollieren. --ring-focus, --ring-selected, --shadow-control-edge und --shadow-pressed bleiben sichtbar — Fokus und Bedienelement-Kanten brauchen 3:1 Kontrast (WCAG 1.4.11).

Weiterführende Informationen