Files
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

42 KiB
Raw Permalink Blame History

cdrop 桌面客户端实施计划

Wails v2 · macOS 优先 + Windows 已落地 状态:macOS 与 Windows 双端均已实现并 RDP/真机实测通过——A2 前端复用 + 托盘/菜单栏 + 剪贴板双向同步 + 桌面设置 + session 持久化 + 开机自启(含自启静默驻留)+ 设备类型登记。macOS 见 §10Windows 落地(含「custom-scheme 不支持流式」核心坑与 127.0.0.1 本地代理解法)见 §11。计划已据原生平台研究 sweep(desktop/RESEARCH.md)校正。 关联:根 README.mddesktop/(脚手架)、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 主体(§10A2 复用 + 菜单栏/dock + 剪贴板接入 + 设置 + 本地配置 已实现(本地全绿,待真机勾验菜单栏/剪贴板/自启) 本轮待提交
登录 session 本地持久化(§10.7.1:Go 文件 + 启动注入 + 同步水合 已实现,重启免登真机跑通 本轮待提交
凭据安全一致化(§10.8refresh_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. 范围、非范围与本轮关键决策

范围:常驻桌面客户端,补浏览器做不到的——后台常驻、剪贴板自动监听上传、文件落盘、系统通知、全局快捷键。平台 macOSApple Silicon + Intel)优先Windowsx64)保持兼容(同源、同分支,平台差异用 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.0Go + 系统 WebView 系统 WebView 省内存;Go 单语言写原生;单 codebase
待评估 Wails v3-alpha(见决策 C / §9 原生覆盖 systray + 多窗口 + ActivationPolicyD2/D5 前定夺
淘汰 Electron / Tauri / 原生双栈 / PWA Electron 200+ MBTauri 多一门 Rust;原生双 codebasePWA 无后台常驻
资源预算 macOS idle 80120 MBWindows idle 100150 MB;包体 1020 MB WebView 复用系统组件

1.2 瘦客户端架构

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

1.3 与后端的契约(桌面端用到的端点)

沿用 Web 现有 APIweb/src/net/api.ts 注入 Authorization: Bearer401 懒刷新):

端点 用途 里程碑
GET /api/auth/configPOST /api/auth/exchangePOST /api/auth/refresh OIDC 配置 / 换 token / 刷新 D1
GET/PUT /api/clipboard 云剪贴板读写 D4
GET /api/hub/eventsSSE 服务端推送(新剪贴板 / 消息 / 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 不被污染。
  • 否决:A1frontend 直指 ../web,桌面专属代码无处落脚)、A3(复制 web,分叉地狱)。

2.2 平台抽象层(Platform 接口)

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

能力 Web 实现 Desktop 实现(经 Wails bind 调 Go
登录 window.location.href 整页跳 authorize 系统浏览器 + loopback 回调 + 注入(§3D1
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. D1OAuth 登录闭环(已据 sweep 定型)

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

3.1 回调通道:loopback(已定,弃 scheme

采用 RFC 8252 loopbackGo 起临时 127.0.0.1:0(必须 IPv4 字面量,不用 localhostHTTP 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 clientPKCE 时 client_secret 可空)、对 http://127.0.0.1 任意端口自动放行(IsValidOrigin 短路,需 Casdoor ≥ ~v3.3)。桌面直连 Casdoor 端点:authorize=/login/oauth/authorizetoken=/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-desktopJWT、无 secret、不勾 shared、登记 loopback redirect 兜底)。
  • 后端:CDROP_OIDC_AUDIENCE 改多值(方案 A)。
  • Goloopback server + runtime.BrowserOpenURL + state 校验 + token 交换 + EventsEmittoken 持久化(Keychain / DPAPI)。
  • 验收:冷启动 → 点登录 → 系统浏览器授权 → 自动回应用已登录;杀进程重启仍登录;refresh 跑通。dev 模式(VITE_CDROP_DEV_TOKEN)可先绕 Casdoor 打通壳。

4. D2D6 详解(含 sweep 校正)

D2 · 菜单栏 + 窗口生命周期 ⚠️ 受 v2/v3 决策影响

  • sweep 校正(refutedWails v2 无任何可用内建托盘 APIoptions.AppTray、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 + NSMenugetlantern/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 通知UNUserNotificationCentercgo + 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 按钮、SetActivationCallbackArguments 区分。坑:须先 SetAppData 注册 AUMID + stub CLSID,否则 COM 激活失效、回调收不到。
  • 落地节奏:先做“单击整条通知拉主窗 + 打开文件”基础版,三按钮作增量。
  • 落盘 已落地(2026-06-14,Go 绑定写盘):单文件 platform/download.go(非按平台分),SaveDownload 写入可配置目录(DesktopConfig.DownloadDir 空=系统 ~/Downloads),sanitizeFileName 剥目录成分防路径穿越、uniquePath 重名加 (n)revealInFileManageropen -R / explorer /select)。桌面端 web 的 downloadBlob 改走绑定 SaveDownloadblob→base64 过桥),失败回退浏览器下载;设置页可改目录(ChooseDownloadDir 原生选择框)。

D4 · 剪贴板自动同步(核心价值)

  • 统一底层 golang.design/x/clipboard v0.8.0macOS 已 purego/objc 无 cgoWindows / 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 及私有 UTIWindows 读后 EnumClipboardFormatsExcludeClipboardContentFromMonitorProcessing / CanUploadToCloudClipboard,命中跳过上传。
    2. 回环防护(R4):自写剪贴板会自增计数触发自己的 Watch 风暴;写入前记 hash + 自写序号,Watch 收变更先比对跳过。
    3. 去重 + debounce:复用已在 go.mod 的 github.com/bep/debounce + 内容 hash。
  • 写回本机剪贴板优先用库的 Write(规避 Wails #4132macOS LANG 为空时编码 bug)。
  • go 版本:该库 go.mod 要求 go 1.24desktop/go.mod 声明 1.23——引入需把模块声明升 1.24+(本机 toolchain 已满足)。
  • 三按钮通知:新剪贴板到达 →“复制到本机 / 忽略 / 打开”。
  • ⚠️ D4 最大不确定项(须真机实测)macOS 26Tahoe)剪贴板隐私授权——非用户 paste UI 触发的程序化读取会弹授权 Alert,正中“后台自动监听上传”要害。Apple 新增 detectPatterns/detectValues(只探测类型不读内容、不弹窗)但该库未适配。须在 macOS 26 真机验证:纯 changeCount 轮询 vs 读内容 各自的弹窗行为。

D5 · 全局快捷键 + Quick Send 浮窗 ⚠️ 受 v2/v3 决策影响

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

D6 · 打包(无证书:仅 .app

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

5. 跨平台代码组织(防分叉硬约束)

desktop/
  main.go               # Wails 入口(窗口 / bind / 托盘装配)
  app.go                # App struct + 暴露给 WebView 的方法(bindStartLogin 等)
  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;②WindowsParallels Win11 ARM VM)只 git pull从不 commitWindows-only 操作封进 just target;③ARM Win11 跑 x64 emulator 性能 -2040%不能跑资源 / 性能基准,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_mismatchD1)。
  • 资源基准:idle 内存在真机测(mac 本机、Windows 借真 x64 PCARM 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(打包:当前无证书仅 .appCI)。

判据:若 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 应用,加薄平台 seamweb 默认行为不变):

  • API basenet/api.tsAPI_BASE = import.meta.env.VITE_CDROP_API_BASE ?? "" 前缀 fetch 的 URLweb 留空(相对、不变),desktop 构建设 https://drop.commilitia.netWebView 内相对 /api 到不了后端)。
  • 登录桥login 路由检测 desktopWails 注入 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」)。
  • refreshdesktop 的 refreshTokens 走 Go Flow.Refresh(绕开 /api/auth/refresh 后端代理)→ emit 新 token。
  • 设备名desktop 取主机名(os.Hostname)填 selfDeviceName
  • 构建接线desktop/frontend 引用 webnpm workspace / file: 或 vite 指 web/src),保留 wailsjs 绑定;以能 wails build 为准、方式待落地时定。

10.2 剪贴板同步(D4

  • 上行darwin native SourceNSPasteboard changeCount 轮询,puregoclipprobe 已证可跑)→ Monitor(去重 / 回环防护;Sensitive 过滤 best-effort、非主防线)→ api.PutClipboard
  • 下行Go 持 SSE /api/hub/eventsclipboard:updateApplyRemoteSource.Writeloop-guard 防自传)。
  • 安全:服务端 5 min TTL 已上线兜底(密码即使上传,5 分钟即逝)。
  • 开关:受桌面设置「自动同步」控制(建议默认开 + 可关)。

