Dein Theme endete in der Desktop-App: Hermes speichert UI-Einstellungen jetzt pro Maschine


Du stellst in der Desktop-App das Theme auf dunkel, schaltest die Statusleiste auf kompakt, ziehst die Seitenleiste eine Stufe enger. Dann öffnest du die Webapp im Browser auf derselben Maschine und bekommst die Standardpalette und das Standardlayout zurück, als wäre die letzte Minute nie passiert. Kaputt war nichts: gleiche Maschine, gleiche config.yaml, gleiche Sessions auf demselben Gateway. Nur die Oberflächen-Einstellungen waren getrennte Wege gegangen. Das ist es, was #145536 (gemergt am 27. Oktober 2026) behebt — Einstellungen sind nicht mehr Privateigentum eines Tabs, sondern eine Datei auf der Maschine.

localStorage ist ein Fach pro Browser und pro Origin

Erst die Voraussetzung, sonst wirkt der Bug wie Magie.

localStorage ist ein kleines Key-Value-Fach, das der Browser aushändigt — aber nicht eins pro Website, sondern eins pro Origin. Ein Origin ist Schema plus Host plus Port. Ändere eines von den dreien, und der Browser hält es für eine andere Seite mit einem frischen, leeren Fach. Derselbe Dienst unter http://127.0.0.1:9120, unter einer LAN-IP und unter public_url sind für den Browser drei unabhängige Origins und drei Sätze von Einstellungen, die sich nicht sehen. Das ist kein Hermes-Defekt; das ist das Isolationsmodell des Browsers, das genau das tut, was draufsteht.

Und ein Hermes hat meist mehr als eine Adresse. Vor dieser Änderung war ein einzelner Satz Oberflächen-Einstellungen also in mehrere zerlegt: einen pro Gerät, einen pro privatem Fenster (ein privates Fenster ist ein sauberes Fach), einen pro Webapp-Adresse — plus einen weiteren für die Desktop-App, die gar kein Browser ist und ihren eigenen Speicher führt. Du dachtest, du stellst „meine Oberfläche“ ein. Du hast „die Oberfläche dieses Browsers, an dieser Adresse, in diesem Fenster“ eingestellt.

Wenn du die Webapp noch nicht laufen hattest: den ganzen Workspace in einen Browser-Tab erklärt, woher diese Adressen kommen und was host und public_url jeweils tun.

Jetzt eine Datei, und sie gehört der Maschine

Es bleibt genau ein Ort: ui-preferences.json im Hermes-Home der Maschine, auf der die App läuft. Das native Desktop auf dieser Maschine und jeder Browser, der auf ihre Webapp zeigt, lesen und schreiben dieselbe Datei.

Der Eigentümer ist die Maschine, und das lohnt, präzise zu sagen: nicht ein Store pro Gateway-Verbindung und nicht einer pro Profil. Ein Desktop, das gleichzeitig an ein lokales und ein entferntes Gateway angebunden ist, hat trotzdem einen einzigen Satz Einstellungen.

Und die Profile? Ein Desktop kann problemlos aus einem Profil-Home starten. Die Regel ist, dass ein übergebener Pfad zuerst bis zu seinem Root-Home hochgelaufen wird und der Store unter diesem Root landet; Pythons store_path() und Electrons uiPreferencesRoot(resolveDesktopHermesHome()) setzen dieselbe Regel um, und beide lösen Symlinks auf — ein Profil-Home oder ein symlinktes Profilverzeichnis fällt also auf das Root zurück und wird nie zu einem Store pro Profil. Das ist kein Versprechen in einem Kommentar: Ein vitest-Contract-Test lässt den echten Python-Resolver und den echten Electron-Resolver nebeneinander über ein Profil-Home, ein Custom-Home und ein symlinktes Profilverzeichnis laufen und schlägt fehl, wenn sie sich uneinig sind. Wenn du abwägst, wann du wirklich mehr als ein Profil willst, geht die Abwägung einer Instanz pro Profil tiefer — die Oberflächen-Einstellungen reisen nur nicht mehr mit.

Noch etwas, das viele falsch herum annehmen: Ein Desktop, das an ein entferntes Gateway angebunden ist, nutzt trotzdem den Store seiner eigenen Maschine. Einstellungen folgen der Maschine vor dir, nicht dem Gateway.

Vorher und nachher

Vorher Jetzt
Webapp auf einem zweiten Gerät oder in einem privaten Fenster Standardwerte die Einstellungen dieser Maschine
Die drei Adressen: 127.0.0.1, LAN-IP, Public URL drei Sätze ein Satz
Natives Desktop und Webapp auf derselben Maschine je einer geteilt, in beide Richtungen
Nach einer Änderung in einem anderen Fenster oder Tab Reload nötig; über Origins hinweg nie sichtbar natives Fenster in rund 2 s; Webapp-Tab bei Fokus oder innerhalb von 10 s; reaktive Einstellungen zeichnen live neu
Desktop an ein entferntes Gateway angebunden nicht zutreffend weiterhin der Store der eigenen Maschine
Erster Start nach dem Upgrade — jede App bzw. jeder Browser importiert eigene Werte, die im Store fehlen; fügt nur hinzu, überschreibt nichts

Was in der Datei steht, und warum zwei Schreibvorgänge sich nicht gegenseitig plattmachen

Der Vertrag auf der Platte ist eine Form, geteilt von hermes_cli/ui_preferences.py auf der Python-Seite und apps/desktop/electron/ui-preferences.ts auf der Electron-Seite.

