一个贴在屏幕边缘的桌面工作台:点胶囊展开,收起后拖到屏幕四边。待办、笔记、链接、录音、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 / migrationfnos/app/server/ NAS 侧伴侣:auth / store / transactionLog / recovery / workspace-viewscripts/ Codex、Claude Code、Cursor、Copilot、Grok 的 hook 转发tests/ 168 个 Node 单测
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 })- 桌面端打包
mutation(集合 + 实体 + 期望的expectedServerRev); - 服务端比对版本,不等就回
cas_conflict和当前版本; - 客户端拿到新版本后自己 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 记录长任务(大批量迁移)的中间状态。
五、配对与鉴权
- NAS 伴侣生成6 位数字配对码(
000000–999999,crypto均匀分布),带过期时间; - 桌面端输入配对码换本设备专属 bearer token(服务端只存哈希);
- 之后每次请求
verifyToken()→{uid, deviceId},按 uid 隔离数据; - 走飞牛微应用网关时(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 兼容查询,旧版本会显示「无法检测」。
怎么拿到
- 安装包:github.com/Sunanang/Pome-Panel/releases
- 源码(MIT):github.com/Sunanang/Pome-Panel
- 接 NAS 的完整步骤(配对 / 同步 / 备份):把 Pome Panel 接上自己的 NAS