10.3 菜单栏 + dock 隐藏(D2,单窗)

  • 菜单栏fyne.io/systray,菜单项:显示主窗 / 设置 / 退出。
  • dock 动态purego objc NSApp setActivationPolicy:——窗口显示 = regular(有 dock);隐藏 = accessory(无 dock、仅菜单栏)。
  • 生命周期v2 OnBeforeCloseWindowHide + 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=-1proxy_test.go TestProxyStreamsSSE)。
  2. 登录桥 token 入 JS storeStartLogin 解码 JWTUserFromToken,镜像后端 extractUserid_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 而非 puregoclang 编译期校验 selector,盲写更稳。
  4. 剪贴板 JS-centric(§10.2 落地):Go 只做原生读写(clipboard_darwin.m NSPasteboard 轮询 changeCount + 读写)+ 发 clipboard:native 事件 / ApplyRemoteClipboard 绑定;上行复用 web uploadClipboard、下行复用 applyRemoteClipboard单 SSEJS 的)。回环防护用既有 Monitor(已加 mutex 并发安全,-race 通过)。窗口隐藏后 WebView 仍存活 → menubar 模式下行有效。SensitiveConcealedType 等)best-effort 过滤 + 服务端 5min TTL 兜底。
  5. 设置 + 本地配置(§10.4 落地):appsettings.go 读写 ~/Library/Application Support/cdrop/config.jsonlaunchagent_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 加 mutexoauth.go 加 IDToken 字段。desktopapp.go(登录桥 / 生命周期 / 剪贴板 / 设置绑定)、main.goproxy + OnBeforeClose)、wails.jsonfrontend 指向 web 构建)。webnet/desktop.ts + DesktopSettings.tsx 新增;login.tsx / auth.ts / clipboard.ts / __root.tsx / settings.tsx / main.tsx 加 guarded 桌面分支。

