Pome Panel(桌面工作台)

Pome Panel(桌面工作台)

Electron 桌面工作台:待办、笔记、链接、录音、9 家 AI 工具额度与完成提醒。工作区可配对飞牛 NAS 多端同步,服务端 CAS + 客户端 rebase,无云端。

September 18, 20262026年9月18日

Read in English →阅读英文版 →

一个贴在屏幕边缘的桌面工作台:点胶囊展开,收起后拖到屏幕四边。待办、笔记、链接、录音、9 家 AI 工具的额度与完成提醒,工作区可以配对飞牛 NAS 在多台设备间同步——没有云端,也没有账号系统。

Electron 44,MIT,168 个测试文件。这篇写它是怎么工作的,重点在同步协议和踩过的坑。

待办页:分类、到期与提醒状态都在同一屏

一、环境与代码规模

项值
版本0.11.0
运行时Electron 44 / Node(主进程)
平台macOS 13+(Apple Silicon)、Windows 10/11 x64
打包electron-builder 26(dmg / nsis),通吃;NAS 侧伴侣另打 fpk
代码42 个顶层 JS 模块 + 36 个 renderer 模块 + 168 个测试文件
同步packages/sync-protocol(桌面端与 NAS 伴侣共用同一份协议)
main.js Electron 主进程:窗口与定位、菜单栏、剪贴板、媒体、通知气泡
main-services.js 纯领域服务(不 import electron,可直接 Node 单测)
preload.js contextBridge 白名单桥
renderer/ 界面与交互
packages/sync-protocol/ collections / mutation / pull / pairing / secret-box / migration
fnos/app/server/ NAS 侧伴侣:auth / store / transactionLog / recovery / workspace-view
scripts/ Codex、Claude Code、Cursor、Copilot、Grok 的 hook 转发
tests/ 168 个 Node 单测

Pome Panel 架构:一份协议,两端实现

main-services.js 单独拆出来是有意的:领域逻辑不碰 electron,待办排序、提醒窗口、位置吸附都能直接跑 Node 单测,不用起无头 Electron。168 个测试文件大部分落在这一层。

二、约束决定了形状

约束直接后果
随手用的东西,必须秒开主进程常驻、窗口预创建、唤出不阻塞在动画上
用户数据不许上别人的服务器同步只在自己配对的 NAS 上,没有云、没有账号
双平台 + 要进飞牛应用中心桌面端 Electron,NAS 侧另写零第三方依赖的 Node 伴侣
要读各家 AI 工具的额度每家一个适配器;凭据按需读、不批量解密

三、本地存储:宁可多一次 fsync

工作区状态是 JSON 文件,所有写入走同一套原子写:

// atomicStorage.js
function writeAtomic(filePath, bytes, disk = fs) {
  disk.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 })
  const temporary = `${filePath}.${randomUUID()}.tmp`
  let fd
  try {
    fd = disk.openSync(temporary, 'wx', 0o600)   // O_EXCL:绝不覆盖已存在的临时文件
    disk.writeFileSync(fd, bytes)
    disk.fsyncSync(fd)
    disk.closeSync(fd); fd = undefined
    disk.renameSync(temporary, filePath)         // 原子替换
    if (process.platform !== 'win32') {          // rename 本身也要落盘
      const dir = disk.openSync(path.dirname(filePath), 'r')
      try { disk.fsyncSync(dir) } finally { disk.closeSync(dir) }
    }
  } finally {
    if (fd !== undefined) disk.closeSync(fd)
    if (disk.existsSync(temporary)) disk.unlinkSync(temporary)
  }
}

读的时候故意不宽容:解析失败不吞错,抛 storage_corrupt 并保留原文件——笔记宁可打不开一次,也不能被「修复」成空文件。

为什么不用 SQLite:数据量是几百条待办 + 几百条笔记,单文件够用;SQLite 会引入迁移、锁、原生模块三件麻烦事,而同步层本来就要按「集合 + 实体 + revision」建模,JSON 更贴近这个模型。

四、同步协议:服务端只保证单调,客户端负责 rebase

整个同步的设计目标:没有中心服务器、没有账号,但两台设备不能互相覆盖。

