在桌面端调好的界面,浏览器里全没了:Hermes 把设置收进这台机器的一个文件


你在台式机的 Desktop 里把主题调成暗色,把状态栏收成紧凑模式,顺手把侧边栏密度也改小了一档。然后你打开同一台机器上的笔记本浏览器,输入 Webapp 的地址——迎面而来的是一套默认配色和默认布局,好像刚才那一分钟白干了。更怪的是,明明还在同一台机器上:文件是同一批、会话是同一个 gateway、配置是同一份 config.yaml,偏偏界面设置各过各的。这次改动(#145536,2026-10-27 合并)修的就是这件事:界面偏好不再是某个浏览器标签页的私人物品,而是这台机器上的一份文件。

localStorage:每个浏览器、每个源自己的小仓库

先把前提说清楚,不然会觉得这个 bug 很玄。

localStorage 是浏览器发给你的一块键值小仓库,但它不是“每个网站一份”,而是每个源一份。源(origin)由协议、主机、端口三样共同决定:协议变了、主机名变了、端口变了,浏览器就认为你换了一个网站,另给一个空仓库。所以同一个服务,从 http://127.0.0.1:9120 打开、从局域网 IP 打开、从 public_url 打开,在浏览器眼里是三个互不相干的源,三套互不可见的设置。这不是 Hermes 的毛病,是浏览器的隔离模型在照章办事。

偏偏同一个 Hermes 经常不止一个地址。于是变更前,一套界面设置被切成了好几份:每台设备一份;每个隐私窗口一份(隐私窗口是一个干净的仓库);每个 Webapp 地址一份;而原生 Desktop 根本不是浏览器,它有自己的存储,又是单独一份。你以为你在调“我的界面”,其实你在调“这个浏览器、这个地址、这个窗口里的界面”。

如果你还没跑过 Webapp,把整个工作区塞进浏览器标签页那篇先看一遍,地址是怎么来的、host 和 public_url 各自干什么,都在那里。

现在只有一份,而且它属于机器

变更之后位置只剩一个:运行这个 app 的那台机器的 root Hermes home 下的 ui-preferences.json。这台机器上的原生 Desktop,以及任何浏览器打开它托管的 Webapp,读写的是同一个文件。

归属是“机器”,这一点值得说得更死:不是每个 gateway 连接一份,也不是每个 profile 一份。所以一个 Desktop 同时连着本地网关和一个远程网关,界面还是一套。

那 profile 呢?Desktop 完全可能从一个 profile home 启动。规则是先由传入的路径找到它的 root home,再落到 root 下的 ui-preferences.json;Python 侧的 store_path() 和 Electron 侧的 uiPreferencesRoot(resolveDesktopHermesHome()) 走的是同一条规则,而且两边都解析符号链接——profile home 也好、符号链接过去的目录也好,最后都收敛回 root,永远不会变成“每个 profile 一套”。这不是口头承诺:有一个 vitest 契约测试把真正的 Python 解析器和真正的 Electron 解析器并排跑在 profile home、自定义 home、以及符号链接的 profile 目录这三种路径上,两边解析结果不一致就红。哪些场景下该有多个 profile,一个 profile 一套实例的取舍里讨论得更细——只是界面设置现在不再跟着 profile 走了。

还有一条容易误解的:Desktop 连远程 gateway 时,用的仍然是它自己这台机器的存储。设置跟着“你面前这台机器”,不跟着网关跑。

一张表看清变化

之前 现在
第二台设备 / 隐私窗口里的 Webapp 默认值 这台机器的设置
127.0.0.1 / 局域网 IP / 公网 URL 三种地址 三套 一套
同一台机器上的原生 Desktop 与 Webapp 各一份 共享、双向
另一个窗口或标签改动之后 需刷新;跨源则永远看不到 原生窗口约 2 秒;Webapp 标签聚焦时或 10 秒内;reactive 偏好实时重绘
Desktop 连远程 gateway 不适用 仍用自己机器的存储
升级后第一次运行 — 每个 app/浏览器把 store 里没有的自己的值导入,只增,不覆盖任何已存值

文件长什么样,写入为什么不会互踩

磁盘上的契约两侧是同一份:Python 的 hermes_cli/ui_preferences.py 和 Electron 的 apps/desktop/electron/ui-preferences.ts。

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

values 是 key 到字符串的映射。key 必须以 hermes.desktop.、hermes-desktop-、hermes-onboarding- 三者之一开头,长度有上限——这个文件不是给你塞任意数据的。某个 key 的值写成 null 表示删除它。

一次写入不是“读整个文件、改一处、整文件覆盖”。它按 key 合并在一个目录锁下进行:锁是 <store>.lock 这个目录,靠 mkdir 实现——mkdir 在 Node 和 Python 上、在所有系统上都是原子的,没有平台分支。等锁最多 3 秒,轮询间隔 0.02 秒,超过 10 秒的陈旧锁会被直接打破(进程被 kill 掉时不至于把整个文件永久锁死)。合并完成后 revision 加一,落盘用临时文件加 rename,权限 0600。所以两个窗口同时拨两个不同的开关,不会有一个把另一个擦掉。

“没有 store”的判断故意做得很窄:只有文件不存在才算。其它任何读取失败——权限、磁盘、编码——都会拒绝这次写入(Webapp 上就是 503,让客户端重试),而不是假装仓库是空的、在上面合并一把。这条比它看上去重要:把“读不出来”当成“没有”,是数据丢失最常见的入口。

损坏的文件也不会被静默丢掉:替换它的那次写入会先把原件复制成 ui-preferences.json.corrupt-<ms>,并且这次写入开一个新的 generation。

2 秒、10 秒,和“立刻”

差别在于谁在听、听什么。

原生 Desktop 这边是一个带类型的 preload 能力 window.hermesDesktop.uiPreferences:首帧就能同步拿到的 snapshot、read、经过校验的 write、以及 onChanged。主进程把改动推给所有窗口,包括“由 Webapp 的 Python 进程写进去”的那些——主进程用一个便宜的 fs.watchFile 看文件的 stat 就能发现变化,不需要轮询解析 JSON。这里没有开一个通用的文件系统门,暴露出去的能力是收窄的、带类型的。handler 就注册在 main.ts 里,紧挨着本地皮肤的首帧读取(那块被挪进了 local-skin.ts,main.ts 因此少了 7 行)。

Webapp 这边是普通的 HTTP:GET /api/ui-preferences 和 PATCH /api/ui-preferences。这条路由只在 Webapp 这个 surface 上存在(SurfacePolicy.ui_preferences),在 serve、dashboard、gateway 上是 404;它也待在原有的 session-token/登录门禁后面,没认证就是 401。

于是时间尺度是这样:原生窗口大约 2 秒;Webapp 标签页在你聚焦它的时候、或者最多 10 秒内跟上;而 reactive 的那批偏好——主题、模式、密度、状态栏——是实时重绘的,不用等轮询。跨源那条“永远看不到”的老账,也在这里一并结掉了。

升级后第一次打开

第一次运行不会扔掉你的老设置,也不会覆盖新文件里的东西。每个 app/浏览器会把自己 store 里、目标文件里还没有的值导进去:只增,绝不改写任何已经存在的值;导入完成之前,也不会先把本地值删掉。所以升级对你是无感的——桌面端还是你熟悉的样子,Webapp 第一次打开时也不再是默认配色。第一次启动本身的流程(包括它会问你什么、不会问你什么),桌面端首次运行里有完整描述。

它不做什么

不是云同步。没有账号,没有服务器中转,换一台机器仍然是另一套设置——因为文件在那台机器上。它解决的问题范围很窄,也很具体:同一台机器上、同一个人的几个窗口、几个浏览器、几个地址,应该看到同一套界面。

还有一点值得知道:这仍然是一个本机的明文 JSON,权限 0600,和 Hermes home 下其它东西一样交给你自己的文件权限体系管。能读这个 home 的人,就能读到你的界面设置。

至于并发写、跨进程锁这些细节,最好的用法就是别去想它们——你调你的主题,它自己在锁后面排队。