# iOS 快捷指令剪贴板同步 > **状态(2026-06-14):暂停。** 网页端「签发 token」界面已移除(组件 `web/src/features/shortcut/ShortcutTokens.tsx` 保留休眠、后端端点/令牌系统保留,便于将来重启)。本文余下内容为暂停前的设计与搭建规格,存档参考。 > > **暂停原因(两条硬伤,源自轮询模型)**: > 1. **频繁鉴权**——自动指令每 INTERVAL 轮询一次,每次都走一遍完整鉴权(HS256 验签 + 设备 upsert + 异步 touch)。 > 2. **设备列表被「Unknown Device」刷屏**——轮询打的 `GET /api/clipboard/version` **没带 `X-Device-Name`**,鉴权中间件遂用 UA 兜底名(含随机十六进制)登记设备,每次轮询生成一条新设备行。 > > 重启此路径前必须先解决:给所有快捷指令请求统一带 `X-Device-Name`(或服务端对 scoped 令牌固定设备名/跳过 upsert),并评估轮询频率对鉴权的压力(如令牌侧加轻量校验、或换长连/推送方案)。 借鉴 [SyncClipboard](https://github.com/Jeric-X/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 `。 ## 手动快捷指令搭建 在 iPhone「快捷指令」App 中手工新建两个指令,首次运行填入服务器地址与 token(用「文本」+「设定变量」持久化,后续动作引用变量)。两个指令都加请求头 `X-Device-Name: <你的 ASCII 设备名>`(仅 ASCII,如 `iPhone`),让它在设备列表里统一显示为一台设备。 ### 拉取(云端 → 本机剪贴板) 1. 「获取 URL 内容」:地址 `<服务器地址>/api/clipboard`,方法 GET,请求头加 `Authorization: Bearer `。 2. 「获取词典值」:取键 `content`。 3. 内容非空 →「拷贝到剪贴板」;可加「显示通知」提示已拉取。 ### 上传(本机剪贴板 → 云端) 1. 「获取剪贴板」;若为空则「停止并输出」。 2. 「获取 URL 内容」:地址同上,方法 PUT,请求头加 `Authorization: Bearer ` 与 `Content-Type: application/json`,请求体选 JSON:`{"content": 剪贴板, "content_type": "text/plain"}`。 3. 可加「显示通知」提示已上传。 将两个指令加到主屏 / 共享表单 / 轻点背面即可手动触发。 ## 网络容错 单次运行遇网络波动可能失败,重试即可——长效 token 不会因短暂离线失效。Web / 桌面端的实时通道(SSE)已改为**永不放弃的指数退避重连**(1s→3s→9s→18s,封顶 30s),短期断网自动恢复、不报错不退出。 ## 吊销 设置页「iOS 快捷指令」列表 →「吊销」。服务端在每次请求时校验 `revoked`,吊销后该 token 下次使用立即失效。 ## iCloud 分发:合并指令搭建规格(手动同步) 做**一个合并指令**(运行时菜单选 拉取 / 上传),配 **Import Questions** 在安装时问参数——服务器地址、设备名给默认值,安装者只需粘贴一次令牌。在 iPhone「快捷指令」App 里照下面搭,再「拷贝 iCloud 链接」分发;分享出的 iCloud 链接由 Apple 签名,安装无需开启「不受信任的快捷指令」。 ### 顶部:三个配置变量 1. 「文本」→ 内容 `https://drop.commilitia.net` →「设定变量」`BASE`。 2. 「文本」→ 内容占位(如 `token`)→「设定变量」`TOKEN`。 3. 「文本」→ 内容 `iPhone` →「设定变量」`NAME`。 (这三条「文本」的内容随后设为 Import Questions,安装者填 / 确认。) ### 菜单 4. 「从菜单选取」→ 两项:`从云端拉取`、`上传到云端`。 ### 「从云端拉取」分支 5. 「获取 URL 内容」: - 网址:插入变量 `BASE`,紧跟文本 `/api/clipboard`。 - 方法:`GET`。 - 标头:`Authorization` = 文本 `Bearer ` 后插变量 `TOKEN`;`X-Device-Name` = 变量 `NAME`。 6. 「获取词典值」:键 `content`,输入为上一步结果。 7. 「如果」上一步「有任何值」: - 「拷贝到剪贴板」= 上一步值;(可选)「显示通知」`已从 cdrop 拉取`。 - 否则:(可选)「显示通知」`云端剪贴板为空`。 ### 「上传到云端」分支 8. 「获取剪贴板」。 9. 「如果」剪贴板「有任何值」: - 「获取 URL 内容」: - 网址:变量 `BASE` 紧跟文本 `/api/clipboard`。 - 方法:`PUT`。 - 请求体:`JSON`,两字段——`content`(文本)= 变量「剪贴板」;`content_type`(文本)= `text/plain`。 - 标头:`Authorization` = `Bearer ` + 变量 `TOKEN`;`X-Device-Name` = 变量 `NAME`。(请求体选 JSON 时通常自动带 `Content-Type: application/json`,没有就手动加。) - (可选)「显示通知」`已上传到 cdrop`。 - 否则:(可选)「显示通知」`本机剪贴板为空`。 ### 配置 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`(无真无限循环,停靠手动结束运行)。 循环体: 1. `L` = 「获取剪贴板」。 2. **如果 `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 是否等于我发的」判接受/落败。) 3. **否则 → 廉价探测上游**: - 「获取 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,不回写。 4. 「等待」`INTERVAL` 秒。 `<当前时间 Unix 毫秒>` 用「当前日期」→ 格式化为 Unix 毫秒时间戳。 **局限(如实交代)**:iOS 无法监听剪贴板变化,「自动」本质是前台/驻留轮询、不保证真后台、需手动停止;完全没网时「获取 URL 内容」报错会中断循环(iOS 无 try/catch),需重新运行或配「连上 Wi-Fi 时」自动化重启。真正无感同步只能靠原生 App。