# 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 <token>`。

## 手动快捷指令搭建

在 iPhone「快捷指令」App 中手工新建两个指令，首次运行填入服务器地址与 token（用「文本」+「设定变量」持久化，后续动作引用变量）。两个指令都加请求头 `X-Device-Name: <你的 ASCII 设备名>`（仅 ASCII，如 `iPhone`），让它在设备列表里统一显示为一台设备。

### 拉取（云端 → 本机剪贴板）

1. 「获取 URL 内容」：地址 `<服务器地址>/api/clipboard`，方法 GET，请求头加 `Authorization: Bearer <token>`。
2. 「获取词典值」：取键 `content`。
3. 内容非空 →「拷贝到剪贴板」；可加「显示通知」提示已拉取。

### 上传（本机剪贴板 → 云端）

1. 「获取剪贴板」；若为空则「停止并输出」。
2. 「获取 URL 内容」：地址同上，方法 PUT，请求头加 `Authorization: Bearer <token>` 与 `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。
