Tu tema se quedaba en la app de escritorio: ahora Hermes guarda los ajustes de interfaz por máquina

Pones el tema en oscuro en la app de escritorio, cambias la barra de estado a compacta, ajustas la barra lateral un punto. Luego abres la webapp en un navegador de la misma máquina y te encuentras la paleta por defecto y el layout por defecto, como si ese último minuto no hubiera existido. No había nada roto: la misma máquina, el mismo config.yaml, las mismas sesiones en el mismo gateway. Solo los ajustes de interfaz habían tomado caminos distintos. Eso es lo que arregla el #145536 (mergeado el 27 de octubre de 2026): las preferencias dejan de ser propiedad privada de una pestaña y pasan a ser un fichero de la máquina.
localStorage es un cajón por navegador y por origen
Primero la premisa, o el bug parece magia.
localStorage es un pequeño cajón clave-valor que el navegador te da, pero no es uno por sitio web: es uno por origen. Un origen es esquema más host más puerto. Cambia cualquiera de los tres y el navegador lo considera otro sitio distinto, con un cajón nuevo y vacío. El mismo servicio abierto en http://127.0.0.1:9120, en una IP de la LAN y en public_url son tres orígenes sin relación para el navegador, y tres juegos de ajustes que no se ven entre sí. Eso no es un defecto de Hermes: es el modelo de aislamiento del navegador haciendo exactamente lo que promete.
Y un Hermes suele tener más de una dirección. Así que antes de este cambio, un único juego de preferencias de interfaz quedaba troceado en varios: uno por dispositivo, uno por ventana privada (una ventana privada es un cajón limpio), uno por dirección de la webapp, y uno más para la app de escritorio, que no es un navegador y lleva su propio almacenamiento. Tú creías que estabas ajustando «mi interfaz». Estabas ajustando «la interfaz de este navegador, en esta dirección, en esta ventana».
Si todavía no has usado la webapp, meter todo el workspace en una pestaña del navegador explica de dónde salen esas direcciones y qué hace cada uno de host y public_url.
Ahora un solo fichero, y es de la máquina
Queda una sola ubicación: ui-preferences.json en la raíz del home de Hermes de la máquina que ejecuta la app. El Desktop nativo de esa máquina y cualquier navegador apuntando a su webapp leen y escriben el mismo fichero.
El dueño es la máquina, y merece la pena decirlo con precisión: no hay un store por conexión de gateway, ni uno por profile. Un Desktop conectado a la vez a un gateway local y a uno remoto sigue teniendo un único juego de preferencias.
¿Y los profiles? Un Desktop puede perfectamente arrancar desde el home de un profile. La regla es que la ruta que se le pasa se sube primero hasta su home raíz y el store acaba bajo esa raíz; el store_path() de Python y el uiPreferencesRoot(resolveDesktopHermesHome()) de Electron implementan la misma regla, y ambos resuelven los symlinks, de modo que el home de un profile, o un directorio de profile enlazado simbólicamente, colapsa de vuelta a la raíz y nunca se convierte en un store por profile. Y no es una promesa en un comentario: un test de contrato en vitest ejecuta el resolver real de Python y el resolver real de Electron en paralelo sobre el home de un profile, un home personalizado y un directorio de profile con symlink, y falla cuando no coinciden. Si estás sopesando cuándo quieres de verdad más de un profile, los compromisos de tener una instancia por profile entra más a fondo; lo que ya no viaja con ellos son los ajustes de interfaz.
Y algo que la gente suele entender al revés: un Desktop conectado a un gateway remoto sigue usando el store de su propia máquina. Los ajustes siguen a la máquina que tienes delante, no al gateway.
Antes y después
| Antes | Ahora | |
|---|---|---|
| Webapp en un segundo dispositivo o en una ventana privada | valores por defecto | los ajustes de esta máquina |
Las tres direcciones: 127.0.0.1, IP de LAN, URL pública |
tres juegos | un juego |
| Desktop nativo y webapp en la misma máquina | uno cada uno | compartidos, en ambos sentidos |
| Tras un cambio en otra ventana o pestaña | hacía falta recargar; entre orígenes, nunca visible | ventana nativa en unos 2 s; pestaña de la webapp al enfocar o en 10 s como máximo; las preferencias reactivas se repintan en vivo |
| Desktop conectado a un gateway remoto | no aplica | sigue siendo el store de su propia máquina |
| Primer arranque tras actualizar | — | cada app o navegador importa sus propios valores que le faltan al store; solo añade, no sobrescribe nada |
Qué hay en el fichero, y por qué dos escrituras no se pisan
El contrato en disco es una sola forma, compartida por hermes_cli/ui_preferences.py en el lado Python y apps/desktop/electron/ui-preferences.ts en el lado Electron.
{
"version": 1,
"generation": 1,
"revision": 12,
"values": {}
}
values mapea claves a strings. Las claves tienen que empezar por hermes.desktop., hermes-desktop- o hermes-onboarding-, y su longitud está acotada: este fichero no es un vertedero de propósito general. Un valor null borra esa clave.
Una escritura no es «leer el fichero entero, cambiar un campo, reescribir el fichero entero». Es un merge por clave hecho bajo un lock de directorio: el lock es el directorio <store>.lock, tomado con mkdir, que es atómico en Node y en Python y en cualquier plataforma, sin ramas por sistema operativo. La espera máxima es de 3 segundos, sondeando cada 0,02 segundos, y un lock con más de 10 segundos se rompe directamente para que un proceso muerto no pueda atascar el store para siempre. Después del merge, revision se incrementa y el fichero se reemplaza mediante un fichero temporal más rename con modo 0600. Dos ventanas cambiando dos interruptores distintos a la vez no pueden borrarse el uno al otro.
La definición de «no hay store» es deliberadamente estrecha: solo cuenta un fichero ausente. Cualquier otro fallo de lectura — permisos, disco, codificación — rechaza la escritura (503 en la webapp, y el cliente reintenta) en lugar de fingir que el store estaba vacío y hacer merge sobre la nada. Esa distinción importa más de lo que parece: tratar «no puedo leer» como «aquí no hay nada» es la forma clásica de perder datos.
Un fichero corrupto tampoco se descarta en silencio: la escritura que lo reemplaza copia primero el original a ui-preferences.json.corrupt-<ms>, y esa escritura arranca una nueva generation.
Dos segundos, diez segundos o al instante
La diferencia es quién está escuchando.
En el lado nativo hay una capacidad tipada en el preload, window.hermesDesktop.uiPreferences: un snapshot disponible de forma síncrona en el primer frame, read, un write validado y onChanged. El proceso principal empuja los cambios a todas las ventanas, incluidos los escritos por el proceso Python de la webapp: se entera con un fs.watchFile barato sobre el stat, sin parsear JSON en el bucle de sondeo. Aquí no hay una puerta genérica al sistema de ficheros; la superficie expuesta es estrecha y tipada. Los handlers se registran en main.ts, justo al lado de la lectura de primera trama del local-skin (que se movió a local-skin.ts, dejando main.ts siete líneas más corto).
El lado de la webapp es HTTP plano: GET /api/ui-preferences y PATCH /api/ui-preferences. La ruta solo existe en la superficie de la webapp (SurfacePolicy.ui_preferences); en serve, dashboard y gateway devuelve 404. Además está detrás del session-token y del control de inicio de sesión que ya existían, así que una petición sin autenticar es un 401.
De ahí salen las escalas de tiempo: una ventana nativa en unos 2 segundos; una pestaña de la webapp al enfocar, o en 10 segundos como máximo; y las preferencias reactivas —tema, modo, densidad, barra de estado— se repintan en vivo en lugar de esperar a un sondeo. El viejo problema de «entre orígenes nunca se ve» queda resuelto junto con el resto.
El primer arranque tras actualizar
Actualizar no tira tus ajustes viejos ni sobrescribe lo que ya hay en el fichero nuevo. Cada app o navegador importa los valores que tiene y que le faltan al store: solo añade, nunca reescribe un valor existente, y no borra su copia local antes de que la importación llegue a puerto. Así que la actualización es invisible: la app de escritorio se ve como la dejaste y la webapp ya no aparece con los colores por defecto la primera vez que la abres. Para el flujo de arranque en sí, incluido lo que te pregunta y lo que no, mira la guía del primer arranque en Desktop.
Lo que no hace
No es sincronización en la nube. Sin cuenta, sin relay, sin servidor en medio: otra máquina sigue teniendo otro juego de ajustes, porque el fichero vive en esa máquina. El alcance que resuelve es estrecho y concreto: en una máquina, las varias ventanas, navegadores y direcciones de una misma persona deberían mostrar una sola interfaz.
Conviene saberlo: sigue siendo un fichero JSON en texto plano en disco, modo 0600, regido por los mismos permisos de fichero que todo lo demás bajo el home de Hermes. Cualquiera que pueda leer ese home puede leer tus ajustes de interfaz.
En cuanto al locking y al merge, la mejor forma de usarlos es no pensar nunca en ellos. Pon tu tema; las escrituras se ponen en cola detrás del lock solas.