# cdrop 桌面客户端实施计划

> Wails v2 · macOS 优先 + Windows 已落地
> 状态：**macOS 与 Windows 双端均已实现并 RDP/真机实测通过**——A2 前端复用 + 托盘/菜单栏 + 剪贴板双向同步 + 桌面设置 + session 持久化 + 开机自启（含自启静默驻留）+ 设备类型登记。macOS 见 §10，Windows 落地（含「custom-scheme 不支持流式」核心坑与 127.0.0.1 本地代理解法）见 §11。计划已据原生平台研究 sweep（`desktop/RESEARCH.md`）校正。
> 关联：根 `README.md`、`desktop/`（脚手架）、`desktop/RESEARCH.md`（调研依据），记忆 `cdrop-desktop-client` / `cdrop-deploy-pointers` / `cdrop-state`。

---

## 实现进度（截至 2026-06-13）

| 部分 | 状态 | commit |
|---|---|---|
| 后端 OIDC audience 多值（R1） | ✅ 已提交（现非必需、保留无害） | `36abec1` |
| 后端 `/api/auth/config` 发布 `token_url` | ✅ 已提交 + **已部署 prod** | `a393b5d` |
| **D1 OAuth loopback 登录**（复用 web client + PKCE + Refresh） | ✅ 已实现，**登录真机 e2e 通过** | `c876413` |
| D4 剪贴板双向策略层 + 认证 API 客户端 | ✅ 已实现（23 单测全绿，尚未接入 app） | `516d3b2` |
| **桌面 app 主体（§10）**：A2 复用 + 菜单栏/dock + 剪贴板接入 + 设置 + 本地配置 | ✅ 已实现（本地全绿，待真机勾验菜单栏/剪贴板/自启） | 本轮待提交 |
| **登录 session 本地持久化（§10.7.1）**：Go 文件 + 启动注入 + 同步水合 | ✅ 已实现，**重启免登真机跑通** | 本轮待提交 |
| **凭据安全一致化（§10.8）**：refresh_token 全平台不进 JS | ✅ 已实现（绑定层 grep 零命中） | 本轮待提交 |

**复用 web client（暂行决策）**：桌面端不建独立 Casdoor client，复用 web 的 confidential + PKCE client（与 web 同一 client_id）做 loopback PKCE（不送 secret），靠长寿命 refresh token 维持。token 的 aud = web client_id = 后端 `OIDCAudience`，故 R1 多值非必需。

**待实现 / 待验证**：① 真机勾验（本环境无 GUI 不可达）——菜单栏三项、dock 动态隐藏、剪贴板跨设备上下行、开机自启、refresh，清单见 §10.6；② D3 系统通知（未做）；文件落盘已落地（桌面收文件写入可配置目录，默认 `~/Downloads`，见 §D3 落盘）；③ D5 全局快捷键 + Quick Send 浮窗（受 v2/v3 决策闸，未做）；④ token 持久化升级 Keychain（推迟到 Developer-ID 签名后，§10.8）；⑤ Windows/Linux 原生 Source / 托盘 / 自启目前为 stub。

---

## 0. 范围、非范围与本轮关键决策

**范围**：常驻桌面客户端，补浏览器做不到的——后台常驻、剪贴板自动监听上传、文件落盘、系统通知、全局快捷键。平台 **macOS（Apple Silicon + Intel）优先**，**Windows（x64）保持兼容**（同源、同分支，平台差异用 Go build tag 隔离）。

**非范围（推后）**：Linux 打包、iOS / Android 原生、自更新（仅版本检查提示）、产品化 / 多租户、Admin、Symmetric NAT 的 mDNS 直连、本地内容缓存 / 离线。

**定位**：桌面端不是“另一份 UI”，而是“带原生外壳的同一份 UI”——UI 复用 `web/src` 的 React 应用，价值全在 Go 外壳提供的原生能力。

### 决策 C：先做框架无关里程碑，推迟 v2/v3 取舍（2026-06-13 定）

sweep 发现 Wails v2 缺两样桌面核心能力：**无内建系统托盘**、**单原生窗口**（见 §4 D2 / D5、§9）。是否升级 Wails v3-alpha 是悬而未决的架构岔路。**本轮决定：先在 v2 上推进与窗口框架无关的 D1（OAuth）+ D3（通知）+ D4（剪贴板），把 v2/v3 取舍推迟到真正动 D2（托盘）/ D5（多窗）之前。** 理由：这三个里程碑在 v2 / v3 上实现差异不大，能先产出价值，同时让 v3 再成熟一截。

### 签名决策：暂不申请 Developer 证书（2026-06-13 定）

暂不申请 Apple Developer 证书。macOS 产物仅 **未签名 / ad-hoc 签名的 `.app`**，**不公证、不封装 DMG**。下游影响（须正视）：

- 分发侧 macOS Gatekeeper 会拦“未知开发者”，自用需右键打开或 `xattr -dr com.apple.quarantine`。
- **D3 macOS 系统通知（`UNUserNotificationCenter`）要求 app 有 `CFBundleIdentifier` 且“已签名”**——未签名不工作。无 Developer 证书时须用 **ad-hoc 签名**（`codesign -s - --force --deep YourApp.app`）补一个本地有效签名 + bundle id；ad-hoc 是否足以让本机通知工作**须真机实测**（§4 D3）。
- `desktop/RESEARCH.md` 的“打包·签名·公证手册”留作**未来若申请证书**时的参考；当前路径以本文 D6 为准。

---

## 1. 技术栈与总体架构

### 1.1 选型

| 维度 | 决定 | 理由 |
|---|---|---|
| 框架 | **Wails v2.12.0**（Go + 系统 WebView） | 系统 WebView 省内存；Go 单语言写原生；单 codebase |
| 待评估 | **Wails v3-alpha**（见决策 C / §9） | 原生覆盖 systray + 多窗口 + ActivationPolicy；D2/D5 前定夺 |
| 淘汰 | Electron / Tauri / 原生双栈 / PWA | Electron 200+ MB；Tauri 多一门 Rust；原生双 codebase；PWA 无后台常驻 |
| 资源预算 | macOS idle 80–120 MB；Windows idle 100–150 MB；包体 10–20 MB | WebView 复用系统组件 |

