# 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` 手加 `LSUIElement`(启动瞬间 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`(`lipo` 合并 arm64 + amd64)。**不公证、不 DMG、无 Developer ID**。为满足 D3 通知与稳定身份,做 **ad-hoc 签名** `codesign -s - --force --deep .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 `` 前注入 ``(启动读文件、`json.Marshal` 默认 HTML 转义防 `` 逃逸、只改文档请求、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 --args --hidden`(裸 dev 二进制 `exe --hidden`)。 - Windows:注册表值 `"exe" --hidden`。 - **自启项自愈**:`startup` 时若自启已开,重写一次条目——旧版本(无 `--hidden`)或迁移过位置的 bundle 自动刷新到当前路径+标志,无需手动重勾。 **仍待办**:Windows 剪贴板敏感过滤;菜单栏/托盘标签 i18n(仍硬编码简体);Keychain(待 Developer-ID 签名);Windows 代码签名 / SmartScreen。