Commilitia Drop:自托管的跨设备剪贴板同步与点对点文件传输。 - 后端 Go(chi / SQLite WAL / SSE Hub / WebRTC signaling + 状态机 / Relay ring buffer),编译进单个 distroless 镜像(前端 go:embed)。 - 前端 React + TanStack Router + Zustand,自实现 SSE + WebRTC P2P,NAT 受阻时回退服务端中继;聚珍(Juzhen)CJK 综合排版。 - 桌面端 Wails v2(macOS / Windows),瘦客户端复用 web。 - 鉴权 OIDC PKCE(自建 Casdoor 等),refresh_token 信封加密存系统密钥库;iOS Shortcut 用 HS256 scoped token。 架构文档与变更记录见 docs 分支(PROJECT_BRIEF / FRONTEND_DESIGN / CHANGELOG)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
11 KiB
iOS 快捷指令剪贴板同步
状态(2026-06-14):暂停。 网页端「签发 token」界面已移除(组件
web/src/features/shortcut/ShortcutTokens.tsx保留休眠、后端端点/令牌系统保留,便于将来重启)。本文余下内容为暂停前的设计与搭建规格,存档参考。暂停原因(两条硬伤,源自轮询模型):
- 频繁鉴权——自动指令每 INTERVAL 轮询一次,每次都走一遍完整鉴权(HS256 验签 + 设备 upsert + 异步 touch)。
- 设备列表被「Unknown Device」刷屏——轮询打的
GET /api/clipboard/version没带X-Device-Name,鉴权中间件遂用 UA 兜底名(含随机十六进制)登记设备,每次轮询生成一条新设备行。重启此路径前必须先解决:给所有快捷指令请求统一带
X-Device-Name(或服务端对 scoped 令牌固定设备名/跳过 upsert),并评估轮询频率对鉴权的压力(如令牌侧加轻量校验、或换长连/推送方案)。
借鉴 SyncClipboard 的形态:两个 iCloud 快捷指令——手动与自动。本阶段先落地手动路径并真机验证,自动轮询指令在此基础上追加(见文末)。
鉴权模型(长期方案)
为快捷指令签发长效、仅限剪贴板、可随时吊销的专用 HS256 token,不采用 SyncClipboard 的账号密码 Basic Auth。
理由出自信任模型(不信任本机其他软件):token 会明文存在快捷指令配置里、同机其他 App 可读。
- Basic Auth 一旦泄漏的是整账号、无法单独吊销、改密即全端失效——正是要避开的反模式。
- 本方案 token 只携带
scope=clipboard,经requireScope中间件只能命中/api/clipboard;可单独吊销(服务端查revoked,下次使用即失效)、带 TTL(默认 365 天)、记last_used_at审计。泄漏面仅限剪贴板、可定点吊销、不碰账号密码。
token 用 CDROP_HS256_SECRET 派生的 32 字节 HMAC 密钥签发(SHA-256 派生,任意长度密钥都可用)。快捷指令登记的设备名取自请求的 X-Device-Name——设备名全局限制为 ASCII:名字要塞进 HTTP 头,非 ASCII 无法可靠传输(浏览器 fetch 还会直接抛错),故输入处校验 + 服务端 sanitizeDeviceName 兜底剥离。ios 设备类型保留给未来真正的原生客户端,快捷指令不发该类型。
签发 token
设置页 →「iOS 快捷指令」→ 填备注名 →「签发」。弹窗一次性展示 token 与服务器地址,立即复制保存;关闭后无法再次查看。每用户活跃 token 上限默认 10(CDROP_SHORTCUT_MAX_PER_USER)。
需服务端配置 CDROP_HS256_SECRET 才启用,否则签发返回 503。
同步模型(version + origin_ts)
服务端权威,每条剪贴板带:
version:每用户严格单调递增的版本号。判断「变没变」的唯一依据,不比内容。origin_ts:内容在源设备被复制的时刻(ms)。冲突收敛的 LWW 键——写入仅在origin_ts严格大于现存时被接受,故延迟/乱序到达的旧复制不会盖掉新的(异步容忍)。source_device:来源,抑制回声 + 展示。
客户端:记 lastVersion + 自己的设备名。轮询轻量 /version 探测变更(不取 content),版本变了且 source ≠ 自己 才取全量;推送带 origin_ts。
端点
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/clipboard/version |
廉价探测:只返 {version, source_device, origin_ts, content_type, updated_at},不含 content |
| GET | /api/clipboard |
取全量;返回上面字段 + content |
| PUT | /api/clipboard |
写入;体 {"content": "...", "content_type": "text/plain", "origin_ts": <复制时刻 ms>},上限 64 KB。被 LWW 拒(有更晚的复制)回 409 + 当前胜出态;省略 origin_ts 时服务端用 now |
三者都需请求头 Authorization: Bearer <token>。
手动快捷指令搭建
在 iPhone「快捷指令」App 中手工新建两个指令,首次运行填入服务器地址与 token(用「文本」+「设定变量」持久化,后续动作引用变量)。两个指令都加请求头 X-Device-Name: <你的 ASCII 设备名>(仅 ASCII,如 iPhone),让它在设备列表里统一显示为一台设备。
拉取(云端 → 本机剪贴板)
- 「获取 URL 内容」:地址
<服务器地址>/api/clipboard,方法 GET,请求头加Authorization: Bearer <token>。 - 「获取词典值」:取键
content。 - 内容非空 →「拷贝到剪贴板」;可加「显示通知」提示已拉取。
上传(本机剪贴板 → 云端)
- 「获取剪贴板」;若为空则「停止并输出」。
- 「获取 URL 内容」:地址同上,方法 PUT,请求头加
Authorization: Bearer <token>与Content-Type: application/json,请求体选 JSON:{"content": 剪贴板, "content_type": "text/plain"}。 - 可加「显示通知」提示已上传。
将两个指令加到主屏 / 共享表单 / 轻点背面即可手动触发。
网络容错
单次运行遇网络波动可能失败,重试即可——长效 token 不会因短暂离线失效。Web / 桌面端的实时通道(SSE)已改为永不放弃的指数退避重连(1s→3s→9s→18s,封顶 30s),短期断网自动恢复、不报错不退出。
吊销
设置页「iOS 快捷指令」列表 →「吊销」。服务端在每次请求时校验 revoked,吊销后该 token 下次使用立即失效。
iCloud 分发:合并指令搭建规格(手动同步)
做一个合并指令(运行时菜单选 拉取 / 上传),配 Import Questions 在安装时问参数——服务器地址、设备名给默认值,安装者只需粘贴一次令牌。在 iPhone「快捷指令」App 里照下面搭,再「拷贝 iCloud 链接」分发;分享出的 iCloud 链接由 Apple 签名,安装无需开启「不受信任的快捷指令」。
顶部:三个配置变量
- 「文本」→ 内容
https://drop.commilitia.net→「设定变量」BASE。 - 「文本」→ 内容占位(如
token)→「设定变量」TOKEN。 - 「文本」→ 内容
iPhone→「设定变量」NAME。
(这三条「文本」的内容随后设为 Import Questions,安装者填 / 确认。)
菜单
- 「从菜单选取」→ 两项:
从云端拉取、上传到云端。
「从云端拉取」分支
- 「获取 URL 内容」:
- 网址:插入变量
BASE,紧跟文本/api/clipboard。 - 方法:
GET。 - 标头:
Authorization= 文本Bearer后插变量TOKEN;X-Device-Name= 变量NAME。
- 网址:插入变量
- 「获取词典值」:键
content,输入为上一步结果。 - 「如果」上一步「有任何值」:
- 「拷贝到剪贴板」= 上一步值;(可选)「显示通知」
已从 cdrop 拉取。 - 否则:(可选)「显示通知」
云端剪贴板为空。
- 「拷贝到剪贴板」= 上一步值;(可选)「显示通知」
「上传到云端」分支
- 「获取剪贴板」。
- 「如果」剪贴板「有任何值」:
- 「获取 URL 内容」:
- 网址:变量
BASE紧跟文本/api/clipboard。 - 方法:
PUT。 - 请求体:
JSON,两字段——content(文本)= 变量「剪贴板」;content_type(文本)=text/plain。 - 标头:
Authorization=Bearer+ 变量TOKEN;X-Device-Name= 变量NAME。(请求体选 JSON 时通常自动带Content-Type: application/json,没有就手动加。)
- 网址:变量
- (可选)「显示通知」
已上传到 cdrop。 - 否则:(可选)「显示通知」
本机剪贴板为空。
- 「获取 URL 内容」:
配置 Import Questions(分享前)
打开指令详情(设置)→「导入问题」区,添加三个,对应顶部三条「文本」:
- 服务器地址 → 问题「服务器地址」,默认答案
https://drop.commilitia.net。 - 令牌 → 问题「粘贴 cdrop 令牌」,无默认。
- 设备名 → 问题「设备名(仅 ASCII)」,默认
iPhone。
分发
指令详情 →「分享」→「拷贝 iCloud 链接」→ 把链接发来,接进网站签发面板(安装链接 + 二维码 + 复制令牌)。
要点:设备名仅 ASCII(与全局策略一致);不发 X-Device-Type——快捷指令在设备列表显示为浏览器类型 + 你填的名字,ios 类型保留给未来原生客户端;两个分支都带 X-Device-Name,统一折成一台设备。
自动同步:双向轮询指令
一个无限循环指令,按 version + origin_ts 模型做双向同步:本机被复制就推、远端更新就拉,单一 LAST_VERSION 防回环。每轮只读一次本机剪贴板 + 一次轻量 /version,仅在真有新内容时才取全量。
配置同合并指令(BASE_URL / TOKEN / NAME + Import Questions / INTERVAL)。持久变量:LAST_VERSION、BASELINE(本机当前内容)。初始化:BASELINE=获取剪贴板、LAST_VERSION=0。「重复」次数 99999(无真无限循环,停靠手动结束运行)。
循环体:
L= 「获取剪贴板」。- 如果
L≠BASELINE(本机被复制)→ 推送:- 「获取 URL 内容」PUT
BASE_URL/api/clipboard,体 JSON{content: L, content_type: "text/plain", origin_ts: <当前时间 Unix 毫秒>},标头Authorization/X-Device-Name。 - 取响应
version→LAST_VERSION;取响应content:等于L→BASELINE=L(被接受);不等 → 「拷贝到剪贴板」=响应 content、BASELINE=响应 content(被拒,采纳胜出内容)。 - (iOS 读不了 409 状态码,故靠「响应 content 是否等于我发的」判接受/落败。)
- 「获取 URL 内容」PUT
- 否则 → 廉价探测上游:
- 「获取 URL 内容」GET
BASE_URL/api/clipboard/version(无 content)→{version, source_device}。 - 如果
version≠LAST_VERSION且source_device≠NAME:GET 全量/api/clipboard→ 「拷贝到剪贴板」=content、BASELINE=content、LAST_VERSION=version。 - 否则如果
version≠LAST_VERSION(自己的回声/同值):只LAST_VERSION=version,不回写。
- 「获取 URL 内容」GET
- 「等待」
INTERVAL秒。
<当前时间 Unix 毫秒> 用「当前日期」→ 格式化为 Unix 毫秒时间戳。
局限(如实交代):iOS 无法监听剪贴板变化,「自动」本质是前台/驻留轮询、不保证真后台、需手动停止;完全没网时「获取 URL 内容」报错会中断循环(iOS 无 try/catch),需重新运行或配「连上 Wi-Fi 时」自动化重启。真正无感同步只能靠原生 App。