### 1.2 瘦客户端架构

- **全部业务走 `drop.commilitia.net`**：桌面端沿用 Web 的 API 客户端，本地只存 token + 少量设置。
- **Go 外壳只做四类原生事**：①把 OS 事件源（剪贴板变更、热键、OAuth 回调）翻译成事件推给 WebView；②把 WebView 的请求（开系统浏览器、写文件、发通知）翻译成 OS 调用；③后台常驻；④打包。

### 1.3 与后端的契约（桌面端用到的端点）

沿用 Web 现有 API（`web/src/net/api.ts` 注入 `Authorization: Bearer`，401 懒刷新）：

| 端点 | 用途 | 里程碑 |
|---|---|---|
| `GET /api/auth/config`、`POST /api/auth/exchange`、`POST /api/auth/refresh` | OIDC 配置 / 换 token / 刷新 | D1 |
| `GET/PUT /api/clipboard` | 云剪贴板读写 | D4 |
| `GET /api/hub/events`（SSE） | 服务端推送（新剪贴板 / 消息 / presence） | D3 / D4 |
| `POST /api/message` | 文本消息 | D5 Quick Send |
| `POST /api/transfer/initiate`（+ `/api/hub/signal`、`/api/calls/credentials`） | 文件传输 / WebRTC | 后续（非 MVP 主线） |
| `POST /api/me/disconnect` | 登出即时下线 | D2 退出 |

> D1 例外：桌面端**不复用** `/api/auth/exchange`（它是 confidential 代理、附 web client_secret），改为直连 Casdoor token 端点做 public-client PKCE。详见 §3。

---

## 2. 前端复用 + 平台抽象层

### 2.1 前端复用（方案 A2）

D0 脚手架的 `desktop/frontend` 是 Wails 默认 vanilla-ts 模板，**尚未指向 `web/src`**。D1 第一步改为复用现有 React 应用：

- **A2（采用）**：`desktop/frontend` 作薄入口，import `web` 为本地包（npm workspace / `file:` 依赖）。`web` 暴露可复用入口 `mountApp(root, platform)`，desktop 入口传 `platform: "desktop"` 适配器。隔离清晰、桌面专属代码有家、web 不被污染。
- 否决：A1（`frontend` 直指 `../web`，桌面专属代码无处落脚）、A3（复制 web，分叉地狱）。

### 2.2 平台抽象层（Platform 接口）

Web 与桌面在四处行为不同，UI 只依赖 `Platform` 接口，web / desktop 各给一份实现：

| 能力 | Web 实现 | Desktop 实现（经 Wails bind 调 Go） |
|---|---|---|
| **登录** | `window.location.href` 整页跳 authorize | 系统浏览器 + loopback 回调 + 注入（§3，D1） |
| **token 存储** | zustand store | Go 侧 Keychain / DPAPI，启动注入 |
| **通知** | Web Notification（前台限定） | 系统通知中心（后台也行，D3） |
| **文件落盘** | 浏览器下载 | Go 写可配置目录（默认 `~/Downloads`）+ 揭示 Finder（✅ 已落地） |

> 铁律：**WebView 内绝不 `window.location.href` 跳外部 authorize**（会把 WebView 自身导航走、应用白屏，且违反 RFC 8252 §8.12 嵌入式 user-agent 禁令）。登录走系统浏览器 + 原生回调桥。

---

## 3. D1：OAuth 登录闭环（已据 sweep 定型）

sweep 把 D1 从“最高风险未知”变成“路径已明、含一处后端改动”。完整客户端机制 + 代码骨架见 `desktop/RESEARCH.md` 的“OAuth 原生回调通道——Wails 客户端机制”。要点：

### 3.1 回调通道：loopback（已定，弃 scheme）

采用 **RFC 8252 loopback**：Go 起临时 `127.0.0.1:0`（必须 IPv4 字面量，不用 `localhost`）HTTP server 捕获 `code`。相对自定义 scheme `cdrop://` 的优势：免单实例锁、免 `Info.plist` / 注册表登记、免深链解析、跨平台代码一致。scheme 留作未来“浏览器点 cdrop 链接唤起桌面端”的另一用例。

### 3.2 token 交换放 Go 侧（已定）

PKCE `verifier` 与换得的 token 全程不进 WebView / JS 上下文（防注入脚本窃取），并由 Go 直接持久化进 Keychain / DPAPI。前端只收“登录成功 / 失败”事件（`runtime.EventsEmit` → 前端 `EventsOn`）。

### 3.3 R1：后端 / Casdoor 协调（最小一处改动，已解）

sweep 源码级确认：Casdoor 支持 public client（PKCE 时 `client_secret` 可空）、对 `http://127.0.0.1` 任意端口自动放行（`IsValidOrigin` 短路，需 Casdoor ≥ ~v3.3）。桌面直连 Casdoor 端点：authorize=`/login/oauth/authorize`，token=`/api/login/oauth/access_token`（**非**标准 `/oauth/token`）。

**唯一后端改动（方案 A，推荐）**：`CDROP_OIDC_AUDIENCE` 从单值扩成逗号分隔多值（web + desktop 两个 client_id），`internal/jwtauth/middleware.go:150` 展开进 `AnyAudience`（go-jose 语义“命中其一即可”）。issuer / JWKS / 签名校验与 web 完全一致，不动。

**前置核实**：prod 自建 Casdoor 版本须 ≥ ~v3.3.0；低于则升近版。

### 3.4 D1 任务清单

- [ ] 前端复用落地（§2.1 A2）：`web` 暴露 `mountApp(root, platform)`；`desktop/frontend` 薄入口。
- [ ] `Platform` 接口 + web / desktop 双实现（先只做 login 一处）。
- [ ] Casdoor 新建 public application `cdrop-desktop`（JWT、无 secret、不勾 shared、登记 loopback redirect 兜底）。
- [ ] 后端：`CDROP_OIDC_AUDIENCE` 改多值（方案 A）。
- [ ] Go：loopback server + `runtime.BrowserOpenURL` + state 校验 + token 交换 + `EventsEmit`；token 持久化（Keychain / DPAPI）。
- **验收**：冷启动 → 点登录 → 系统浏览器授权 → 自动回应用已登录；杀进程重启仍登录；refresh 跑通。dev 模式（`VITE_CDROP_DEV_TOKEN`）可先绕 Casdoor 打通壳。