服务端状态(每个 uid 一份)

live Map<entityId, entity> 当前实体
tombstones Map<entityId, {serverRev, deletedAt}> 删除留痕
changeLog [] 单调递增的变更流
serverRev int 全局单调版本号

一次推送

// fnos/app/server/store.js
if (Number(expectedServerRev) !== b.serverRev) {
  return { error: 'cas_conflict', currentServerRev: b.serverRev }
}
b.serverRev += 1
b.live.set(entityId, { ... })
b.changeLog.push({ serverRev: b.serverRev, entityId, collection, op: 'upsert', at: ts, deviceId })
  1. 桌面端打包 mutation(集合 + 实体 + 期望的 expectedServerRev);
  2. 服务端比对版本,不等就回 cas_conflict 和当前版本;
  3. 客户端拿到新版本后自己 rebase 再提交——服务端永远不合并,只做「版本号单调 + 日志只追加」这一件简单的事。

这样两端共用同一份 rebase 代码(packages/sync-protocol),不会出现「桌面端升级了 NAS 没升」的协议错位。

为什么删除要留墓碑

删掉就把实体抹掉,另一台离线设备回来时会推「它还活着」,删除就复活了。所以删除写 tombstones(带 serverRev 与 deletedAt),墓碑也进变更流。aiCompletions 更麻烦:本地已删、远端又来同 id 新增,服务端会以新的 revision 回放一次删除,让两端收敛。

有一类数据不能靠版本覆盖

待办的「已提醒」标记与 AI 完成记录是可合并集合:两台设备离线各自提醒过不同任务,联网后要并集,不能整条覆盖。协议里单独有 mergeReminderMark / mergeSyncedCompletions,只对这类数据生效。

迁移:用「删除日志」表达「以本地为准」

从旧数据导入或切换权威端时,authority = 'local' 会先把 NAS 上现存的每条实体写成一条 delete 进变更流,再逐条 upsert 本地实体。这样其它设备拉到时看到的是「旧的都删了、新的都建了」,正常收敛,而不是靠一条特殊指令硬覆盖:

if (row.authority === 'local' && Array.isArray(entities)) {
  for (const [entityId, prev] of [...b.live.entries()]) {
    b.serverRev += 1
    b.live.delete(entityId)
    b.tombstones.set(entityId, { entityId, serverRev: b.serverRev, deletedAt: ts })
    b.changeLog.push({ serverRev: b.serverRev, entityId, op: 'delete', clientMutationId: `mig-del-${migrationId}-${entityId}` })
  }
  for (const ent of entities) { /* 逐条 upsert,同一个 serverRev 序列 */ }
}

事务日志:崩溃后能接着走,不用重放业务逻辑

服务端状态变更不是整体重写文件,而是算 diff 再追加:

// transactionLog.js
const patch = stateDiff(before, after)   // root maps + 每个 uid 的字段/映射/追加日志
if (nextLog.length < oldLog.length || oldLog.some((e, i) => e !== nextLog[i]))
  throw new Error('change_log_not_append_only')     // 日志只许追加,违反直接抛

用 v8.serialize() 写盘、deserialize() 读回;recovery.js 还按 chunk 记录长任务(大批量迁移)的中间状态。

五、配对与鉴权

  1. NAS 伴侣生成6 位数字配对码(000000–999999,crypto 均匀分布),带过期时间;
  2. 桌面端输入配对码换本设备专属 bearer token(服务端只存哈希);
  3. 之后每次请求 verifyToken() → {uid, deviceId},按 uid 隔离数据;
  4. 走飞牛微应用网关时(unix socket + 前缀),身份来自网关透传头,只在显式 trustGateway 时才采信,否则一律要求 device token。

密钥库不落明文:secret-box.js 用 AES-256-GCM(12 字节 IV)加密后再进同步,密钥由本地钥匙串保管——服务端从头到尾没见过明文。

六、AI 额度与完成提醒