{
  "version": 1,
  "generation": 1,
  "revision": 12,
  "values": {}
}

values bildet Schlüssel auf Strings ab. Schlüssel müssen mit hermes.desktop., hermes-desktop- oder hermes-onboarding- beginnen, und ihre Länge ist begrenzt — diese Datei ist keine allgemeine Ablage für alles Mögliche. Ein null-Wert löscht den Schlüssel.

Ein Schreibvorgang ist nicht „die ganze Datei lesen, ein Feld ändern, die ganze Datei zurückschreiben“. Es ist ein Merge pro Schlüssel unter einem Directory-Lock: Der Lock ist das Verzeichnis <store>.lock, genommen mit mkdir, was unter Node, unter Python und auf jeder Plattform atomar ist, ohne OS-spezifische Verzweigung. Das Warten endet nach spätestens 3 Sekunden, mit Polling alle 0,02 Sekunden, und ein Lock, das älter als 10 Sekunden ist, wird kurzerhand gebrochen, damit ein abgeschossener Prozess den Store nicht für immer verkeilt. Nach dem Merge inkrementiert revision, und die Datei wird über eine Temp-Datei plus Rename mit Modus 0600 ersetzt. Zwei Fenster, die gleichzeitig zwei verschiedene Schalter umlegen, können sich also nicht gegenseitig auslöschen.

Die Definition von „kein Store“ ist bewusst eng: Nur eine fehlende Datei zählt. Jeder andere Lesefehler — Rechte, Platte, Encoding — verweigert den Schreibvorgang (503 in der Webapp, und der Client versucht es erneut), statt so zu tun, als wäre der Store leer, und auf einem Nichts zu mergen. Diese Unterscheidung ist wichtiger, als sie aussieht: „kann nicht lesen“ als „da ist nichts“ zu behandeln ist der klassische Weg, Daten zu verlieren.

Eine kaputte Datei wird auch nicht stillschweigend verworfen: Der Schreibvorgang, der sie ersetzt, kopiert das Original zuerst nach ui-preferences.json.corrupt-<ms>, und dieser Schreibvorgang beginnt eine neue Generation.

Zwei Sekunden, zehn Sekunden oder sofort

Der Unterschied ist, wer zuhört.

Auf der nativen Seite gibt es eine typisierte Preload-Fähigkeit, window.hermesDesktop.uiPreferences: einen Snapshot, der im ersten Frame synchron verfügbar ist, read, ein validiertes write und onChanged. Der Main-Prozess schiebt Änderungen in jedes Fenster, auch in die, die der Python-Prozess der Webapp geschrieben hat — er merkt es über einen billigen fs.watchFile-Stat, ohne JSON-Parsing in der Polling-Schleife. Eine allgemeine Dateisystem-Tür gibt es hier nicht; die exponierte Fläche ist schmal und typisiert. Die Handler sind in main.ts registriert, direkt neben dem First-Frame-Read für Local Skin (der nach local-skin.ts gewandert ist und main.ts sieben Zeilen kürzer zurücklässt).

Die Webapp-Seite ist schlichtes HTTP: GET /api/ui-preferences und PATCH /api/ui-preferences. Die Route existiert nur auf der Webapp-Oberfläche (SurfacePolicy.ui_preferences); unter serve, dashboard und gateway ist sie ein 404. Sie liegt außerdem hinter dem bestehenden Session-Token- und Sign-in-Gate, eine unauthentifizierte Anfrage bekommt also ein 401.

Daraus ergeben sich die Zeitskalen: ein natives Fenster in etwa 2 Sekunden; ein Webapp-Tab bei Fokus oder innerhalb von 10 Sekunden; und die reaktiven Einstellungen — Theme, Modus, Dichte, Statusleiste — zeichnen live neu, statt auf ein Polling zu warten. Das alte Problem „über Origins hinweg nie sichtbar“ ist damit mit erledigt.

Der erste Start nach dem Upgrade

Das Upgrade wirft deine alten Einstellungen nicht weg und überschreibt auch nicht, was schon in der neuen Datei steht. Jede App bzw. jeder Browser importiert die Werte, die er hat und die im Store fehlen: nur hinzufügen, nie einen bestehenden Wert umschreiben, und die lokale Kopie nicht löschen, bevor der Import gelandet ist. Das Upgrade bleibt damit unsichtbar — die Desktop-App sieht aus wie hinterlassen, und die Webapp ist beim ersten Öffnen nicht mehr in Standardfarben. Zum Startablauf selbst, inklusive was er dich fragt und was nicht, siehe den First-Run-Guide für Desktop.

Was es nicht macht

Es ist kein Cloud-Sync. Kein Account, kein Relay, kein Server dazwischen — eine andere Maschine hat weiterhin einen anderen Satz Einstellungen, weil die Datei auf jener Maschine liegt. Der Umfang, den es löst, ist eng und konkret: Auf einer Maschine sollen mehrere Fenster, Browser und Adressen einer Person eine Oberfläche zeigen.

Wissenswert: Das ist weiterhin eine Klartext-JSON-Datei auf der Platte, Modus 0600, unter denselben Dateirechten wie alles andere im Hermes-Home. Wer dieses Home lesen kann, kann deine Oberflächen-Einstellungen lesen.

Und was das Locking und das Mergen angeht — am besten nutzt man sie, indem man nie an sie denkt. Theme setzen; die Schreibvorgänge stellen sich von selbst hinter dem Lock an.