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)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
42 KiB
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作薄入口,importweb为本地包(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 冲突),不达标则自写 ObjCNSStatusBar + 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,点击经 delegatedidReceiveResponse区分。硬门槛(与签名决策冲突,须正视):要求已签名 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/clipboardv0.8.0(macOS 已 purego/objc 无 cgo;Windows / Linux 纯 syscall)。sweep 校正 PLAN 旧假设:① macOS 不是 3s,是该库写死 1s 轮询changeCount;② Windows 也是轮询(GetClipboardSequenceNumber1s),不是AddClipboardFormatListener事件——两端机制其实一致。若坚持 Windows 真事件,可自写 syscall message-only window,但建议先轮询上线(剪贴板同步对 1s 不敏感)、按真机反馈再定。 - 自写一层
ClipboardSource接口做库不负责的三件事:- 敏感内容过滤(库不做,直接用会同步密码):macOS 读
-types比对org.nspasteboard.ConcealedType/TransientType/AutoGeneratedType及私有 UTI;Windows 读后EnumClipboardFormats查ExcludeClipboardContentFromMonitorProcessing/CanUploadToCloudClipboard,命中跳过上传。 - 回环防护(R4):自写剪贴板会自增计数触发自己的 Watch 风暴;写入前记 hash + 自写序号,Watch 收变更先比对跳过。
- 去重 + debounce:复用已在 go.mod 的
github.com/bep/debounce+ 内容 hash。
- 敏感内容过滤(库不做,直接用会同步密码):macOS 读
- 写回本机剪贴板优先用库的
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/hotkeypin v0.4.1(CarbonRegisterEventHotKey,对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.mdD6 手册,届时直接套用。 - 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-wincross-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):
- R1 后端协调(非代码,可并行起草)+ Casdoor 建桌面 client。
- D1(前端复用 + loopback OAuth)——框架无关,最高价值。
- D3(SSE 常驻 + 通知 + 落盘)+ D4(剪贴板核心价值)——框架无关。
- ⟵ v2/v3 决策闸:动 D2 / D5 前,正式评估三选一:
- A) 留 v2:稳定、文档全;D2 托盘 + D5 多窗靠第三方 / 自写 ObjC,技术债重。
- B) 升 v3-alpha:原生 systray + 多窗口 + ActivationPolicy;但 alpha 有风险、文档薄、需重做 D0 脚手架与 D1–D4 的少量框架接缝。
- C) 混合:v2 上把 D2/D5 做到“可用即可”(不追求完美失焦自隐等),待 v3 稳定再迁移。
- D2(托盘 + 生命周期)→ D5(热键 + Quick Send)。
- 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)→ 调 GoStartLogin→ Go 直连 Casdoor 换 token + 解 access_token JWT 取 sub/name →EventsEmit {access_token, refresh_token, user}→ JSsetAuth。即 token 进 JS store(A2 必需、本机可接受,放弃「token 只在 Go」)。 - refresh:desktop 的
refreshTokens走 GoFlow.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 的关键决策:
- 同源反向代理替代 API_BASE 跨域(§10.1 偏差):
platform.NewAPIProxy在 WailsAssetServer.Handler反代/api/*→ backend,前端相对 URL 原样不变。理由:后端无 CORS,跨域方案要加 CORS + 重新部署(不可达);代理同源、零 CORS、零后端改动。SSE 流式已核实可行并单测(darwinURLSchemeTaskDidReceiveData逐 write 推流 +FlushInterval=-1;proxy_test.goTestProxyStreamsSSE)。 - 登录桥 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。webnet/desktop.ts全程isDesktop()门控,浏览器零影响。 - 菜单栏 + 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,盲写更稳。 - 剪贴板 JS-centric(§10.2 落地):Go 只做原生读写(
clipboard_darwin.mNSPasteboard 轮询 changeCount + 读写)+ 发clipboard:native事件 /ApplyRemoteClipboard绑定;上行复用 webuploadClipboard、下行复用applyRemoteClipboard,单 SSE(JS 的)。回环防护用既有Monitor(已加 mutex 并发安全,-race 通过)。窗口隐藏后 WebView 仍存活 → menubar 模式下行有效。Sensitive(ConcealedType 等)best-effort 过滤 + 服务端 5min TTL 兜底。 - 设置 + 本地配置(§10.4 落地):
appsettings.go读写~/Library/Application Support/cdrop/config.json;launchagent_darwin.go用 LaunchAgent plist 实现开机自启(纯文件 I/O,已单测)。webfeatures/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,本地全绿)
真机实测「功能基本正常」,三处调整已修:
-
登录 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 自带前端步),Justfiledesktop-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 加固仍留后续。 - 根因 A(真正的持久化障碍):Wails 从
-
常用快捷键:
app.go buildMenu()装应用菜单——menu.EditMenu()给 ⌘C/⌘V/⌘X/⌘A/⌘Z,自写「窗口」菜单 ⌘W=隐藏 / ⌘M=最小化。关键坑:Wails 把窗口关闭按钮与 ⌘Q 都经同一OnBeforeClose("Q")通道(windowShouldClose与applicationShouldTerminate都processMessage("Q")),不可区分 → 用 roleAppMenu()的 ⌘Q 会被 hide 逻辑吞掉永不退出。故自写应用菜单 + 自定义 ⌘Q 退出项(先置quitting再退),实现「关闭按钮 / ⌘W 隐藏,⌘Q / 菜单退出 真退」。 -
菜单栏图标 + 设置崩溃:状态项改用 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。已在绑定边界核实:生成的 TSSessionView无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 顶栏 /__rootheader。规范:页面禁止纯文字呈现品牌(须 logo 图或图+文字)。 - 登录两坑:① 跳转帧「两个 logo」(顶栏 logo 与 header logo 重叠)→ 过渡屏
oauth.callback/setup传hideBrand隐顶栏 logo;② 图片重载闪烁(embed.FS 无 modtime、http.FileServer不发缓存头)→internal/webui/embed.go补Cache-Control(/assets/*immutable、indexno-cache、logo/faviconmax-age=604800)+ index.htmlpreload。
Desktop:
- App 图标:
build/appicon.png←app-icon/icon-1024.png,构建期转iconfile.icns。bundle IDcom.wails.{{.Name}}占位致 LaunchServices 缓存陈旧(一直显默认图)→ 改net.commilitia.cdrop(与 launchAgentLabel 一致)+lsregister -f重注册。 - 菜单栏图标:SF Symbol → 彩色品牌头像(
platform/menubar-icon.pngavatar-head 256px,go:embed→NSImage,template=NO,尺寸取 NSStatusBarthickness)。 - 菜单栏交互(未来 Windows 托盘须对齐):左键单击→打开主窗口;右键/⌃单击→菜单。不挂
item.menu(否则 mousedown 即弹菜单),改item.buttonaction +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 idnet.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。