# cdrop — Commilitia Drop

跨 OS 剪贴板与文件传输服务。架构文档（`PROJECT_BRIEF.md` / `FRONTEND_DESIGN.md`）与 Changelog 位于 **`docs` 分支**：

```sh
git worktree add ../cdrop-docs docs          # 并列检出，main / docs 互不扰动
open ../cdrop-docs/PROJECT_BRIEF.md
open ../cdrop-docs/CHANGELOG.html
```

## 克隆

前端 `web/src/lib/cjk-autospace/` 为 submodule（聚珍 / Juzhen 2.0：CJK 综合排版——中西间隙 / 长英文断词 / 标点挤压 / 禁则 / justify，跨 Claude / Nvim / cdrop 共享）。首次 clone 须带 `--recurse-submodules`，否则 `vite build` 会因找不到 import 而失败：

```sh
git clone --recurse-submodules <URL> cdrop
# 已 clone 但未拉 submodule：
git submodule update --init --recursive
```

## 当前阶段

MVP（M0–M13）已上线 prod（OIDC PKCE 接入自建 Casdoor，Cloudflare Realtime TURN over TLS）。其后进入阶段二，已落地：

- **剪贴板同步**：单条覆写式云剪贴板（短 TTL 限暴露面）+ 文本消息一次性通道
- **桌面客户端**：Wails v2（macOS universal `.app` + Windows `.exe`）—— 瘦客户端复用 web，业务全走后端 API；refresh_token 存系统密钥库（信封加密）
- **iOS Shortcut**：HS256 scoped token 手动签发路径（网页签发 UI 暂停）
- **安全加固**：中继会话防耗尽、HS256 强制 jti、prod 强制 audience、TURN 短 TTL、OAuth 端点限流（详见 CHANGELOG）

MVP 里程碑（历史记录）：

| 里程碑 | 内容 | 状态 |
|---|---|---|
| M0–M5 | 后端：chi / sqlite WAL / dev+prod auth 中间件 / SSE Hub / signaling + 状态机 / Relay ring buffer | 完成 |
| M6–M10 | 前端：Mantine + TanStack Router + Zustand（其后叠加自定义设计 token 与 Theme B 重塑）/ 自实现 SSE / WebRTC P2P / Relay 回退 / SSE 重连 + chunk 重试 | 完成 |
| M11 | 本机双 tab 端到端验收（dev mock auth）+ 多轮 bug 修复（race / 状态机 / DOM nesting / 状态着色） | 完成 |
| M12 | OIDC PKCE：后端 /api/auth/{config,exchange,refresh} 代理 token endpoint，前端 PKCE redirect + callback；Casdoor confidential client + client_secret | 完成 |
| M13 | 部署：cdrop-base 缓存层 + 应用镜像（BUILDPLATFORM 跨编译，6 MB distroless），Cloudflare Realtime TURN App，反代 reverse_proxy + flush_interval -1（SSE 友好），h3 已启 | 完成 |

## 本机开发：两 tab 验收流程（M11）

### 1. 准备 dev token

把同一个随机 base64 串同时写到两份 env 文件：

```bash
TOKEN=$(openssl rand -hex 32)

cp .env.example .env
sed -i '' "s|replace-with-32-byte-random-base64|$TOKEN|" .env

cp web/.env.example web/.env.local
sed -i '' "s|replace-with-32-byte-random-base64|$TOKEN|" web/.env.local
```

`.env` 会被根目录的 Justfile 自动加载（`set dotenv-load := true`）。

### 2. 起后端（dev 模式）

```bash
just dev-back
# 监听 :8080，json 日志到 stdout
```

后端要求 `CDROP_AUTH_MODE=dev` 与 `CDROP_DEV_TOKEN` 同时设置；缺一会 panic 拒启动（防止误带到生产）。`.env` 文件由后端自动加载（koanf）。

### 3. 起前端（vite dev）

另开一个终端：

```bash
just dev-front
# 默认 http://localhost:5173，/api 自动代理到 :8080
```

### 4. 打开两个浏览器 tab

| tab | URL | 期望 |
|---|---|---|
| tab-1 | `http://localhost:5173/login?dev_user=alice` | 输入 `Continue` → Setup 输入设备名 `tab-1` → Home |
| tab-2 | `http://localhost:5173/login?dev_user=alice` | 同上但设备名填 `tab-2` |