9 个适配器(Claude / Codex / Cursor / Copilot / Grok / Antigravity / Manus…)各自读本机凭据算剩余额度。重点不在抓取,而在何时允许抓取:

  • 启动后不自动读。用户在「设置 → AI」点「读取额度 / 重试授权」之后,本次运行才启用对已存 Token 的读取;
  • 拒绝过授权就不再自动重试,要用户主动点;
  • Token 与录音配置不在列表页解密,打开对应页面才读钥匙串;
  • 不索取 Mac 登录密码、不遍历钥匙串,系统授权交给 macOS 弹窗。

完成提醒是另一条链路:Codex / Claude Code / Cursor 等工具的 hook 触发 scripts/*-notify.js,带着 POME_NOTIFY_PORT 打本机 HTTP,主进程按 aiEventPolicy.js + aiNotificationScheduler.js 决定弹不弹。端口被占用会提示异常并自动重试。

会议模式(自动检测)三条件任一满足即静默:摄像头持续使用约 3 秒、检测到投屏 / 屏幕共享、系统勿扰状态;全部解除约 30 秒后恢复。只读状态位、不采集画面声音;识别不到的显示「无法检测」,已触发的暂停保持——宁可少弹一次,也不在开会时弹。

七、踩坑记录

逐条对应仓库里的修复提交。

现象根因修法
多显示器 / 多个 Space 下唤不出面板,胶囊挂在错误的屏幕上只按主屏坐标定位,窗口身份在 macOS 上跟 display 绑定遍历所有 display 与 Space 找回窗口,再按记忆位置恢复
反复 hide / show 之后窗口消失或位置漂移原生可见性状态在多次切换后不同步抽出一层可见性生命周期统一管理,回归后再合并
Windows 上原生焦点关闭后通知不消失,越堆越多依赖焦点事件关闭通知,Win 上事件顺序不同检测到原生焦点关闭就主动 dismiss
Cursor 额度一直读取失败Cursor 把凭据加密存储,原来按明文读支持加密凭据解密后再查额度
Codex 额度数字抖动、偶尔读不到解析逻辑对输出格式变化太敏感,失败后没有恢复路径稳定解析 + 通知链自动恢复
额度进度条被挤位移经典滚动条占宽度,布局按无滚动条假设预留滚动条宽度,不再让行位移
负温度显示被裁掉负号温度布局按固定宽度算用实测宽度为负号预留空间
百分比数字换行 / 对不齐用等宽字体测量真实字体的宽度按实际字重测量每段文本
Computer Use 包装后的 Codex 收不到完成提醒嵌套一层 Codex 后 hook 链断检测嵌套并修复,清理旧 Pome 层、保留原通知链
打包校验在 CI 上误报校验整包(含时间戳)只比对 package.json 里的产品身份字段

八、测试方式

168 个测试文件基本都是 Node 单测(不需要 Electron)。另外有一个专门的回归脚本,跑「工作中枢」的负载:

npm run check:work-hub

固定夹具:250 条笔记、200 条待办、100 个链接、20 条录音、50 条完成记录;分别测普通唤出与隔离 NAS 同步负载各 20 次,记录 P95 与几何信息到 dist.noindex/iteration-validation/<run-id>/,生成文件不提交。Windows 真机与实体显示器 / DPI 的检查单独做——虚拟环境测不出 DPI 缩放的问题。

九、取舍与已知限制

选择替代方案为什么不用
CAS + 客户端 rebase服务端三方合并合并逻辑要在服务端和客户端各实现一遍;让服务端只保证单调版本更可靠
JSON + 原子写SQLite数据规模不需要;同步层本来就按实体建模
自建配对(6 位码 + device token)OAuth / 云账号数据不上云,就不该引入云端身份
可合并集合单独处理全量 LWW提醒标记是并集语义,覆盖会丢「已提醒」导致重复打扰
启动不自动读额度后台定时刷新未经允许读凭据是越界,宁可让用户点一下

已知限制:两台设备同时离线时仍可能各自提醒一次(联网后合并标记,但已经弹过的收不回);homeLayout 尚未接入同步;共享检测只能覆盖系统镜像与受支持软件里的共享控制,网页共享不保证识别;Windows 勿扰检测依赖 Shell 兼容查询,旧版本会显示「无法检测」。

怎么拿到