---

## 4. D2–D6 详解（含 sweep 校正）

### D2 · 菜单栏 + 窗口生命周期 ⚠️ 受 v2/v3 决策影响
- **sweep 校正（refuted）**：**Wails v2 无任何可用内建托盘 API**（`options.App` 无 `Tray`、runtime 无托盘函数、`pkg/menu/tray.go` 是 onhold 死代码、Issue #4990 官方 closed not planned）。原计划“Wails 原生托盘”不成立。
- **窗口生命周期（v2 原生可做）**：`StartHidden:true` + `HideWindowOnClose:true` 实现“启动驻留、关闭隐藏不退出”（与 `OnBeforeClose` 钩子二者互斥，不可同设）。
- **托盘图标 + 菜单（必须第三方 / 自写）**：macOS 先验证 `github.com/ra1phdd/systray-on-wails`（非阻塞 `Register`、规避 NSApplication 冲突），不达标则自写 ObjC `NSStatusBar + NSMenu`；`getlantern/systray` 在 macOS+Wails 有 objc 链接冲突，排除。Windows 用 `fyne.io/systray`。
- **隐藏 dock 图标**：`mac.ActivationPolicy` 在 v2.12 不可用；改在 `desktop/build/darwin/Info.plist` 手加 `<key>LSUIElement</key><true/>`（启动瞬间 dock 闪现约 10ms 是系统通病、无法消除）。
- **决策闸**：D2 是 v2/v3 取舍的触发点（见 §9）。若决定上 v3，D2 用 v3 原生 systray 重做。

### D3 · 系统通知 + 文件落盘 + SSE 接入
- **SSE 由 Go 持有**（非 WebView）：后台常驻、窗口隐藏仍收推送、免 WebView 休眠断流。事件分流：要弹通知的走 Go 通知 API，要更新 UI 的经 `EventsEmit` 推 WebView。
- **macOS 通知**：`UNUserNotificationCenter`（cgo + ObjC）。三按钮 = `UNNotificationCategory` 含 3 个 `UNNotificationAction`，点击经 delegate `didReceiveResponse` 区分。**硬门槛（与签名决策冲突，须正视）**：要求已签名 bundle + `CFBundleIdentifier`，且须从 `.app` 运行——无 Developer 证书时须 ad-hoc 签名补救，**ad-hoc 能否让本机通知工作须真机实测**；不行则 macOS 通知降级（仅前台 toast）或推迟到将来获证书。
- **Windows 通知**：`go-toast/v2`（已在 go.mod）。`Notification.Actions` 加 3 按钮、`SetActivationCallback` 按 `Arguments` 区分。坑：须先 `SetAppData` 注册 AUMID + stub CLSID，否则 COM 激活失效、回调收不到。
- **落地节奏**：先做“单击整条通知拉主窗 + 打开文件”基础版，三按钮作增量。
- **落盘** ✅ 已落地（2026-06-14，Go 绑定写盘）：单文件 `platform/download.go`（非按平台分），`SaveDownload` 写入可配置目录（`DesktopConfig.DownloadDir` 空＝系统 `~/Downloads`），`sanitizeFileName` 剥目录成分防路径穿越、`uniquePath` 重名加 ` (n)`、`revealInFileManager`（`open -R` / `explorer /select`）。桌面端 web 的 `downloadBlob` 改走绑定 `SaveDownload`（blob→base64 过桥），失败回退浏览器下载；设置页可改目录（`ChooseDownloadDir` 原生选择框）。

### D4 · 剪贴板自动同步（核心价值）
- **统一底层 `golang.design/x/clipboard` v0.8.0**（macOS 已 purego/objc **无 cgo**；Windows / Linux 纯 syscall）。**sweep 校正 PLAN 旧假设**：① macOS 不是 3s，是该库写死 1s 轮询 `changeCount`；② **Windows 也是轮询**（`GetClipboardSequenceNumber` 1s），**不是** `AddClipboardFormatListener` 事件——两端机制其实一致。若坚持 Windows 真事件，可自写 syscall message-only window，但建议先轮询上线（剪贴板同步对 1s 不敏感）、按真机反馈再定。
- **自写一层 `ClipboardSource` 接口**做库不负责的三件事：
  1. **敏感内容过滤**（库不做，直接用会同步密码）：macOS 读 `-types` 比对 `org.nspasteboard.ConcealedType` / `TransientType` / `AutoGeneratedType` 及私有 UTI；Windows 读后 `EnumClipboardFormats` 查 `ExcludeClipboardContentFromMonitorProcessing` / `CanUploadToCloudClipboard`，命中跳过上传。
  2. **回环防护**（R4）：自写剪贴板会自增计数触发自己的 Watch 风暴；写入前记 hash + 自写序号，Watch 收变更先比对跳过。
  3. **去重 + debounce**：复用已在 go.mod 的 `github.com/bep/debounce` + 内容 hash。
- **写回本机剪贴板**优先用库的 `Write`（规避 Wails #4132：macOS LANG 为空时编码 bug）。
- **go 版本**：该库 go.mod 要求 go 1.24，`desktop/go.mod` 声明 1.23——引入需把模块声明升 1.24+（本机 toolchain 已满足）。
- **三按钮通知**：新剪贴板到达 →“复制到本机 / 忽略 / 打开”。
- **⚠️ D4 最大不确定项（须真机实测）**：**macOS 26（Tahoe）剪贴板隐私授权**——非用户 paste UI 触发的程序化读取会弹授权 Alert，正中“后台自动监听上传”要害。Apple 新增 `detectPatterns/detectValues`（只探测类型不读内容、不弹窗）但该库未适配。须在 macOS 26 真机验证：纯 `changeCount` 轮询 vs 读内容 各自的弹窗行为。