待真机 + 鉴权验证(本环境无 GUI、无法登录,以下不可本地达成):

  • 登录 e2eloopback PKCE → token 入 store → 进首页(含设备名自动取主机名)
  • refreshAPI 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 buildfrontend:build 在本仓库结构下实际不重建 web/(无 vite 输出、dist 不更新),导致 .app 嵌入的是数小时前的旧 JS。所以连续多轮「仍无效」其实是在测根本不含本轮代码的 stale bundle——save/load/注入逻辑一直是对的,只是 JS 从未真正进过包。已用源码 marker 定位证实(marker 进不了 dist)。修法:先手动 npm run buildwails build -s(跳过 wails 自带前端步),Justfile desktop-build-mac 已改成两步。

    终方案(最简、已真机验证落首页):① Go 持久化——platform/session.goLoginResult~/Library/Application Support/cdrop/session.json0600),StartLogin/Refresh 即存、登出 ClearSession 删;② 启动注入——platform/sessioninject.goAssetServer.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 存储:DesktopConfigDeviceName + 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") 通道(windowShouldCloseapplicationShouldTerminateprocessMessage("Q")),不可区分 → 用 role AppMenu() 的 ⌘Q 会被 hide 逻辑吞掉永不退出。故自写应用菜单 + 自定义 ⌘Q 退出项(先置 quitting 再退),实现「关闭按钮 / ⌘W 隐藏,⌘Q / 菜单退出 真退」。

  3. 菜单栏图标 + 设置崩溃:状态项改用 SF Symbol doc.on.clipboardtemplate image,随明暗着色,旧系统回退文字)。崩溃根因:原生菜单动作回调在 AppKit 主线程同步调 Wails runtimeWindowShow / 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 SessionViewrefresh_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.SessionViewLoginResult.View() 投影去掉 refresh)用于登录 emit / 刷新返回 / 注入;StartLogin 存全量到文件、emit ViewRefresh() 无参用 a.token.RefreshTokenstartup 时从文件载入 Go 内存,且容忍 provider 不轮换 refresh);登出 ClearSession 删文件 + 清 Go 内存。

10.9 品牌资产接入 + 桌面打磨(2026-06-13web 已部署 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.webmanifestindex.html 替换 emoji 占位。Google 搜索要求 favicon 为 48px 整数倍,故必挂 48/96。
  • 品牌字标 ui/brand/Wordmark:吉祥物 logologo/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/setuphideBrand 隐顶栏 logo;② 图片重载闪烁(embed.FS 无 modtime、http.FileServer 不发缓存头)→ internal/webui/embed.goCache-Control/assets/* immutable、index no-cache、logo/favicon max-age=604800+ index.html preload

Desktop

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

待办:Keychain 加固仍待签名后;菜单栏标签仍 Go 侧硬编码简体(locale 注入待办)。下一步:Windows 落地,见 §11。

11. Windows 端落地(2026-06-14RDP 实测通过)

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

11.1 交叉编译:macOS 原生即可,无需 mingw

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

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

根因Wails 的 Windows AssetServer 把 http.Handler 的响应攒到 handler 返回才交给 WebView2。长连 SSE/api/hub/eventshandler 永不返回)→ 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 前缀 /apinet/api.ts)。CORS 回显 Access-Control-Request-Headers(覆盖 Authorization / If-None-Match / X-Device-Type,否则剪贴板刷新的 If-None-Match 预检失败=「Failed to fetch」)+ Private-Network 应答头。macOS 维持 custom schemeWKWebView 流式正常,零改动)。

11.3 原生能力(_windows.go

  • 托盘 statusbar_windows.gofyne.io/systraygoroutine + LockOSThread 跑自有消息循环),品牌 tray-icon.icoSetOnTapped 左键开窗 / 右键默认菜单(显示主窗·设置·退出)。关窗→WindowHide(任务栏按钮随之消失),托盘常驻。
  • 剪贴板 clipboard_windows.gogolang.design/x/clipboardGetClipboardSequenceNumber 轮询,纯 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);品牌 .icnsmacOS/ .icoWindows,无 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 / 任务栏 / 终端)不带标志 → 正常显示窗口。

  • macOSLaunchAgent open <bundle> --args --hidden(裸 dev 二进制 exe --hidden)。
  • Windows:注册表值 "exe" --hidden
  • 自启项自愈startup 时若自启已开,重写一次条目——旧版本(无 --hidden)或迁移过位置的 bundle 自动刷新到当前路径+标志,无需手动重勾。

仍待办:Windows 剪贴板敏感过滤;菜单栏/托盘标签 i18n(仍硬编码简体);Keychain(待 Developer-ID 签名);Windows 代码签名 / SmartScreen。