Seu tema parava no app desktop: agora o Hermes guarda as configurações de UI por máquina

Você deixa o tema no escuro no app desktop, muda a barra de status para o modo compacto, aperta um pouco a barra lateral. Aí abre a webapp em um navegador na mesma máquina e recebe de volta a paleta padrão e o layout padrão, como se aquele último minuto não tivesse existido. Nada estava quebrado: mesma máquina, mesmo config.yaml, mesmas sessões no mesmo gateway. Só as configurações de interface tinham ido cada uma para um lado. É isso que o #145536 (mergeado em 27 de outubro de 2026) resolve — as preferências deixam de ser propriedade privada de uma aba e passam a ser um arquivo na máquina.
localStorage é um escaninho por navegador e por origem
Vamos pela premissa primeiro, senão o bug parece mágica.
localStorage é um escaninho pequeno de chave-valor que o navegador entrega, mas ele não é um por site — é um por origem. Origem é esquema mais host mais porta. Mude qualquer um dos três e o navegador considera outro site, com um escaninho novo e vazio. O mesmo serviço aberto em http://127.0.0.1:9120, em um IP da LAN e em public_url são três origens sem relação nenhuma para o navegador, e três conjuntos de configurações que não se enxergam. Isso não é defeito do Hermes; é o modelo de isolamento do navegador fazendo exatamente o que promete.
E um Hermes costuma ter mais de um endereço. Então antes desta mudança um único conjunto de preferências de interface era fatiado em vários: um por dispositivo, um por janela privada (janela privada é escaninho limpo), um por endereço da webapp — mais um para o app desktop, que não é navegador e guarda no próprio armazenamento. Você achava que estava ajustando “minha interface”. Estava ajustando “a interface deste navegador, neste endereço, nesta janela”.
Se você ainda não rodou a webapp, colocar o workspace inteiro em uma aba do navegador explica de onde vêm esses endereços e o que host e public_url fazem.
Agora é um arquivo só, e ele pertence à máquina
Sobrou um único lugar: ui-preferences.json na raiz da home do Hermes da máquina que roda o app. O Desktop nativo dessa máquina e qualquer navegador apontado para a webapp dela leem e escrevem o mesmo arquivo.
O dono é a máquina, e vale dizer isso com precisão: não é um store por conexão de gateway, nem um por profile. Um Desktop ligado a um gateway local e a um remoto ao mesmo tempo continua com um único conjunto de preferências.
E os profiles? Um Desktop pode perfeitamente subir a partir da home de um profile. A regra é que um caminho recebido é subido até a home raiz primeiro, e o store fica sob essa raiz; o store_path() do Python e o uiPreferencesRoot(resolveDesktopHermesHome()) do Electron implementam a mesma regra, e os dois resolvem symlinks — então a home de um profile, ou um diretório de profile que é symlink, volta para a raiz e nunca vira um store por profile. Isso não é promessa em comentário: um teste de contrato em vitest roda o resolver real do Python e o resolver real do Electron lado a lado sobre a home de um profile, uma home customizada e um diretório de profile que é symlink, e falha quando os dois discordam. Se você está avaliando quando realmente quer mais de um profile, os trade-offs de uma instância por profile aprofunda o assunto — as configurações de interface é que não viajam mais junto com eles.
Mais uma coisa que as pessoas invertem: um Desktop conectado a um gateway remoto continua usando o store da própria máquina. As configurações seguem a máquina na sua frente, não o gateway.
Antes e depois
| Antes | Agora | |
|---|---|---|
| Webapp em um segundo dispositivo ou em janela privada | padrões | configurações desta máquina |
Os três endereços: 127.0.0.1, IP da LAN, URL pública |
três conjuntos | um conjunto |
| Desktop nativo e webapp na mesma máquina | um de cada | compartilhado, nos dois sentidos |
| Depois de uma mudança em outra janela ou aba | precisava recarregar; entre origens, nunca visível | janela nativa em ~2s; aba da webapp no foco ou em até 10s; preferências reativas repintam na hora |
| Desktop conectado a um gateway remoto | não se aplica | continua o store da própria máquina |
| Primeira execução após o upgrade | — | cada app ou navegador importa os próprios valores que faltam no store; só adiciona, não sobrescreve nada |
O que tem no arquivo, e por que duas escritas não se atropelam
O contrato em disco é uma forma só, compartilhada por hermes_cli/ui_preferences.py no lado Python e apps/desktop/electron/ui-preferences.ts no lado Electron.
{
"version": 1,
"generation": 1,
"revision": 12,
"values": {}
}
values mapeia chaves para strings. As chaves precisam começar com hermes.desktop., hermes-desktop- ou hermes-onboarding-, e têm tamanho limitado — este arquivo não é depósito de uso geral. Um valor null apaga aquela chave.
Uma escrita não é “ler o arquivo inteiro, mudar um campo, gravar o arquivo inteiro de volta”. É um merge por chave feito sob um lock de diretório: o lock é o diretório <store>.lock, criado com mkdir, que é atômico no Node, no Python e em qualquer plataforma, sem ramo por SO. A espera vai no máximo até 3 segundos, com polling a cada 0.02 segundos, e um lock com mais de 10 segundos é quebrado na marra para que um processo morto não trave o store para sempre. Depois do merge o revision incrementa, e o arquivo é substituído via arquivo temporário mais rename em modo 0600. Duas janelas trocando dois switches diferentes ao mesmo tempo, portanto, não conseguem apagar uma à outra.
A definição de “sem store” é deliberadamente estreita: só conta um arquivo ausente. Qualquer outra falha de leitura — permissão, disco, encoding — recusa a escrita (503 na webapp, e o cliente tenta de novo) em vez de fingir que o store estava vazio e fazer merge em cima do nada. Essa distinção importa mais do que parece: tratar “não consigo ler” como “não tem nada aqui” é a forma clássica de perder dados.
Arquivo corrompido também não é descartado em silêncio: a escrita que o substitui primeiro copia o original para ui-preferences.json.corrupt-<ms>, e essa escrita começa uma nova generation.
Dois segundos, dez segundos ou imediato
A diferença é quem está escutando.
No lado nativo existe uma capability tipada no preload, window.hermesDesktop.uiPreferences: um snapshot disponível de forma síncrona já no primeiro frame, read, um write validado e onChanged. O processo principal empurra as mudanças para todas as janelas, inclusive as escritas pelo processo Python da webapp — ele percebe por um stat barato com fs.watchFile, sem parsing de JSON no loop de polling. Não existe porta genérica de filesystem aqui; a superfície exposta é estreita e tipada. Os handlers são registrados em main.ts, logo ao lado da leitura de primeiro frame do skin local (que foi para local-skin.ts, deixando o main.ts sete linhas menor).
O lado da webapp é HTTP puro: GET /api/ui-preferences e PATCH /api/ui-preferences. A rota só existe na superfície da webapp (SurfacePolicy.ui_preferences); em serve, dashboard e gateway ela é 404. Também fica atrás do session-token e do gate de login já existentes, então requisição não autenticada recebe 401.
O que dá as escalas de tempo: janela nativa em cerca de 2 segundos; aba da webapp no foco, ou em até 10 segundos; e as preferências reativas — tema, modo, densidade, barra de status — repintando na hora, sem esperar polling. O velho problema do “entre origens nunca é visível” se resolve junto com o resto.
A primeira execução depois do upgrade
O upgrade não joga fora suas configurações antigas nem sobrescreve o que já está no arquivo novo. Cada app ou navegador importa os valores que tem e que faltam no store: só adiciona, nunca reescreve um valor existente, e não apaga a cópia local antes de a importação concluir. Então o upgrade é invisível — o app desktop fica do jeito que você deixou, e a webapp deixa de vir com a cor padrão na primeira vez que você abre. Para o fluxo de primeira execução em si, incluindo o que ele pergunta e o que não pergunta, veja o guia de primeira execução do desktop.
O que ele não faz
Não é sincronização na nuvem. Sem conta, sem relay, sem servidor no meio — outra máquina continua com outro conjunto de configurações, porque o arquivo mora naquela máquina. O escopo que ele resolve é estreito e específico: em uma máquina, as várias janelas, navegadores e endereços de uma pessoa devem mostrar uma única interface.
Vale saber: isso ainda é um arquivo JSON em texto puro no disco, modo 0600, regido pelas mesmas permissões de arquivo que tudo o mais sob a home do Hermes. Quem consegue ler aquela home consegue ler suas configurações de interface.
Quanto ao locking e ao merge — a melhor forma de usá-los é nunca pensar neles. Escolha seu tema; as escritas entram na fila atrás do lock sozinhas.