### D5 · 全局快捷键 + Quick Send 浮窗 ⚠️ 受 v2/v3 决策影响
- **热键（已定）**：`golang.design/x/hotkey` **pin v0.4.1**（Carbon `RegisterEventHotKey`，对 `Cmd+Shift+V` 零授权弹窗）。**勿用最新 v0.6.x**——它改用 CGEventTap，每个热键都强制 Accessibility 授权，开机弹窗劣化体验；且 v0.5.0+ 要 go 1.24，v0.4.1 仅 go 1.17。**不调 `mainthread.Init`**（Wails 已占主线程 run loop）。Windows 走 `RegisterHotKey`，零 cgo、无权限。
- **Quick Send 浮窗（sweep 校正）**：**Wails v2 单原生窗口**，开不了第二个原生窗。v2 内只能“同窗 HTML 浮层 / 路由切换”近似；“失焦自隐”v2 无现成钩子、要自写 ObjC `NSWindowDelegate windowDidResignKey`（v2 路线最脏一块）。**这是上 v3 的最强理由**（v3 多窗口每窗一等对象）。
- 产品化时建议热键可重绑（规避撞车）。

### D6 · 打包（无证书：仅 `.app`）
- **macOS（当前路径）**：`wails build -platform darwin/universal` 产出 `build/bin/<App>.app`（`lipo` 合并 arm64 + amd64）。**不公证、不 DMG、无 Developer ID**。为满足 D3 通知与稳定身份，做 **ad-hoc 签名** `codesign -s - --force --deep <App>.app` + `Info.plist` 带 `CFBundleIdentifier`。分发靠手动（右键打开 / 去 quarantine）。
- **entitlements（若 ad-hoc 也带）**：非沙箱只需 `com.apple.security.network.client`；purego dlopen 系统 framework **不需** `disable-library-validation`；Carbon 热键不需 entitlement。（不要列通知 / 剪贴板 / 热键 entitlement——过度声明。）
- **Windows**：`wails build -platform windows/amd64 [-nsis]` 产出 `.exe` / NSIS 安装器；当前**不签名**（SmartScreen 会提示），获代码签名证书后再 `signtool`。
- **版本检查**：启动 / 定时拉版本端点，过期提示去下载，**不自更新**。
- **将来获证书**：完整 codesign（Developer ID）+ notarytool + DMG + CI 流程见 `desktop/RESEARCH.md` D6 手册，届时直接套用。
- **CI**：D6 后 GitHub Actions 双 runner（macos-latest / windows-latest）跑构建 + 防回归（无证书阶段仅构建 + ad-hoc，不跑公证）。

---

## 5. 跨平台代码组织（防分叉硬约束）

```
desktop/
  main.go               # Wails 入口（窗口 / bind / 托盘装配）
  app.go                # App struct + 暴露给 WebView 的方法（bind：StartLogin 等）
  platform/
    oauth.go            # loopback server + PKCE（平台无关）
    clipboard.go        # ClipboardSource 接口 + 公共逻辑（过滤 / 去重 / 回环防护）
    clipboard_darwin.go //go:build darwin
    clipboard_windows.go //go:build windows
    tray_darwin.go / tray_windows.go
    hotkey_darwin.go / hotkey_windows.go
    notify_darwin.go / notify_windows.go
    download_darwin.go / download_windows.go
    secrets_darwin.go   # Keychain
    secrets_windows.go  # DPAPI / Credential Manager
  frontend/             # 薄入口 + Platform desktop 实现，UI import 自 web
```

**铁律**：①平台代码全用 build tag，同 monorepo 同分支，**绝不开 windows-branch**；②Windows（Parallels Win11 ARM VM）只 `git pull`、**从不 commit**，Windows-only 操作封进 `just` target；③ARM Win11 跑 x64 emulator 性能 -20–40%，**不能跑资源 / 性能基准**，D6 借真 x64 PC 锁数字；④D6 后 CI 防退化。

---

## 6. 安全审查

| 面 | 风险 | 缓解 |
|---|---|---|
| **token 存储** | 明文落盘即被盗即接管 | macOS Keychain / Windows DPAPI；不写明文文件 / localStorage |
| **OAuth 回调** | scheme 劫持 / CSRF | 用 loopback（无 scheme 抢注面）；强制 PKCE S256 + state 校验；token 交换走 HTTPS 直连 Casdoor |
| **剪贴板隐私** | 自动上传密码 / 验证码 | 自写 ClipboardSource 做敏感类型过滤（§4 D4）；默认策略待定；提供暂停 + 排除 |
| **更新** | 无自更新 = 不引入更新通道劫持面 | 仅版本检查提示 |
| **无签名分发** | 用户绕过 Gatekeeper 习惯被培养、易被仿冒 | 自用 / 小范围可接受；公开分发前应申请 Developer 证书签名公证（见 RESEARCH.md） |
| **WebView ↔ Go 桥** | 暴露的 Go 方法被 XSS 滥用 | bind 方法最小化 + 参数校验；只加载本地 embed 资源 + 固定 API origin |

---

## 7. 验证与测试策略

- **每里程碑**：`just desktop-build-mac` 冷 / 增量、`desktop-build-win` cross-compile 双绿，再真机手测（各 D 的“验收”）。
- **真机实测清单（sweep 标注，资料无法定论）**：① macOS 26 剪贴板隐私授权对轮询的行为（D4）；② ad-hoc 签名能否让 `UNUserNotificationCenter` 本机通知工作（D3）；③ hotkey v0.4.1 在 Wails 主线程下稳定收事件（D5）；④ Casdoor loopback 任意端口端到端不报 `redirect_uri_mismatch`（D1）。
- **资源基准**：idle 内存在真机测（mac 本机、Windows 借真 x64 PC，ARM VM 不算数），D6 锁定。

---

## 8. 风险登记表

| # | 风险 | 影响 | 缓解 / 状态 |
|---|---|---|---|
| R1 | OAuth 后端协调 | D1 | **已降级**：sweep 给出最小路径（多值 audience 一处改动 + Casdoor ≥ v3.3），不再是未知阻塞 |
| R2 | 前端复用污染 web 构建 | 维护 / 分叉 | §2.1 A2 隔离 + Platform 接口 + CI 校验 |
| R3 | **Wails v2 缺托盘 + 单窗口** | D2 / D5 | 决策 C：推迟 v2/v3 至 D2/D5 前；v2 走第三方 systray + 自写 NSWindowDelegate，或升 v3 |
| R4 | 剪贴板回环（自写触发自传） | 上传风暴 | ClipboardSource 层来源标记 + hash 去重 + debounce |
| R5 | **macOS 26 剪贴板隐私授权** | D4 自动同步可能被弹窗打断 | 真机实测 changeCount 轮询是否触发；必要时改用 detect API |
| R6 | **无证书 → D3 通知 / Gatekeeper** | D3 通知可能不工作、分发受阻 | ad-hoc 签名 + 真机实测；不行则通知降级 / 推迟；公开分发前补证书 |
| R7 | ARM VM 测不了性能 / 真签名 | 数字不可信 | D6 借真 x64 PC |

