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

11 KiB
Raw Blame History

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 的形态:两个 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,安装者填 / 确认。)

菜单

  1. 「从菜单选取」→ 两项:从云端拉取上传到云端

「从云端拉取」分支

  1. 「获取 URL 内容」:
    • 网址:插入变量 BASE,紧跟文本 /api/clipboard
    • 方法:GET
    • 标头:Authorization = 文本 Bearer 后插变量 TOKENX-Device-Name = 变量 NAME
  2. 「获取词典值」:键 content,输入为上一步结果。
  3. 「如果」上一步「有任何值」:
    • 「拷贝到剪贴板」= 上一步值;(可选)「显示通知」已从 cdrop 拉取
    • 否则:(可选)「显示通知」云端剪贴板为空

「上传到云端」分支

  1. 「获取剪贴板」。
  2. 「如果」剪贴板「有任何值」:
    • 「获取 URL 内容」:
      • 网址:变量 BASE 紧跟文本 /api/clipboard
      • 方法:PUT
      • 请求体:JSON,两字段——content(文本)= 变量「剪贴板」;content_type(文本)= text/plain
      • 标头:Authorization = Bearer + 变量 TOKENX-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_VERSIONBASELINE(本机当前内容)。初始化:BASELINE=获取剪贴板、LAST_VERSION=0。「重复」次数 99999(无真无限循环,停靠手动结束运行)。

循环体:

  1. L = 「获取剪贴板」。
  2. 如果 LBASELINE(本机被复制)→ 推送
    • 「获取 URL 内容」PUT BASE_URL/api/clipboard,体 JSON {content: L, content_type: "text/plain", origin_ts: <当前时间 Unix 毫秒>},标头 Authorization/X-Device-Name
    • 取响应 versionLAST_VERSION;取响应 content等于 LBASELINE=L(被接受);不等 → 「拷贝到剪贴板」=响应 content、BASELINE=响应 content(被拒,采纳胜出内容)。
    • (iOS 读不了 409 状态码,故靠「响应 content 是否等于我发的」判接受/落败。)
  3. 否则 → 廉价探测上游
    • 「获取 URL 内容」GET BASE_URL/api/clipboard/version(无 content)→ {version, source_device}
    • 如果 versionLAST_VERSION source_deviceNAMEGET 全量 /api/clipboard → 「拷贝到剪贴板」=content、BASELINE=content、LAST_VERSION=version。
    • 否则如果 versionLAST_VERSION(自己的回声/同值):只 LAST_VERSION=version,不回写。
  4. 「等待」INTERVAL 秒。

<当前时间 Unix 毫秒> 用「当前日期」→ 格式化为 Unix 毫秒时间戳。

局限(如实交代):iOS 无法监听剪贴板变化,「自动」本质是前台/驻留轮询、不保证真后台、需手动停止;完全没网时「获取 URL 内容」报错会中断循环(iOS 无 try/catch),需重新运行或配「连上 Wi-Fi 时」自动化重启。真正无感同步只能靠原生 App。