Files
Commilitia-Drop/docs/ios-shortcut.md
T
admin f21fa5b5e8 cdrop — 跨 OS 剪贴板与文件传输服务
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)。

本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
2026-06-15 21:38:28 +08:00

149 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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。