---

## 9. 执行顺序与 v2/v3 决策闸

**顺序（决策 C）**：

1. **R1 后端协调**（非代码，可并行起草）+ Casdoor 建桌面 client。
2. **D1**（前端复用 + loopback OAuth）——框架无关，最高价值。
3. **D3**（SSE 常驻 + 通知 + 落盘）+ **D4**（剪贴板核心价值）——框架无关。
4. **⟵ v2/v3 决策闸**：动 D2 / D5 前，正式评估三选一：
   - **A) 留 v2**：稳定、文档全；D2 托盘 + D5 多窗靠第三方 / 自写 ObjC，技术债重。
   - **B) 升 v3-alpha**：原生 systray + 多窗口 + ActivationPolicy；但 alpha 有风险、文档薄、需重做 D0 脚手架与 D1–D4 的少量框架接缝。
   - **C) 混合**：v2 上把 D2/D5 做到“可用即可”（不追求完美失焦自隐等），待 v3 稳定再迁移。
5. **D2**（托盘 + 生命周期）→ **D5**（热键 + Quick Send）。
6. **D6**（打包：当前无证书仅 `.app`；CI）。

**判据**：若 menubar 常驻 + Quick Send 浮窗被确认为产品核心体验、且 v3-alpha 在评估时已足够稳，倾向 B；否则 A/C 先交付。

---

## 10. 当前执行计划：桌面 app（A2 复用 + 菜单栏 + 剪贴板 + 设置，2026-06-13 定）

> 用户要求：实现 web 全部功能、复用大部分界面与设计逻辑（A2）+ 剪贴板同步；**无活动窗口时从 dock 隐藏 + 有 menu bar item**；**可能需独立的桌面设置界面**。

### 10.0 架构决策：留 v2（单窗菜单栏）

本需求是单窗口形态（一个主窗 + 菜单栏，无 Quick Send 多窗），v2 可达：菜单栏 `fyne.io/systray`、dock 动态隐藏 purego objc `setActivationPolicy`、关闭即隐藏 v2 `OnBeforeClose`。复用 D1 全部成果，不冒 v3-alpha 迁移；Quick Send 多窗若将来要，再议 v3。

### 10.1 A2 前端复用（地基）

Wails 构建并嵌入 web/ 的 React 应用，加薄平台 seam（web 默认行为不变）：

- **API base**：`net/api.ts` 用 `API_BASE = import.meta.env.VITE_CDROP_API_BASE ?? ""` 前缀 fetch 的 URL；web 留空（相对、不变），desktop 构建设 `https://drop.commilitia.net`（WebView 内相对 `/api` 到不了后端）。
- **登录桥**：login 路由检测 desktop（Wails 注入 `window.runtime`）→ 调 Go `StartLogin` → Go 直连 Casdoor 换 token + 解 access_token JWT 取 sub/name → `EventsEmit {access_token, refresh_token, user}` → JS `setAuth`。即 **token 进 JS store**（A2 必需、本机可接受，放弃「token 只在 Go」）。
- **refresh**：desktop 的 `refreshTokens` 走 Go `Flow.Refresh`（绕开 `/api/auth/refresh` 后端代理）→ emit 新 token。
- **设备名**：desktop 取主机名（`os.Hostname`）填 `selfDeviceName`。
- **构建接线**：desktop/frontend 引用 web（npm workspace / `file:` 或 vite 指 `web/src`），保留 wailsjs 绑定；以能 `wails build` 为准、方式待落地时定。

### 10.2 剪贴板同步（D4）

- **上行**：darwin native Source（NSPasteboard changeCount 轮询，purego，clipprobe 已证可跑）→ Monitor（去重 / 回环防护；`Sensitive` 过滤 best-effort、非主防线）→ `api.PutClipboard`。
- **下行**：Go 持 SSE `/api/hub/events` → `clipboard:update` → `ApplyRemote` → `Source.Write`（loop-guard 防自传）。
- **安全**：服务端 5 min TTL 已上线兜底（密码即使上传，5 分钟即逝）。
- **开关**：受桌面设置「自动同步」控制（建议默认开 + 可关）。

### 10.3 菜单栏 + dock 隐藏（D2，单窗）

- **菜单栏**：`fyne.io/systray`，菜单项：显示主窗 / 设置 / 退出。
- **dock 动态**：purego objc `NSApp setActivationPolicy:`——窗口显示 = regular（有 dock）；隐藏 = accessory（无 dock、仅菜单栏）。
- **生命周期**：v2 `OnBeforeClose` → `WindowHide` + accessory；菜单栏「显示主窗」→ `WindowShow` + regular；「退出」才真正退出（触发 `/api/me/disconnect`）。

### 10.4 桌面独立设置界面

- **桌面专属设置**（web 无）：剪贴板自动同步开关、开机自启、设备名、菜单栏 / dock 行为、（TTL 只读展示）。
- **形态**：desktop-only 路由 / 页，复用 web 设计系统（Mantine + tokens + Field / Switch / Panel 组件）保持视觉一致；由菜单栏「设置」打开。
- **存储**：Go 侧本地配置文件（`~/Library/Application Support/cdrop/config.json`）+ 绑定方法读写；前端经 Wails binding 读写。

### 10.5 执行顺序

A2 复用（地基，先让真 web UI 在桌面跑起来 + 登录走 Go 桥）→ 菜单栏 + dock 隐藏 → 剪贴板同步 → 桌面设置。每步 `wails build` 可验；登录 / 同步 / 菜单栏需真机测。

### 10.6 实现状态（2026-06-13 实现，本地全绿）

