docs: 架构文档与变更记录

cdrop 的架构 / 协议 / 鉴权设计(PROJECT_BRIEF)、前端设计(FRONTEND_DESIGN)、变更记录(CHANGELOG)。

公开发布初始提交:完整文档历史留存于私有归档,公开分支自此干净起步。
This commit is contained in:
2026-06-15 21:49:23 +08:00
commit 19f8f7588e
4 changed files with 7321 additions and 0 deletions
+6607
View File
File diff suppressed because one or more lines are too long
+153
View File
@@ -0,0 +1,153 @@
<!DOCTYPE html>
<html lang="zh-Hans">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>cdrop — Changelog</title>
<meta name="generated-at" content="2026-06-14">
<meta name="source" content="cdrop · docs branch">
<!-- RUNTIME_CSS -->
</head>
<body>
<header class="page">
<div class="meta">
<h1>cdrop — Changelog</h1>
<p class="subtitle">时间倒序。细粒度迭代见 git 历史;本档只记录结构性 / 行为性变更与重要 fix。</p>
<p class="timestamp"><time>2026-06-14</time> · <span>docs branch</span></p>
</div>
<div class="actions">
<!-- ACTIONS -->
</div>
</header>
<main>
<p class="lede">本 Changelog 自 2026-05-22 起以 HTML 格式维护。此前之变更可从 main 分支 <code>git log</code> 取得;下表为近期较显著之 commit。</p>
<h2>2026-06-14 — 安全加固:中继会话防耗尽 + 令牌入系统密钥库 + 鉴权 / 限流多项收紧</h2>
<p>多 agent 安全审计后的一轮加固,按信任模型(信任账号所有者 + 服务端;不信任跨用户 / 网络 hop peer / 本机其他软件)逐项收紧。</p>
<ul>
<li><strong>中继会话防耗尽重构</strong>:中继改为「预登记」模型 —— 传输进入 <code>RELAY_ACTIVE</code> 时由服务端登记其两个合法参与方,之后仅已登记会话可接入、且调用方须为 sender / receiver。叠加 per-user 配额(3)+ 全局上限(8+ 空闲回收(2min+ ring 单 reader 断言。一举堵住「任意 id 凭空铸造会话耗尽全实例并钉 512 MiB」「幽灵会话」「id 泄露后无第二道防线」「双 reader 撕裂数据」四类问题</li>
<li><strong>桌面 refresh_token 入系统密钥库</strong>:长效 refresh_token 不再明文落盘,改存系统密钥库(macOS Keychain 经 <code>security</code> / Windows Credential Manager·DPAPI / Linux Secret Service),同机软件无法直读冒充;会话文件只留短效 access_token。遗留明文文件首次启动自动迁移并抹除</li>
<li><strong>HS256 token 强制 jti</strong>HS256 路径只签 clipboard 限域的快捷指令 token(必带 jti),无 jti 的有效签名一律拒签,把密钥泄露的爆炸半径压回 clipboard-only</li>
<li><strong>prod 强制 OIDC audience</strong>prod 下 <code>CDROP_OIDC_AUDIENCE</code> 为空即拒绝启动 —— 空 audience 会跳过校验、令同一 JWKS 下兄弟应用的 token 也能验过(跨 app 冒充)。<strong>部署要求</strong>:prod 须设该变量(web + 桌面 client_id 逗号分隔)</li>
<li><strong>请求面收紧</strong>:信令 / 文本消息加请求体上限(防 OOM);内置 TURN 凭据 TTL 由 24h 降至 1h(缩泄露窗口 24×);<code>/auth/exchange</code><code>/auth/refresh</code> 加 per-IP 限流(防把服务当 OIDC 暴力跳板)</li>
<li>commit <code>bcdc2b4</code>(中继 / HS256 / 密钥库 / body 上限)·<code>c536a35</code>audience / TURN TTL / 限流)</li>
</ul>
<h2>2026-06-14 — 桌面文件落盘可配置 + 传输列表行内操作 + macOS 下载兜底修正</h2>
<ul>
<li><strong>桌面端接收文件落盘到可配置目录</strong>(默认系统 <code>~/Downloads</code>):设置页加「下载目录」+ 原生目录选择框(<code>ChooseDownloadDir</code> → Wails <code>OpenDirectoryDialog</code>)。机制为 Go 绑定写盘 —— 桌面模式下 <code>downloadBlob</code> 改走 <code>SaveDownload</code>blob → base64 过桥 → Go 写盘),对端文件名经 <code>sanitizeFileName</code> 剥目录成分防路径穿越、重名加 <code>(n)</code>、落盘后 <code>open -R</code> / <code>explorer /select</code> 揭示</li>
<li><strong>macOS 下载兜底修正</strong>WKWebView 不处理 <code>&lt;a download&gt;</code><code>blob:</code>,原「失败回退浏览器下载」在 macOS 是空操作、会静默丢文件;改为写配置目录失败时自动改写系统 <code>~/Downloads</code>,连默认目录也失败才显式报错(<code>toast.error</code>),不再丢文件</li>
<li><strong>传输列表行内操作</strong>:每行加 —— 非终态「取消」(拆本端 P2P / relay + <code>POST /transfer/{id}/cancel</code>)、终态「删除」(从 active + history 移除)、发送方仍在试 P2P 时「立即中继」(清 30s ICE watchdog + <code>POST /transfer/{id}/fallback</code>,跳过等待)</li>
<li>双端桌面客户端(macOS universal <code>.app</code> + Windows <code>.exe</code>)重构并同步;web 端 prod 部署(<code>drop.commilitia.net</code></li>
<li>commit <code>43ddf36</code>(下载路径)/ <code>10a4c99</code>(传输 UX + 兜底修正)</li>
</ul>
<h2>2026-06-12 — 接入聚珍(Juzhen2.0 综合 CJK 排版:全角式 + 功能分级 + 本地优先字体</h2>
<ul>
<li>submodule <code>cjk-autospace</code> 升级 <code>a5faec5 → a3c94dd</code>:v1 自动空格 shim 从零重写为「聚珍(Juzhen)」2.0 综合排版引擎 —— 中西间隙 / 标点挤压(全角式)/ 禁则避头尾 / 垂悬字避免 / 长英文断词 / 功能分级;JS 只注入 <code>jz-*</code> 结构、视觉尺寸全在 <code>juzhen.css</code>(必配)</li>
<li><strong>功能分级</strong>:功能分文本级(中西间隙 / 标点挤压,任意文本片段适用)与段落级(禁则 / 垂悬字 / 长词断字,需行段布局,对单行 UI 文本无意义甚至有害)。薄壳 <code>level:{text:".juzhen"}</code> 令全树默认文本级(cdrop 绝大多数是 UI chrome);消息正文 / 剪贴板内容 / Callout / Toast 正文 / Field hint / 登录说明等 8 处流动正文加 <code>data-jz-level="paragraph"</code> opt-in 段落级,失败模式良性</li>
<li><strong>字体逐 region 明确化</strong><code>fonts.css</code><code>:lang()</code> 切 SC/TC/JP/KR):Sans Orbit Gothic CJKSerif Noto Serif CJK(本地合一)+ Noto Serif &lt;R&gt;(Web 子集、跨区并集覆盖);Mono Maple Mono + Orbit Gothic CJK</li>
<li><strong>本地优先字体层</strong>(新增 <code>fontface.css</code>):CDN 的 <code>@font-face</code><code>local()</code> 且 family 名与本机安装同名 → 被捕获、永远走网络。本层在 CDN <code>&lt;link&gt;</code> 之后用 <code>src: local(), url()</code> 重声明项目用到的字重,本机命中即零网络、否则回退 CDN(swap)</li>
<li>Badge 加固(children 包单个 inline span,防聚珍注入元素在 <code>inline-flex</code> gap 中各成 flex item 致 gap 叠加);术语「Pangu」统一改「聚珍」;commit <code>907d132</code> / <code>b3c74a6</code></li>
</ul>
<h2>2026-05-22 — README 补 clone 注意事项</h2>
<ul>
<li>新 clone 之 repo 预设不自动 fetch submodule<code>web/src/lib/cjk-autospace/</code> 为空时 <code>vite build</code> 会因找不到 import 而失败;错误信息虽含路径但不直观</li>
<li><code>README.md</code> 顶部加「克隆」小节,指引 <code>git clone --recurse-submodules &lt;URL&gt;</code> 或补拉 <code>git submodule update --init --recursive</code></li>
<li>commit <code>1443543</code></li>
</ul>
<h2>2026-05-22 — cjk-autospace 抽出为共用 submodule</h2>
<ul>
<li>把原内联在 <code>web/src/utils/cjkAutospace.ts</code> 的 Pangu shim 抽出为上游 git 仓库 <a href="https://git.commilitia.net/admin/cjk-autospace">cjk-autospace</a>(双 pass 架构:跨样式标签透明 + 行内块边界,commit <code>c3defc4</code></li>
<li>以 git submodule 引入 <code>web/src/lib/cjk-autospace/</code><code>cjkAutospace.ts</code> 缩为 ~50 行薄壳,只负责 cdrop 的 <code>pillSelector="code, kbd, .cjk-pill"</code> 配置 + React 下的 MutationObserver 增量观察(自反馈防护、<code>queueMicrotask</code> 防同 batch 重排)</li>
<li>单一真实源:lib 修改一次三端(HTML 模板 / Peek.nvim shim / cdrop)同步生效;本仓库 deploy 时 Vite 把 submodule 检出之文件当一般 TS / JS 模块打包,运行时不读 Dropbox 也不依赖 git 元数据</li>
<li>架构文档(<code>PROJECT_BRIEF.md</code> + <code>FRONTEND_DESIGN.md</code>+ Changelog 移到 docs 分支(本档)</li>
</ul>
<h2>历史变更(移转前未以本档记录)</h2>
<table>
<thead>
<tr><th>commit</th><th>说明</th></tr>
</thead>
<tbody>
<tr><td><code>aa80793</code></td><td>CJK 排版子系统:Pangu.js v7.2.1 端口 + cjk.css + Button label 包裹</td></tr>
<tr><td><code>4e8c541</code></td><td>前端视觉重塑后续迭代 + i18n 本地化清理</td></tr>
<tr><td><code>81dfbe1</code></td><td>前端重设计:tokens 变量化 + features-first + 原子组件 + 首页重画 + Toast + Dracula Pro 主题</td></tr>
<tr><td><code>6db6444</code></td><td>新增繁体中文(臺灣)+ locale 检测按 Hans / Hant + 区域子标签细分</td></tr>
</tbody>
</table>
</main>
<footer class="page">
<p>cdrop · docs branch · CHANGELOG</p>
</footer>
<!-- RUNTIME_SPRITE -->
<script type="text/markdown" id="md-source">
# cdrop Changelog
时间倒序细粒度迭代见 git 历史本档只记录结构性 / 行为性变更与重要 fix
## 2026-06-14 安全加固中继会话防耗尽 + 令牌入系统密钥库 + 鉴权 / 限流多项收紧
agent 安全审计后的一轮加固按信任模型信任账号所有者 + 服务端不信任跨用户 / 网络 hop peer / 本机其他软件逐项收紧
- **中继会话防耗尽重构**中继改为预登记模型 传输进入 `RELAY_ACTIVE` 时服务端登记两个合法参与方之后仅已登记会话可接入且调用方须为 sender / receiver叠加 per-user 配额3+ 全局上限8+ 空闲回收2min+ ring reader 断言堵住任意 id 铸造会话耗尽实例并钉 512 MiB幽灵会话id 泄露 reader 撕裂四类
- **桌面 refresh_token 入系统密钥库**长效令牌不再明文落盘改存 macOS Keychain / Windows DPAPI / Linux Secret Service同机软件无法直读会话文件只留短效 access_token遗留明文文件首次启动自动迁移并抹除
- **HS256 token 强制 jti** jti 的有效签名一律拒签把密钥泄露爆炸半径压回 clipboard-only
- **prod 强制 OIDC audience**`CDROP_OIDC_AUDIENCE` 为空即拒绝启动 audience 跳过校验 兄弟 app 跨冒充部署须设该变量web + 桌面 client_id 逗号分隔
- **请求面收紧**信令 / 消息加 body 上限TURN 凭据 TTL 24h 1h`/auth/exchange` + `/auth/refresh` per-IP 限流
- commit `bcdc2b4` · `c536a35`
## 2026-06-14 桌面文件落盘可配置 + 传输列表行内操作 + macOS 下载兜底修正
- **桌面端接收文件落盘到可配置目录**默认系统 `~/Downloads`设置页加下载目录+ 原生目录选择框机制为 Go 绑定写盘blob base64 过桥 `SaveDownload`文件名 `sanitizeFileName` 防路径穿越重名加 `(n)`落盘后揭示 Finder / Explorer
- **macOS 下载兜底修正**WKWebView 不处理 `<a download>` blob回退浏览器下载 macOS 空操作会丢文件改为配置目录失败时改写系统 `~/Downloads`仍失败才 `toast.error`
- **传输列表行内操作**非终态取消`/cancel`终态删除移除记录发送方立即中继 ICE watchdog + `/fallback`跳过 P2P 等待
- 双端桌面macOS universal `.app` + Windows `.exe`重构并同步web prod 部署
- commit `43ddf36` / `10a4c99`
## 2026-06-12 接入聚珍Juzhen2.0 综合 CJK 排版全角式 + 功能分级 + 本地优先字体
- submodule `cjk-autospace` 升级 `a5faec5 → a3c94dd`v1 自动空格 shim 重写为聚珍Juzhen2.0 综合排版引擎中西间隙 / 标点挤压全角式 / 禁则 / 垂悬字避免 / 长词断字 / 功能分级
- **功能分级**文本级间隙 / 标点挤压任意文本vs 段落级禁则 / 垂悬字 / 长词需行段布局薄壳 `level:{text:".juzhen"}` 全树默认文本级8 处流动正文加 `data-jz-level="paragraph"` opt-in 段落级
- **字体逐 region 明确化**`fonts.css`+ **本地优先字体层**新增 `fontface.css``local()` 优先覆盖 CDN url `@font-face`本机命中零网络
- Badge 加固术语 Pangu 聚珍commit `907d132` / `b3c74a6`
## 2026-05-22 README clone 注意事项
- `README.md` 顶部加克隆小节指引 `git clone --recurse-submodules <URL>` `git submodule update --init --recursive`避免 `web/src/lib/cjk-autospace/` 为空时 vite build 失败
- commit `1443543`
## 2026-05-22 cjk-autospace 抽出为共用 submodule
- 把原内联在 `cjkAutospace.ts` Pangu shim 抽出为上游 git 仓库 cjk-autospace pass 架构跨样式标签透明 + 行内块边界
- git submodule 引入 `web/src/lib/cjk-autospace/``cjkAutospace.ts` 缩为 ~50 行薄壳
- 架构文档 + Changelog 移到 docs 分支
## 历史变更
- `aa80793` CJK 排版子系统Pangu.js v7.2.1 端口
- `4e8c541` 前端视觉重塑后续迭代
- `81dfbe1` 前端重设计
- `6db6444` 新增繁体中文 locale 检测
</script>
<!-- RUNTIME_JS -->
</body>
</html>
+317
View File
@@ -0,0 +1,317 @@
# Commilitia Drop — 前端设计
补充 PROJECT_BRIEF.md,专注 web 前端实现。后端、协议、鉴权见主 brief。
> 本文记录 MVP 初版前端设计(Mantine + 浅色)。其后前端经「Theme B」重塑:自定义设计 token、品牌 web font、Dracula 深色、聚珍(JuzhenCJK 综合排版——视觉部分以 [`CHANGELOG`](CHANGELOG.html) 与实际代码为准。
---
## 1. 技术栈
```
Vite 构建
React 18 + TypeScript 框架
TanStack Router 路由(类型安全 > React Router
Zustand state 管理
Mantine v7 UI 组件库(CSS modules,零运行时开销)
@mantine/dropzone 文件拖拽
@mantine/notifications toast
Tailwind CSS utility 微调(不主导样式)
lucide-react 图标
原生 RTCPeerConnection P2P
```
**不引入** starter 模板。从 `npm create vite@latest -- --template react-ts` 起步,按需装包。
**不引入**MUI / Ant Designbundle 过大)、shadcn/ui(手搓成本与 MVP 速度冲突)、TanStack QueryMVP 阶段 SSE + Zustand 已够,需要时再加)、Storybook / i18next / Husky 等企业级配套。
---
## 2. 资源占用
前端是纯静态资源,部署在 Caddy 静态目录。服务器开销可忽略:
- gzipped bundle 估计 400-600 KB
- 服务器内存 / CPU 接近零(无 SSR)
- 真正吃资源的是后端 Go 进程的 ring buffer 和 SSE 长连接
构建在本地完成,部署只 push 静态文件。
---
## 3. 设计语言
中性、克制、工具感。参考点:
- **Linear**:留白、灰阶、克制的强调色
- **Cloudflare Dashboard**:信息密度合理的卡片式布局
- **Tailscale Admin**:设备列表、状态指示
避开:
- AirDrop 圆圈布局(不打算抄 Apple 的视觉语言,且圆圈在设备数 > 8 时退化严重)
- Snapdrop / PairDrop 极简主义(看着是玩具,不是工具)
- Material You(不适合自托管极简风)
**配色**:浅色优先,且 MVP 阶段**仅实现浅色**。深色模式属阶段三 UX 完善的范围,不在 MVP 内。Mantine 配置 `defaultColorScheme="light"`,不读取 `prefers-color-scheme`,不提供切换控件。
强调色 cdrop 蓝(`#3b82f6` 量级,待最终选定)。文本主色深灰(`#1f2937`),次级灰阶按 Mantine `gray.6 / gray.7` 走。背景纯白或近白(`#fafafa`)以减少屏幕反光强度。
### 字体与 CJK 排版
> 原 MVP 计划用系统字体栈、不引入 web font;前端重塑(Theme B)后改为品牌 web font CJK 综合排版。
**字体栈**`web/src/styles/fonts.css`,逐 region 由 `:lang()` 切 SC/TC/JP/KR`:root` 默认 SC,亦兼英文 / 无 lang 回退):
- **Sans**(正文):`Orbit Gothic CJK <R>` 系统回退
- **Serif**`Noto Serif CJK <R>`(本地合一、全字覆盖)→ `Noto Serif <R>`Google Fonts Web 子集,跨区并集补字)→ 系统
- **Mono**`Maple Mono` `Orbit Gothic CJK <R>` 系统
web font 由三个 CDN 的 `@font-face` 提供(`index.html` 三条 `<link>`),均 `font-display: swap`。因这些 `@font-face` 不含 `local()`、family 名又与本机安装同名 → 浏览器被 `@font-face` 捕获、永远走网络(同名本地副本不被用)。故 `web/src/styles/fontface.css` 加**本地优先覆盖层**:在 CDN `<link>` 之后、对项目用到的字重重声明 `@font-face``src: local(...), url(<cdn>)` —— 本机命中即零网络瞬时渲染,未装才回退 CDN(swap)。
**CJK 综合排版(聚珍 / Juzhen**submodule `web/src/lib/cjk-autospace`(上游 `git.commilitia.net/admin/cjk-autospace`;引擎机制见其 `README.md` / `ARCHITECTURE.md`)。`web/src/utils/cjkAutospace.ts` 为 cdrop 侧薄壳:
- `createJuzhen({ lang:{style:"quanjiao"}, scope:{include:".juzhen"}, level:{text:".juzhen"}, pillSelector:"code, kbd, .cjk-pill" })``render` / `revert` MutationObserver 增量(render 幂等)。
- **全角式**`quanjiao`):句内点号不挤、占全角,仅承载 justify 单份间距与行端收缩。
- **作用域** `.juzhen``#root` 加该类):处理与 normalize 收敛于此子树,`body` 上 Mantine portal 不受扰。
- **功能分级**:文本级(中西间隙 / 标点挤压,任意文本片段适用)全树默认跑;段落级(禁则避头尾 / 垂悬字避免 / 长英文断词,需行段布局)仅带 `data-jz-level="paragraph"` 的流动正文(消息 / 剪贴板 / Callout / Toast / 登录说明等)跑。cdrop 绝大多数是 UI chrome(短标签),故默认文本级、正文 opt-in,失败模式良性。
- **copy-clean**:注入的间隙 / 结构元素不入剪贴板,复制得紧凑原文。
---
## 4. 核心交互:选择目标设备
放弃圆圈布局,采用**单行可横向滚动的设备条**(接近 AirDrop iOS 分享面板)。要求同时支持两种顺序:
- **先选文件,再选设备**(拖入 → 高亮可用设备 → 点击设备发送)
- **先选设备,再选文件**(点击设备打钩 → 拖入或点击"选择文件" → 自动发送)
### 布局(Home 页)
```
┌──────────────────────────────────────────────────────────────┐
│ Commilitia Drop [Settings] [User] │
├──────────────────────────────────────────────────────────────┤
│ │
│ Devices (3 online) │
│ ┌──────────┬──────────┬──────────┬──────────┬───── │
│ │ ● │ ● │ ○ │ ● │ … │
│ │ MBA M4 │ Win-Desk │ iPhone │ Mac mini │ │
│ │ desktop │ desktop │ mobile │ desktop │ │
│ │ this dev │ ✓ │ │ │ │
│ └──────────┴──────────┴──────────┴──────────┴───── │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Drop files here, or click to browse │ │
│ │ │ │
│ │ Selected: report.pdf (4.2 MB) [×] │ │
│ │ │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ [ Send to Win-Desk ] ← primary CTA, 状态联动 │
│ │
├──────────────────────────────────────────────────────────────┤
│ Recent transfers │
│ • report.pdf → Win-Desk ✓ 2 min ago │
│ • photo.zip ← iPhone ✓ 5 min ago │
│ • video.mp4 → Mac mini × failed (timeout) │
└──────────────────────────────────────────────────────────────┘
```
### 设备卡片状态
每张卡片宽度约 120-140 px,固定不随屏幕拉伸。横向 `overflow-x: auto`,触控板 / 鼠标滚轮可推。
| 状态 | 视觉 |
|---|---|
| this device | 灰色边框 + "this device" 标签,不可选 |
| online idle | 白底 + 浅灰边框 + 绿点,可选 |
| online selected | 蓝色描边 + ✓ 角标 + 浅蓝底 |
| online incompatible(文件已选但目标设备 capability 不允许) | 灰底 + 50% 透明 + 不可选 |
| online incompatible(被选后用户拖入了不兼容文件) | 同上,自动取消选中 + Notification 提示 |
| online busy(已在收发) | 黄色环 + 进度数字 |
| offline | 灰底 + 50% 透明 + 不可选(默认隐藏,"显示离线"开关展开) |
每张卡片显示:状态点 + 设备名 + 类型小字。不显示头像 / 图标(避免维护设备图标资源)。设备名超长截断为省略号,hover 显示 tooltip 全名。
### 双向选择顺序的状态机
文件和目标设备分别独立可选,CTA 状态由两者组合:
```
state = (file: File | null, target: Device | null)
(null, null) → CTA: disabled "Select file and device"
(file, null) → CTA: disabled "Select a device"
(null, device) → CTA: disabled "Select a file"
(file, device, ok) → CTA: enabled "Send to {device.name}"
(file, device, !compat) → CTA: disabled "Cannot send {file.type} to {device.type}"
```
CTA 按钮永远可见在拖拽区下方,文案随状态变化。点击 CTA 即触发 `POST /api/transfer/initiate`
### 拖拽优化
- **Tab 键支持**:设备条可用方向键导航,Space 切换选中。无障碍是免费的,Mantine 默认就有
- **拖入页面任意位置**触发全屏 dropzone overlay,提示松手位置(默认行为继承 dropzone)
- 文件拖到具体设备卡片上 → 该设备自动选中并立即发送("快捷路径",跳过 CTA)。这是 AirDrop 没有的额外便捷
- 选了不止一个文件 → MVP 拒绝(提示"目前每次只支持一个文件")。多文件留到阶段三
### 为什么不用圆圈
- 圆圈布局在设备数 > 8 时退化为环形排列,视觉混乱
- 圆圈区分度低,必须靠头像区分设备,但头像维护成本高
- 单行卡片在桌面浏览器(cdrop 主要场景)上信息密度更合适
- 一行卡片可以承载更多信息(设备名 + 类型 + 状态 + 选中标),圆圈只能放头像和小字
- AirDrop 单行布局是 Apple 在分享面板里给出的样式,本身就是为"多目标快速选择"优化过的
---
## 5. 路由
```
/ Home(设备 + 拖拽 + 历史)
/login Casdoor PKCE 跳转中转
/oauth/callback Casdoor 回调
/setup 首次填设备名(设备名未设时强制跳转此页)
/settings 设备名修改、退出登录
后续:shortcut token 管理、外观(深色切换)
```
TanStack Router file-based 路由模式,类型安全 + 自动代码分割。
---
## 6. 状态管理
Zustand 一个 store,分 slice
```
useStore = {
auth: { tokens, user, login, logout, refresh }
device: { selfName, selfType, set, hydrate }
hub: { connected, devices, signal, sse-handlers }
transfer: { current, history, initiate, accept, cancel }
ui: { selectedDevice, selectedFile, notifications }
}
```
不引入 Redux 或 Jotai。Zustand 的 selector + `useShallow` 处理性能问题足够。
---
## 7. 组件清单
业务组件(自己写):
```
DeviceCard 单设备卡片(状态联动)
DeviceStrip 横向滚动设备条
FileDropzone 拖拽区(@mantine/dropzone 包一层)
SendCTA 主按钮(状态机文案)
TransferRow 历史记录单行
TransferProgress 进度条 / RingProgress
DeviceTypeBadge desktop / mobile / browser 标签
```
页面级:
```
HomePage
LoginPage
SetupPage
SettingsPage
OAuthCallbackPage
```
通用 lib(与组件无关):
```
lib/auth.ts PKCE flow, refresh, fetch wrapper
lib/api.ts 带 Authorization + X-Device-Name 的 fetch 封装
lib/hub.ts 自实现 SSE 客户端(fetch + ReadableStream
lib/p2p.ts P2PSession(裸 RTCPeerConnection
lib/relay.ts RelaySession(流式 GET + 分块 POST
lib/transfer.ts Orchestrator(决定 P2P / Relay / fallback
```
---
## 8. SSE 客户端要点
浏览器原生 `EventSource` 不支持自定义 header,所以不能直接带 `Authorization`。自实现:
```ts
const res = await fetch('/api/hub/events', {
headers: { Authorization: `Bearer ${token}`, 'X-Device-Name': name },
signal: abortController.signal
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
// 按 '\n\n' 切分 SSE 事件,按 'event:' / 'data:' 字段解析
}
```
重连:指数退避(1s → 2s → 4s → 8s → 16s 上限),断开时 UI 显示 "Reconnecting..."。
token 接近过期时(< 5min)先 refresh,再重建 SSE 连接,避免连接中途 401。
---
## 9. 性能 / Bundle 优化
- TanStack Router 自动代码分割:每个路由独立 chunk
- Mantine 按需 import,不要 `import { Button } from '@mantine/core/dist/...'`
- 使用 `vite-plugin-imp` 或依赖 Mantine 自身的 ESM tree-shakev7 默认就好)
- recharts / d3 不需要(cdrop 不画图)
- `react-helmet` 不需要(应用很简单,document.title 直接改)
构建产物期望:
```
index.html ~ 2 KB
assets/index.[hash].js ~ 250-350 KB (gzipped)
assets/index.[hash].css ~ 30-50 KB (gzipped)
其他 chunk 按路由懒加载
```
---
## 10. 浏览器兼容
目标:
- Chrome / Edge ≥ 110(涵盖 99% Chromium 用户)
- Firefox ≥ 110
- Safari ≥ 16.4macOS 13+ / iOS 16.4+WebRTC 完整支持)
不支持 IE / 旧 Edge / iOS 15 及以下。原因:WebRTC DataChannel 行为差异和 fetch streaming 兼容问题不值得投入精力。
---
## 11. 与主 brief 的对齐
§3 技术栈一行更新为:
```
**前端**Vite + React 18 + TypeScript / Mantine v7 + Tailwind / TanStack Router / Zustand / 原生 RTCPeerConnection / lucide-react
```
§7 MVP 验收标准的 "Home 页看到对方在线" 默认指本文件 §4 描述的设备条。
§8 阶段三 UX 完善中"暗色模式"对应本文件 §3 提到的 MVP 不实现深色——阶段三再做。
---
## 12. 你(Claude Code)需要决策的事
1. 设备卡片的具体尺寸、间距、状态色值,等 §3 强调色锁定后再敲定
2. CTA 按钮的具体文案(中文 vs 英文 vs 双语)。MVP 用英文,i18n 走阶段三
3. 历史记录显示几条 / 是否分页 / 是否支持过滤。MVP 默认显示最近 20 条
4. 有更好的设计参考时,可以反驳 §3 的方向选择
+244
View File
@@ -0,0 +1,244 @@
# Commilitia Drop — 跨 OS 剪贴板与文件传输服务
> 本文是项目最初的 MVP 规格(设计基线),保留作架构参考;**当前实现状态以 [`CHANGELOG`](CHANGELOG.html) 为准**——其后已落地剪贴板/消息通道、Wails 桌面端、安全加固等,部分「远期」项已实现。
项目代号 / 仓库名 / 二进制名:`cdrop`
部署域名:`drop.commilitia.net`
节点:自托管(单机 Docker + 反代)
---
## 1. 目标
跨 OSWin / macOS / Linux / iOS / Android)服务,提供:
1. 剪贴板同步(双向)
2. ad-hoc 文件传输(双设备均在线)
复用现有 Casdoor SSO,前置 Caddy。
---
## 2. 锁定的架构决策(不要重新讨论)
### 身份与鉴权
Casdoor 已为 cdrop 创建独立 application。Token 配置:
- `ExpireInHours = 1`access_token 1 小时)
- `RefreshExpireInHours = 196`refresh_token 约 8.16 天,sliding 行为已基于源码分析确认)
- 启用 grant types`authorization_code``refresh_token`
- 启用 PKCE
三条客户端路径:
- **浏览器**:标准 Casdoor OAuth + PKCE。access_token 存内存或 sessionStoragerefresh_token 存内存(不写 localStorage 防 XSS)。
- **Native client**desktop / mobile,远期):标准 Casdoor OAuth + PKCE + refresh_token rotation。系统浏览器或 loopback redirect 完成首次登录;token 存 OS keychainmacOS Keychain / Windows DPAPI / Linux Secret Service)。access_token 即将过期时(< 5 min)调 `/api/login/oauth/refresh_token` 换新 pair。Casdoor 每次 refresh 返回新 refresh_token,新 token 的 `exp` 重置(sliding 7-8 天窗口)。
- **iOS Shortcut**(远期):cdrop 自签 JWTHS256),长 TTL(1 年),不刷新。`shortcut_tokens` 表只存元数据(jti / scope / expires_at / revoked),不存 token 本身。
后端鉴权中间件统一处理:
1. 优先尝试 cdrop 自签 HS256 验签(命中 → Shortcut 路径)
2. 失败回退 Casdoor JWKS RS256 验签(命中 → 浏览器或 native client 路径)
不引入 caddy-security。
### 设备身份
`X-Device-Name` header 自报,UPSERT 到 `devices` 表。不做注册分配 UUID。
### 设备类型
| type | clipboard | file send | file recv |
|---|---|---|---|
| desktop | yes | yes | yes |
| mobile | yes | yes | yes |
| browser | no | yes | yes |
| ios_shortcut | yes | no | no |
同一物理设备的浏览器和 Shortcut 视作两个独立 device 记录。
### 传输层(h2/h3 偏好,禁用 WebSocket
WebSocket 在 Caddy + Go 当前栈下会被强制降级到 h1.1(RFC 8441 未实现),违背 h2/h3 偏好。改用:
- **Hub 通道**presence、信令、传输状态):SSEserver→client+ POSTclient→server
- 不用浏览器原生 EventSource(不支持自定义 header),用 fetch + ReadableStream 自实现 SSE 解析
- **Relay 通道**P2P 失败时):
- Receiver:流式 GETchunked transfer-encoding
- Sender:多次分块 POSTSafari / Firefox 不支持 streaming POST
- Server 端 in-memory ring buffer 桥接
### 文件传输
- **优先 P2P**WebRTC DataChannelDTLS 即 E2E
- 不用 simple-peer,自己包 RTCPeerConnection
- ICE 仅自建 STUN`stun:your-stun.example:3478`),不配 TURN(自建 coturn 跑 STUN-only;现已改用 Cloudflare Realtime TURN
- **P2P 失败 → Relay****不**做 Pipe 持久化邮箱)
- 双方均在线;传输中掉线重连最多 3 次
- 同 user 自动接受;> 100 MB 二次确认;不限文件大小,但中继有内存与并发上限
### 存储
- SQLitemodernc.org/sqlite,纯 Go 无 cgo
- 启用 WAL`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;`
- 中继不落盘,仅内存
### 权限
cdrop 服务侧不调 Casdoor enforce API。仅读 JWT 的 `groups` 数组自判。MVP 阶段所有合法登录用户视作普通用户,不分等级。
---
## 3. 技术栈
**后端**Go 1.22+ / chi / go-jose v4 / modernc.org/sqlite / koanf / sqlc
**前端**Vite + React + TypeScript / Tailwind / Zustand / 原生 RTCPeerConnection
代码风格:严格遵守 `~/.claude/rules/code-style.md`。Go 用 gofmtTS / JSX / JSON 按 Allman、数组内空格、运算符前置。
---
## 4. SQLite SchemaMVP
`devices``shortcut_tokens``transfer_sessions` 三表写入;`clipboard_state` 建表留空壳。
native client 不需要 cdrop 自管 token 表(直接验 Casdoor JWKS)。
```sql
CREATE TABLE devices (
user_id TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
last_seen INTEGER NOT NULL,
PRIMARY KEY (user_id, name)
);
CREATE TABLE shortcut_tokens (
jti TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
label TEXT NOT NULL,
scopes TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
last_used_at INTEGER,
revoked INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE transfer_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
sender_name TEXT NOT NULL,
receiver_name TEXT NOT NULL,
state TEXT NOT NULL,
mode TEXT,
file_name TEXT NOT NULL,
file_size INTEGER NOT NULL,
file_sha256 TEXT,
bytes_transferred INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
finished_at INTEGER,
fail_reason TEXT
);
CREATE TABLE clipboard_state (
user_id TEXT PRIMARY KEY,
content_type TEXT NOT NULL,
content TEXT,
source_device TEXT,
updated_at INTEGER NOT NULL
);
```
State machine: `PENDING → ACCEPTED → (P2P_ACTIVE | RELAY_ACTIVE) → DONE | FAILED | CANCELLED`
---
## 5. SSE 消息协议
**server → client** (event types):
- `presence` { devices: [ { name, type, online } ] }
- `signal` { from, payload }
- `transfer:incoming` { session, auto_accept }
- `transfer:state` { session_id, state }
- `transfer:relay_ready` { session_id, sender_url, receiver_url }
- `ping` (keepalive)
**client → server** (POST body):
- `{ type: "signal", to, payload }`
- `{ type: "transfer:fallback", session_id }`
---
## 6. 前置条件(缺失就问我)
1. OIDC application 详细参数(client_id、JWKS endpoint、issuer、audience、token format
2. 反代(Caddy 等)现状(版本、h3 是否启用、站点 block 是否已配)
3. STUN / TURN 是否已就绪
4. DNS 是否已指向部署节点
---
## 7. MVP 范围
**只做 Web 文件传输端到端**
不做:客户端(任何形态)、剪贴板逻辑、用户间共享、Pipe 模型、历史 UI、audit log 持久化、i18n。
验收标准:
1. 两台桌面浏览器 Casdoor 登录
2. Home 页看到对方在线
3. 拖拽文件 → P2P 直传成功
4. 模拟 NAT 阻塞 → 自动 fallback Relay 成功
5. 传输中关闭一方 → 重连续传;3 次失败终止
MVP 阶段只用浏览器路径。Native client 和 Shortcut 路径在 §2 已锁定但属于后续阶段。
---
## 8. MVP 后开发计划
### 阶段二:剪贴板同步
`clipboard_state` CRUD + Hub 推送;桌面客户端(Tauri 候选,前端复用,Casdoor OAuth 走系统浏览器 + loopback redirect);iOS Shortcut 模板生成(启用 cdrop 自签 token 路径);Android 客户端原型。
### 阶段三:UX 完善
Inbox 历史、设备管理、多文件 / 文件夹、PWA 化、通知、i18n、暗色模式。
### 阶段四:生产化
audit log 持久化、限流、Prometheus 指标、健康检查、SQLite → PostgreSQL 迁移路径、配额、备份、安全审计。
### 阶段五:扩展能力(远期)
用户间共享、命名 pipe、原生客户端、WebTransport 切换、E2E Relay。
---
## 9. Plan 模式输出要求
至少包含:
1. §6 前置条件的获取方案(哪些自己探测、哪些问我)
2. MVP 实施计划(粒度自定)
3. 最高风险点 + 缓解思路
4. 本 brief 中你认为不清楚或需要决策的地方
---
## 10. 协作方式
你直接对我负责。可自然决策的部分不必复述。
允许向我提出**任何**合理建议——包括质疑本 brief 中的决策、提出更好的实现方式、要求补充信息、推迟某个里程碑等。我接受被反驳。
红线:
- 不跳过 Plan 直接编码
- 不重新讨论 §2 已锁定的架构(除非有强证据)
- 增加未列出依赖前先确认
- 不把 MVP 扩大到剪贴板或客户端