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)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
# 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。
|
||||
Reference in New Issue
Block a user