`go build ./... / go test -race ./... / web tsc / wails build` 全部通过，产出 `build/bin/cdrop-desktop.app`（内嵌真 web 应用，8 个绑定就绪）。

落地与对 §10.1–10.4 的关键决策：

1. **同源反向代理替代 API_BASE 跨域**（§10.1 偏差）：`platform.NewAPIProxy` 在 Wails `AssetServer.Handler` 反代 `/api/*` → backend，前端相对 URL 原样不变。理由：后端无 CORS，跨域方案要加 CORS + 重新部署（不可达）；代理同源、零 CORS、零后端改动。SSE 流式已核实可行并单测（darwin `URLSchemeTaskDidReceiveData` 逐 write 推流 + `FlushInterval=-1`；`proxy_test.go` TestProxyStreamsSSE）。
2. **登录桥 token 入 JS store**：`StartLogin` 解码 JWT（`UserFromToken`，镜像后端 `extractUser`：id_token 优先、preferred_username→name→email→sub、avatar→picture）发 `oauth:success{access_token,refresh_token,user}`；`Refresh` 走 public-client（不带 secret）；`DeviceName`=主机名跳过 /setup。web `net/desktop.ts` 全程 `isDesktop()` 门控，浏览器零影响。
3. **菜单栏 + dock：自写 CGO/ObjC `NSStatusBar` 桥，不碰 app delegate**（§10.3 落地）：核实 energye/systray 与 systray-on-wails 的 external-loop 路径仍会 `setDelegate:owner` 抢占 Wails 的 NSApplication delegate → 破坏窗口/运行时，**全部排除**。`statusbar_darwin.m` 在 main queue 建 `NSStatusItem` + 菜单（显示主窗 / 设置 / 退出），dock 经 `setActivationPolicy` 动态切（regular↔accessory）；`OnBeforeClose` 隐藏不退、菜单「退出」置 `quitting` 真退。CGO/ObjC 而非 purego：clang 编译期校验 selector，盲写更稳。
4. **剪贴板 JS-centric**（§10.2 落地）：Go 只做原生读写（`clipboard_darwin.m` NSPasteboard 轮询 changeCount + 读写）+ 发 `clipboard:native` 事件 / `ApplyRemoteClipboard` 绑定；上行复用 web `uploadClipboard`、下行复用 `applyRemoteClipboard`，**单 SSE**（JS 的）。回环防护用既有 `Monitor`（已加 mutex 并发安全，-race 通过）。窗口隐藏后 WebView 仍存活 → menubar 模式下行有效。Sensitive（ConcealedType 等）best-effort 过滤 + 服务端 5min TTL 兜底。
5. **设置 + 本地配置**（§10.4 落地）：`appsettings.go` 读写 `~/Library/Application Support/cdrop/config.json`；`launchagent_darwin.go` 用 LaunchAgent plist 实现开机自启（纯文件 I/O，已单测）。web `features/desktop/DesktopSettings.tsx`（复用 Panel + Mantine Switch）挂在 /settings、`isDesktop()` 门控；菜单栏「设置」→ `menu:settings` 事件 → 路由跳转。

新增/改动（desktop/platform）：proxy.go、identity.go、statusbar{.go,_darwin.go,_darwin.m,_other.go}、clipboard_darwin.{go,m}、clipboard_source_other.go、appsettings.go、launchagent_{darwin,other}.go（+ proxy/identity/appsettings/launchagent 4 测试）；clipboard.go 加 mutex；oauth.go 加 IDToken 字段。desktop：app.go（登录桥 / 生命周期 / 剪贴板 / 设置绑定）、main.go（proxy + OnBeforeClose）、wails.json（frontend 指向 web 构建）。web：net/desktop.ts + DesktopSettings.tsx 新增；login.tsx / auth.ts / clipboard.ts / __root.tsx / settings.tsx / main.tsx 加 guarded 桌面分支。

**待真机 + 鉴权验证（本环境无 GUI、无法登录，以下不可本地达成）：**

- [ ] 登录 e2e：loopback PKCE → token 入 store → 进首页（含设备名自动取主机名）
- [ ] refresh：API 401 → Go public-client 刷新成功
- [ ] 菜单栏 item 出现 + 三项菜单可点
- [ ] dock 动态隐藏：关窗 → 仅菜单栏；菜单「显示主窗口」→ 窗口与 dock 复现
- [ ] 剪贴板跨设备：上行（复制即传）/ 下行（远端更新写回本机）/ 回环不抖
- [ ] 开机自启：勾选后写入 LaunchAgent、重登录系统自动起

**已知后续：** 桌面设置 UI 已接入 `t()`（zh-CN / zh-TW / en-US 三语）；菜单栏标签仍 Go 侧硬编码简体（需启动期把 locale 传入 Go，待后续）；`Greet`/`LoggedIn` 为脚手架残留绑定可清理；Windows/Linux 的原生 Source / 托盘 / 自启目前为 stub。

### 10.7 用户首轮真机反馈修复（2026-06-13，本地全绿）

真机实测「功能基本正常」，三处调整已修：