两 tab 的 Home 页应在 1–2 秒内互相看到对方在线，顶部 `● online` 亮绿。

### 5. 验收 brief §7 五条

| # | 步骤 | 通过标准 |
|---|---|---|
| 1 | 双 tab Casdoor 登录 | M11 阶段使用 dev mock 等价：两 tab 都进 Home |
| 2 | Home 页看到对方在线 | DeviceStrip 互相显示对方卡片，绿点 |
| 3 | 拖拽文件 → P2P 直传 | tab-1 选 tab-2 + 拖文件 → Send；tab-2 浏览器自动下载，sha256 与原文件一致 |
| 4 | 模拟 NAT 阻塞 → fallback Relay | DevTools console 注入 `RTCPeerConnection.prototype._origCreateOffer = RTCPeerConnection.prototype.createOffer; RTCPeerConnection.prototype.createOffer = function() { return new Promise(()=>{}) }`（永远 pending），30s 后看 transfer 状态切到 `RELAY_ACTIVE`，依然下载成功 |
| 5 | 传输中关闭一方 → 重连续传 | M11 范围内可手动 kill backend 几秒再起；或断网卡 30s |

`> 100 MB` 文件不会自动接受（brief §2 阈值）；当前 MVP 还没接 UI 二次确认，可暂停在大文件场景验证。

### 6. 单元测试

```bash
just test            # go 测试
just typecheck       # 后端 go vet 由 just vet 触发
cd web && npm run typecheck && npm run build
```

## 部署

后端编译进单个 distroless 镜像（前端经 Dockerfile 构建后 `go:embed` 进二进制）。镜像约 6 MB。

### 构建镜像

```bash
# 依赖预热（仅 go.mod/go.sum 或 web/package*.json 变动时需重跑）：把 Go modules
# 与 node_modules 烤进 cdrop-base:latest，加速后续源码层构建。
just docker-base

# 应用镜像：从 cdrop-base 起步编前端 + 跨编译 Go，落 linux/amd64 distroless。
just docker-image            # → cdrop:latest
```

宿主原生 arch 即可（`BUILDPLATFORM` 让 Go 跨编译走原生，无需 qemu 执行 amd64）。

### 上线

镜像送到目标主机（二选一）：

```bash
# A. 本地构建 → 传镜像（无需在服务器装 Node/Go）
docker save cdrop:latest | gzip | ssh <user>@<host> 'docker load'

# B. 或把源码同步到服务器，在服务器上 docker build
```

服务条目模板见 [`docker/compose.snippet.yaml`](docker/compose.snippet.yaml)：填进你的 `docker-compose.yaml`，挂一个目录到 `/data`（存 SQLite 库），不开放端口、由反代访问 `<service>:8080`。

```bash
docker compose up -d <service>
curl -s https://<your-domain>/healthz      # {"status":"ok","auth_mode":"prod","db_ok":true}
```

### 反代（SSE 要点）

cdrop 用 SSE 推事件，反代**必须关闭响应缓冲**否则事件不实时。Caddy 示例：

```caddyfile
your-domain.example {
    reverse_proxy <service>:8080 {
        flush_interval -1          # 关闭缓冲，SSE 即时下发
    }
}
```

### prod 配置

env 见 [`.env.example`](.env.example) / `compose.snippet.yaml`。要点：

1. `CDROP_AUTH_MODE=prod`，删掉 `CDROP_DEV_TOKEN`
2. 填 `CDROP_OIDC_*`（你的 OIDC provider，如自建 Casdoor / Keycloak）；在 IdP 建 confidential + PKCE client，回调白名单加 `https://<your-domain>/oauth/callback`
3. **`CDROP_OIDC_AUDIENCE` 必须非空**（你的 client_id，多值逗号分隔）—— 留空会跳过 audience 校验，同一 JWKS 下其他应用的 token 也能验过；后端 prod 启动期强制此项，缺则拒启动
4. 设 `CDROP_HS256_SECRET`（iOS Shortcut scoped token 用，不用可留空）
5. 可选 `CDROP_CF_TURN_KEY_ID` + `CDROP_CF_TURN_API_TOKEN` 启用 Cloudflare Realtime TURN over TLS（不配则前端 STUN-only 兜底）

