Votre thème s’arrêtait à l’app desktop : Hermes stocke désormais les réglages d’interface par machine

Vous passez le thème en sombre dans l’app desktop, vous réglez la barre de statut en compact, vous resserrez la sidebar d’un cran. Puis vous ouvrez la webapp dans un navigateur sur la même machine et vous retrouvez la palette et la disposition par défaut, comme si cette dernière minute n’avait jamais eu lieu. Rien n’était cassé : même machine, même config.yaml, mêmes sessions sur le même gateway. Seuls les réglages d’interface étaient partis chacun de leur côté. C’est ce que corrige #145536 (fusionnée le 27 octobre 2026) — les préférences cessent d’être la propriété privée d’un onglet et deviennent un fichier sur la machine.
localStorage est un casier par navigateur et par origine
D’abord la prémisse, sinon le bug ressemble à de la magie.
localStorage est un petit casier clé-valeur que le navigateur alloue, mais il n’est pas par site : il est par origine. Une origine, c’est un schéma plus un hôte plus un port. Changez l’un des trois et le navigateur y voit un autre site, avec un casier neuf et vide. Le même service ouvert sur http://127.0.0.1:9120, sur une IP du LAN et sur public_url, ce sont trois origines sans rapport pour le navigateur, et trois jeux de réglages qui ne se voient pas. Ce n’est pas un défaut de Hermes ; c’est le modèle d’isolation du navigateur qui fait exactement ce qu’il annonce.
Et un Hermes a généralement plus d’une adresse. Avant ce changement, un seul jeu de préférences d’interface était donc découpé en plusieurs : un par appareil, un par fenêtre privée (une fenêtre privée est un casier propre), un par adresse de webapp — plus un pour l’app desktop, qui n’est pas un navigateur du tout et garde son propre stockage. Vous croyiez régler « mon interface ». Vous régliez « l’interface de ce navigateur, à cette adresse, dans cette fenêtre ».
Si vous n’avez pas encore lancé la webapp, mettre tout l’espace de travail dans un onglet de navigateur explique d’où viennent ces adresses et ce que font host et public_url.
Un seul fichier désormais, et il appartient à la machine
Il ne reste qu’un emplacement : ui-preferences.json à la racine du home Hermes de la machine qui fait tourner l’app. Le Desktop natif de cette machine et n’importe quel navigateur pointé sur sa webapp lisent et écrivent le même fichier.
Le propriétaire, c’est la machine, et cela vaut la peine de le dire précisément : pas un store par connexion au gateway, et pas un par profil. Un Desktop relié en même temps à un gateway local et à un gateway distant n’a toujours qu’un seul jeu de préférences.
Et les profils ? Un Desktop peut très bien se lancer depuis un home de profil. La règle est qu’un chemin passé est d’abord remonté jusqu’à son home racine, et que le store atterrit sous cette racine ; le store_path() de Python et le uiPreferencesRoot(resolveDesktopHermesHome()) d’Electron implémentent la même règle, et tous deux résolvent les symlinks — un home de profil, ou un répertoire de profil symlinké, se replie donc sur la racine et ne devient jamais un store par profil. Ce n’est pas une promesse en commentaire : un test de contrat vitest fait tourner le vrai résolveur Python et le vrai résolveur Electron côte à côte sur un home de profil, un home personnalisé et un répertoire de profil symlinké, et échoue dès qu’ils divergent. Si vous vous demandez quand vous voulez vraiment plus d’un profil, les compromis d’une instance par profil va plus loin — simplement, les réglages d’interface ne voyagent plus avec eux.
Une chose que les gens comprennent à l’envers : un Desktop connecté à un gateway distant utilise quand même le store de sa propre machine. Les réglages suivent la machine devant vous, pas le gateway.
Avant et après
| Avant | Maintenant | |
|---|---|---|
| Webapp sur un second appareil ou en fenêtre privée | valeurs par défaut | les réglages de cette machine |
Les trois adresses : 127.0.0.1, IP du LAN, URL publique |
trois jeux | un seul jeu |
| Desktop natif et webapp sur la même machine | un chacun | partagés, dans les deux sens |
| Après une modification dans une autre fenêtre ou un autre onglet | rechargement nécessaire ; entre origines, jamais visible | fenêtre native en ~2 s ; onglet webapp au focus ou sous 10 s ; les préférences réactives se repeignent en direct |
| Desktop connecté à un gateway distant | sans objet | toujours le store de sa propre machine |
| Premier lancement après mise à jour | — | chaque app ou navigateur importe ses propres valeurs absentes du store ; ajouts seulement, aucun écrasement |
Ce que contient le fichier, et pourquoi deux écritures ne s’écrasent pas
Le contrat sur disque est une seule forme, partagée par hermes_cli/ui_preferences.py côté Python et apps/desktop/electron/ui-preferences.ts côté Electron.
{
"version": 1,
"generation": 1,
"revision": 12,
"values": {}
}
values associe des clés à des chaînes. Les clés doivent commencer par hermes.desktop., hermes-desktop- ou hermes-onboarding-, et leur longueur est plafonnée — ce fichier n’est pas une décharge publique. Une valeur null supprime la clé.
Une écriture, ce n’est pas « lire tout le fichier, changer un champ, réécrire tout le fichier ». C’est une fusion par clé effectuée sous un verrou de répertoire : le verrou, c’est le répertoire <store>.lock, pris avec mkdir, atomique sur Node comme sur Python et sur toutes les plateformes, sans branche par OS. L’attente plafonne à 3 secondes, avec un polling toutes les 0,02 seconde, et un verrou vieux de plus de 10 secondes est cassé d’office pour qu’un processus tué ne bloque pas le store indéfiniment. Après la fusion, revision est incrémenté, et le fichier est remplacé via un fichier temporaire puis un rename en mode 0600. Deux fenêtres qui basculent deux interrupteurs différents en même temps ne peuvent donc pas s’effacer mutuellement.
La définition de « pas de store » est volontairement étroite : seul un fichier absent compte. Toute autre erreur de lecture — permissions, disque, encodage — refuse l’écriture (503 côté webapp, et le client réessaie) au lieu de faire comme si le store était vide pour fusionner sur du néant. Cette distinction compte plus qu’elle n’en a l’air : traiter « je ne peux pas lire » comme « il n’y a rien » est la façon classique de perdre des données.
Un fichier corrompu n’est pas jeté en silence non plus : l’écriture qui le remplace copie d’abord l’original vers ui-preferences.json.corrupt-<ms>, et cette écriture ouvre une nouvelle génération.
Deux secondes, dix secondes, ou immédiatement
La différence, c’est qui écoute.
Côté natif, il y a une capacité de preload typée, window.hermesDesktop.uiPreferences : un snapshot disponible de façon synchrone dès la première frame, read, un write validé, et onChanged. Le processus principal pousse les changements à chaque fenêtre, y compris ceux écrits par le processus Python de la webapp — il le remarque via un fs.watchFile léger en stat, sans parsing JSON dans la boucle de polling. Pas de porte générique vers le système de fichiers ici : la surface exposée est étroite et typée. Les handlers sont enregistrés dans main.ts, juste à côté de la lecture de première frame du skin local (qui a déménagé dans local-skin.ts, laissant main.ts sept lignes plus court).
Côté webapp, c’est du HTTP simple : GET /api/ui-preferences et PATCH /api/ui-preferences. La route n’existe que sur la surface webapp (SurfacePolicy.ui_preferences) ; sur serve, dashboard et gateway, c’est un 404. Elle est aussi derrière le session token et le gate de connexion existants, donc une requête non authentifiée reçoit un 401.
Ce qui donne les échelles de temps : une fenêtre native en 2 secondes environ ; un onglet webapp au focus, ou en 10 secondes ; et les préférences réactives — thème, mode, densité, barre de statut — se repeignent en direct au lieu d’attendre un poll. Le vieux problème « entre origines, jamais visible » est réglé avec le reste.
Le premier lancement après la mise à jour
La mise à jour ne jette pas vos anciens réglages et n’écrase pas ce qui est déjà dans le nouveau fichier. Chaque app ou navigateur importe les valeurs qu’il possède et qui manquent au store : ajouts seulement, jamais de réécriture d’une valeur existante, et pas de suppression de sa copie locale avant que l’import ait abouti. La mise à jour est donc invisible — l’app desktop est comme vous l’avez laissée, et la webapp n’est plus aux couleurs par défaut la première fois que vous l’ouvrez. Pour le déroulé du lancement lui-même, y compris ce qu’il vous demande ou non, voyez le guide du premier lancement de Desktop.
Ce qu’il ne fait pas
Ce n’est pas de la synchro cloud. Pas de compte, pas de relais, pas de serveur au milieu — une autre machine a toujours un autre jeu de réglages, parce que le fichier vit sur cette machine-là. La portée résolue est étroite et précise : sur une machine, les quelques fenêtres, navigateurs et adresses d’une personne doivent afficher une seule interface.
À savoir : c’est toujours un fichier JSON en clair sur le disque, mode 0600, régi par les mêmes permissions de fichier que tout le reste sous le home Hermes. Qui peut lire ce home peut lire vos réglages d’interface.
Quant au verrouillage et à la fusion — la meilleure façon de s’en servir, c’est de ne jamais y penser. Réglez votre thème ; les écritures font la queue derrière le verrou d’elles-mêmes.