1. **登录 session 本地持久化（Go 文件 + 启动注入；已真机跑通）**：

   **两个独立根因，第二个掩盖了第一个、害得多轮误判：**

   - **根因 A（真正的持久化障碍）**：Wails 从 `wails://` 自定义 scheme 提供页面，**WKWebView 对 custom-scheme origin 不持久化 localStorage / sessionStorage**，故任何「前端存储」跨重启即丢。→ 持久化必须交给 Go。
   - **根因 B（构建管线，长期误导）**：`wails build` 的 `frontend:build` 在本仓库结构下**实际不重建 `web/`**（无 vite 输出、dist 不更新），导致 `.app` 嵌入的是**数小时前的旧 JS**。所以连续多轮「仍无效」其实是在测**根本不含本轮代码的 stale bundle**——save/load/注入逻辑一直是对的，只是 JS 从未真正进过包。已用源码 marker 定位证实（marker 进不了 dist）。**修法：先手动 `npm run build` 再 `wails build -s`（跳过 wails 自带前端步），Justfile `desktop-build-mac` 已改成两步。**

   **终方案（最简、已真机验证落首页）**：① Go 持久化——`platform/session.go` 把 `LoginResult` 写 `~/Library/Application Support/cdrop/session.json`（0600），`StartLogin`/`Refresh` 即存、登出 `ClearSession` 删；② **启动注入**——`platform/sessioninject.go` 的 `AssetServer.Middleware` 在 index.html `</head>` 前注入 `<script>window.__CDROP_BOOT__={session,device_name}</script>`（启动读文件、`json.Marshal` 默认 HTML 转义防 `</script>` 逃逸、只改文档请求、assets 与 /api SSE 直通、加 `no-store`+删条件头防 304/缓存）；③ 内联脚本在 deferred module bundle 前同步执行，**`store/helpers.ts` 初始化即同步读 `window.__CDROP_BOOT__` 水合 session + 设备名**——无 IPC、无异步 boot、不依赖 window.go。设备名同样不能靠 WebView 存储：`DesktopConfig` 加 `DeviceName` + `ResolveDeviceName()`（config 优先否则 hostname），重命名经 `SetDeviceName` 绑定持久化，devices slice 初始化 `readInjectedDeviceName() ?? readSelfDevice()`。

   真机诊断证据：`[js] render: injected=true user=Commilitia device=…` + 随后加载首页组件（DeviceStrip / clipboard / SSE）。曾试过的 localStorage（根因 A 挡）、IPC 绑定 / 异步 boot（为绕「以为是缓存」加的冗余）均已移除——它们「无效」只因根因 B 让它们从没真正跑过。Keychain 加固仍留后续。
2. **常用快捷键**：`app.go buildMenu()` 装应用菜单——`menu.EditMenu()` 给 ⌘C/⌘V/⌘X/⌘A/⌘Z，自写「窗口」菜单 ⌘W=隐藏 / ⌘M=最小化。**关键坑**：Wails 把窗口关闭按钮与 ⌘Q **都**经同一 `OnBeforeClose("Q")` 通道（`windowShouldClose` 与 `applicationShouldTerminate` 都 `processMessage("Q")`），不可区分 → 用 role `AppMenu()` 的 ⌘Q 会被 hide 逻辑吞掉永不退出。故自写应用菜单 + 自定义 ⌘Q 退出项（先置 `quitting` 再退），实现「关闭按钮 / ⌘W 隐藏，⌘Q / 菜单退出 真退」。
3. **菜单栏图标 + 设置崩溃**：状态项改用 SF Symbol `doc.on.clipboard`（template image，随明暗着色，旧系统回退文字）。崩溃根因：原生菜单动作回调在 AppKit 主线程同步调 Wails runtime（`WindowShow` / `EventsEmit`）→ 主 run loop 重入崩溃；修为状态项三个回调 `go` 异步派发（Wails 再安全调度回主线程）。

### 10.8 凭据安全策略（2026-06-13 确立，一致化）

**原则：`refresh_token`（长寿命密钥）是受保护对象，绝不暴露给 WebView/JS；`access_token`（短寿命）是工作凭据，是 WebView 唯一持有的凭据。** 此前不一致——Web 把 refresh_token 仅留内存（XSS 最小化），桌面却把整套 token（含 refresh）既写明文文件又灌进 JS（注入 + store）。统一为：

- **refresh_token 全平台都不进 WebView/JS**：Web 仅内存（页面生命周期）、不落盘，刷新走后端代理（持 client secret）；桌面只在 **Go 进程内存 + 受限文件**，刷新走 **Go 内部**——`Refresh()` **无参**，Go 用自己的副本换新 access_token，绝不注入、不返回 JS。已在绑定边界核实：生成的 TS `SessionView` 无 `refresh_token` 字段、整个 wailsjs 生成层 `grep refresh_token` 零命中。
- **access_token 在 JS store**（API 调用必需），短寿命、由 refresh 续期；桌面随 boot 注入 + 落盘（offline-tolerant），401 时 Go 刷新。
- **静态存储（桌面）**：`session.json` 文件 0600、目录 0700（仅当前用户）。威胁模型：防其他本地用户 + 随手访问；**不防**同用户身份运行的恶意程序。**加固路线＝macOS Keychain，推迟到 Developer-ID 正式签名后**——ad-hoc 签名每次构建都改签名，会导致每次重建都弹 Keychain 授权（且当前无法干净验证）。
- 实现：`platform.SessionView`（`LoginResult.View()` 投影去掉 refresh）用于登录 emit / 刷新返回 / 注入；`StartLogin` 存全量到文件、emit View；`Refresh()` 无参用 `a.token.RefreshToken`（startup 时从文件载入 Go 内存，且容忍 provider 不轮换 refresh）；登出 `ClearSession` 删文件 + 清 Go 内存。

### 10.9 品牌资产接入 + 桌面打磨（2026-06-13，web 已部署 prod）

吉祥物品牌资产（源见记忆 `cdrop-brand-assets`）接入。web 与 desktop 共享同一前端，故多数改动两端通吃。

**Web（已部署 drop.commilitia.net）：**

- **站点图标**：`web/public/` favicon-16/32/48/96（源 avatar-head）+ apple-touch-180 + PWA icon-192/512（源 app-icon 金底）+ `site.webmanifest`；`index.html` 替换 emoji 占位。Google 搜索要求 favicon 为 **48px 整数倍**，故必挂 48/96。
- **品牌字标** `ui/brand/Wordmark`：吉祥物 logo（`logo/logo-icon.png`→1024² 870KB）+ Fredoka 600 混排双色（浅 Ink/Amber、深 白/`#F8C637`，主题三态）。Fredoka 经 `gfonts.commilitia.net` 镜像、`--font-display`。用于 AuthShell 顶栏 / `__root` header。规范：**页面禁止纯文字呈现品牌**（须 logo 图或图+文字）。
- **登录两坑**：① 跳转帧「两个 logo」（顶栏 logo 与 header logo 重叠）→ 过渡屏 `oauth.callback`/`setup` 传 `hideBrand` 隐顶栏 logo；② 图片重载闪烁（embed.FS 无 modtime、`http.FileServer` 不发缓存头）→ `internal/webui/embed.go` 补 `Cache-Control`（`/assets/*` immutable、index `no-cache`、logo/favicon `max-age=604800`）+ index.html `preload`。

**Desktop：**

- **App 图标**：`build/appicon.png`←`app-icon/icon-1024.png`，构建期转 `iconfile.icns`。**bundle ID `com.wails.{{.Name}}` 占位致 LaunchServices 缓存陈旧（一直显默认图）→ 改 `net.commilitia.cdrop`**（与 launchAgentLabel 一致）+ `lsregister -f` 重注册。
- **菜单栏图标**：SF Symbol → **彩色品牌头像**（`platform/menubar-icon.png` avatar-head 256px，`go:embed`→NSImage，`template=NO`，尺寸取 NSStatusBar `thickness`）。
- **菜单栏交互（未来 Windows 托盘须对齐）**：**左键单击→打开主窗口；右键/⌃单击→菜单**。不挂 `item.menu`（否则 mousedown 即弹菜单），改 `item.button` action + `sendActionOn:LeftMouseUp|RightMouseUp` 手动路由。

**待办**：Keychain 加固仍待签名后；菜单栏标签仍 Go 侧硬编码简体（locale 注入待办）。下一步：Windows 落地，见 §11。

## 11. Windows 端落地（2026-06-14，RDP 实测通过）

macOS 优先做完后接 Windows。业务逻辑全跨平台纯 Go，平台差异用 build tag 隔离（`_windows.go` / `_darwin.go` / `_other.go`）。

### 11.1 交叉编译：macOS 原生即可，无需 mingw

Windows 侧无 cgo——darwin 的 NSStatusBar/NSPasteboard CGO 走 build tag 排除，托盘/剪贴板/自启均纯 syscall，WebView2 是纯 Go（`go-webview2`）。`just desktop-build-win` = 手动构建前端 + `wails build -s -platform windows/amd64`，从 macOS 直接出 `.exe`。`os.UserConfigDir()` 跨平台 → session/config 落 `%AppData%\cdrop\`。

### 11.2 核心坑：Wails 的 Windows custom-scheme AssetServer 不支持流式

**根因**：Wails 的 Windows AssetServer 把 `http.Handler` 的响应**攒到 handler 返回才交给 WebView2**。长连 SSE（`/api/hub/events`，handler 永不返回）→ WebView2 永远拿不到响应 → fetch 一直 pending（连 `resp.ok` 都不触发）→ 设备列表加载不出、状态卡「连接中」。短请求（剪贴板 fetch 等 handler 返回即送达）正常，故迷惑性强。两个同名进程互相 kick → 连接频繁**结束** → 每次结束才冲刷，是「开两个就好」的假象。

**排错历程**：先加 2KB SSE padding（标准抗缓冲技术）→ 无效，**反证是 buffer-until-return 而非流式缓冲**（padding 已撤）。

**修法**：`platform/localproxy.go` 仅 Windows 在 `127.0.0.1:0` 起**真 HTTP 代理**处理 `/api`——WebView2 的正常网络栈对 loopback 源真流式。前端经注入的 `api_base` 前缀 `/api`（`net/api.ts`）。CORS 回显 `Access-Control-Request-Headers`（覆盖 Authorization / If-None-Match / X-Device-Type，否则剪贴板刷新的 `If-None-Match` 预检失败＝「Failed to fetch」）+ Private-Network 应答头。macOS 维持 custom scheme（WKWebView 流式正常，零改动）。

### 11.3 原生能力（`_windows.go`）

- **托盘** `statusbar_windows.go`：`fyne.io/systray`（goroutine + LockOSThread 跑自有消息循环），品牌 `tray-icon.ico`，`SetOnTapped` 左键开窗 / 右键默认菜单（显示主窗·设置·退出）。关窗→`WindowHide`（任务栏按钮随之消失），托盘常驻。
- **剪贴板** `clipboard_windows.go`：`golang.design/x/clipboard`（`GetClipboardSequenceNumber` 轮询，纯 syscall），上下行经既有 `Monitor`/`Run`。敏感内容过滤暂缺（`Sensitive` 恒 false，靠服务端 5min TTL 兜底；`EnumClipboardFormats` 检查待后续）。
- **开机自启** `launchagent_windows.go`：注册表 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`（per-user 免提权），值为带引号的 exe 路径（尾带 `--hidden`，见 §11.5）。

### 11.4 跨端打磨

- **设备类型**：客户端发 `X-Device-Type`（桌面按 `runtime.GOOS` 注入 macos/windows），后端中间件白名单登记（替代写死 `browser`），前端 i18n 本地化展示（浏览器 / macOS 客户端 / Windows 客户端…）。
- **设备名持久化**修复：`SaveSettings` 合并保留 `DeviceName`（设置页 `DesktopConfig` 无 device_name 字段，原先存设置会用空值覆盖）。
- **统一软件名** `Commilitia Drop Desktop`（`.app` / `.exe`）；品牌 `.icns`（macOS）/ `.ico`（Windows，无 ImageMagick 时用一次性 Go 工具封 PNG-in-ICO）；bundle id `net.commilitia.cdrop`。
- **Cmd+,（Ctrl+,）→ 设置**：`main.tsx` 全局 keydown，桌面与浏览器通用。

### 11.5 自启静默驻留（autostart → 不弹窗，直接驻留菜单栏 / 托盘）

自启拉起的进程不该抢焦点弹窗——用户没主动开 app。机制：自启项在启动命令尾部追加 `--hidden`，进程 `launchedHidden()`（扫 `os.Args`）命中即 `options.StartHidden`（无窗口起）；macOS 再 `SetDockVisible(false)` 降 accessory（隐 dock，仅留菜单栏项）。前端照常加载 → 剪贴板同步后台照跑。手动启动（Finder / 任务栏 / 终端）不带标志 → 正常显示窗口。

- macOS：LaunchAgent `open <bundle> --args --hidden`（裸 dev 二进制 `exe --hidden`）。
- Windows：注册表值 `"exe" --hidden`。
- **自启项自愈**：`startup` 时若自启已开，重写一次条目——旧版本（无 `--hidden`）或迁移过位置的 bundle 自动刷新到当前路径＋标志，无需手动重勾。

**仍待办**：Windows 剪贴板敏感过滤；菜单栏/托盘标签 i18n（仍硬编码简体）；Keychain（待 Developer-ID 签名）；Windows 代码签名 / SmartScreen。
