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

695 lines
42 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# cdrop 桌面端 D1 前决策就绪简报
> 框架基线:Wails v2.12.0Go 1.23 模块声明,本机 toolchain 1.26.3+ 系统 WebView,瘦客户端,业务全走 `drop.commilitia.net`。平台优先级 macOSApple Silicon + Intel universal),Windows x64 兼容。
> 已对四项关键论断做对抗式核验:托盘能力 **refuted**、热键库可用性 **supported(附 pin 条件)**、Casdoor/R1 **mixed(需一处后端改动 + 版本前提)**、签名/公证 entitlement **mixed(核心子句成立,捆绑表述需修正)**。
---
## R1OAuth 后端 / Casdoor 协调(解锁 D1 的最小路径)
核验结论:**mixed**。「public client + PKCE + loopback」成立,但「后端零改动」为假——需且仅需一处后端 audience 改动,且依赖 Casdoor 版本 ≥ ~v3.3。
**已确定(源码级):**
- Casdoor 支持 public clientPKCE`CodeChallenge` 非空)时 `client_secret` 可空;传了则须正确(`token_oauth.go` / `token_oauth_util.go`)。
- loopback 短路放行:`IsValidOrigin()``util/validation.go`)用 `url.Hostname()` 剥端口后比对白名单 `http://127.0.0.1` / `http://localhost` / ...,命中即放行,`http://127.0.0.1:<任意端口>/<任意路径>` 无需进 `RedirectUris` 列表。
- aud 默认 = 签发 application 的 `client_id``token_jwt.go:544`)。
- 本仓库已核实根因:`internal/jwtauth/middleware.go:150` 当前是单值 `expected.AnyAudience = jwt.Audience{a.cfg.OIDCAudience}``docker/compose.snippet.yaml:28``CDROP_OIDC_AUDIENCE` 设为 web `cdrop` client_id。桌面新 client 的 token `aud=<desktop client_id>` 会被这条按 audience 100% 拒(401)。
- `internal/httpapi/auth.go:92-93``148-149` 已核实:`/api/auth/exchange``OIDCClientSecret` 非空时附 `client_secret`confidential 代理)——桌面端**不应复用**。
**最小落地路径(解锁 D1):**
1. Casdoor 新建独立 application `cdrop-desktop`token format = JWT`client_secret` 留空,**不可**勾选 shared application(否则 aud 被改写成 `clientId-org-owner`)。`RedirectUris` 建议登记一条 `http://127.0.0.1/callback` 作文档兜底(loopback 短路理论上登记与否都放行,待实测确认顺序)。
2. 桌面端 Go 起临时 loopback server**必须 bind IPv4 字面量 `127.0.0.1`**,白名单不含 IPv6 `[::1]`),直连 Casdoor 做 public PKCE 换 token**绕过** `/api/auth/exchange`。Casdoor 专有路径:authorize = `/login/oauth/authorize`token = `/api/login/oauth/access_token`(非标准 `/oauth/token`)。authorize 与 token 两步的 `redirect_uri` 须逐字一致(先定端口、全程同一 URL 串)。
3. **后端唯一改动(方案 A,推荐)**:把 `CDROP_OIDC_AUDIENCE` 从单值扩成逗号分隔多值(web + desktop 两个 client_id),`middleware.go:150` 展开进 `AnyAudience`go-jose 的 `AnyAudience` 已是「命中其一即可」语义)。issuer + JWKS + 签名校验与 web 完全一致,无需动。
- 备选 B:桌面带 RFC 8707 `resource` 参数令 aud 收敛——依赖 Casdoor 较新版本且 authorize/token 两步须带同一 resource,不确定性更高,不推荐。
- 备选 C`CDROP_OIDC_AUDIENCE` 置空关 audience 校验——最弱,仅保留 issuer + 签名。
**版本前提:** loopback 短路 2026-04-05commit `ea56cfec`)才接入 redirect 校验,首个含此改动的 release 约 v3.3.02026-04-12)。早于此的 Casdoor 会拒未注册的 `127.0.0.1:<port>`。prod 自建 Casdoor 版本未锁定,须先核实 ≥ v3.3(建议直接升近版,最新 v3.89.0 / 2026-06-13)。
---
## D2menubar / tray + 窗口生命周期
核验结论:托盘论断 **refuted**——Wails v2.12.0 **无任何可用内建托盘 API**。已核实 `options.App``Tray` 字段,runtime 包无托盘函数,`pkg/menu/tray.go` 等是 2022 废弃分支遗留、仅被 devserver 引用的孤儿死代码(README 在 `onhold/` 目录)。Issue #4990 已被官方 closed as not planned。维护者明确「Systray will not be supported in v2」。
**决定:**
- **窗口生命周期(v2 原生可做)**:`StartHidden:true` + `HideWindowOnClose:true` 实现「启动即驻留、关闭隐藏不退出」;或 `OnBeforeClose(ctx) bool``runtime.WindowHide(ctx)``return true`。**二者互斥**`OnBeforeClose` 仅在 `HideWindowOnClose=false` 时触发),不可同设。
- **托盘图标 + 菜单 + 点击回调(必须第三方/自写)**:macOS 首选先验证 `github.com/ra1phdd/systray-on-wails` 的非阻塞 `Register()`(让 Wails 接管主线程事件循环,规避 NSApplication 冲突);不达标则回退自写 CGO/ObjC `NSStatusBar+NSMenu` 绑定(最可控)。`getlantern/systray` 在 macOS+Wails 有 objc linking conflict**排除**。Windows 侧用 `fyne.io/systray`(活跃维护 fork)。
- **隐藏 dock 图标(accessory**`mac.Options.ActivationPolicy` 在 v2.12 仍被注释掉、不可用。已核实本仓库 `desktop/build/darwin/Info.plist``CFBundleIdentifier` 但**无 `LSUIElement`**——需手动加 `<key>LSUIElement</key><true/>`。accessory 有「启动瞬间 dock 图标闪现约 10ms」系统通病,无法消除。
**v3 决策闸:** v2 的 D2(托盘)+ D5(多窗口)需要堆 2-3 套 CGO workaround。Wails v3alpha.92API 已稳定、有生产案例)原生覆盖跨平台 systray + 多窗口 + ActivationPolicy。若 menubar + Quick Send 是产品核心体验,应把「直接上 v3-alpha」作为正式决策选项评估,而非在 v2 上累积 workaround。
---
## D3:系统通知(三按钮:复制到本机 / 忽略 / 打开)
核验结论:v2 **无内置通知 API**(所有 `runtime.RequestNotificationAuthorization` 等是 v3 特性)。按平台分实现、统一 Go 接口(build tag 隔离 `notify_darwin.go` / `notify_windows.go`)。
**决定:**
- **macOS**`UNUserNotificationCenter`cgo + ObjC)。三按钮 = 注册 `UNNotificationCategory`(含 3 个 `UNNotificationAction`,「忽略」可设 `destructive`),发通知设 `categoryIdentifier`;点击经 `UNUserNotificationCenterDelegate didReceiveResponse` 回调,`response.actionIdentifier` 区分。**硬门槛**app 须有 `CFBundleIdentifier` 且已签名(Developer ID 或 Apple Development),dev/未签名构建下 API 不工作,且须从 `.app` bundle 运行(裸二进制抛 `NSInternalInconsistencyException`)。最低 macOS 11.0。→ **D3 验收与 D6 签名强绑定**
- **Windows**:已在 go.mod 的 `git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3`2025-01-17,仍维护)。`Notification.Actions` 加 3 个 `toast.Action{Type: Foreground, Content, Arguments}``toast.SetActivationCallback``Arguments` 区分。**坑**:须先 `wintoast.SetAppData{AppID, GUID, ...}` 注册 AUMID + stub CLSID,否则 COM 激活路径失效、回调收不到(回退 PowerShell 路径时 `SetActivationCallback` 完全失效)。
- **不直接依赖 v3 notifications service**(绑 v3 `application.ServiceOptions` 生命周期 + alpha bug #4449);借鉴其 API 形态移植到 v2。
- **落地节奏**:先实现「单击整条通知拉起主窗 + 打开文件」基础版打通主链路,三按钮作增量。
---
## D4:剪贴板自动同步
核验结论:两份调研一致——现成库都不做敏感内容过滤,会同步密码。
**决定:**
- **统一底层 `golang.design/x/clipboard v0.8.0`**2026-06-07,活跃;macOS 已 purego/objc **无 cgo**Windows/Linux 纯 syscall)覆盖 macOSchangeCount 轮询)+ Windows`GetClipboardSequenceNumber` 1s 轮询)。**纠正 PLAN.md L167**:该库 Windows 是**轮询**不是 `AddClipboardFormatListener` 事件;两端机制其实一致(diff 计数器轮询),轮询间隔写死 1s 不可配。
- **外包一层 `ClipboardSource` 接口做 D4 业务逻辑**(库一概不替你做):
1. **敏感内容过滤**macOS 读 `-types` 比对 `org.nspasteboard.ConcealedType` / `TransientType` / `AutoGeneratedType` 及私有 UTI`com.agilebits.onepassword` 等);Windows 读到内容后 `EnumClipboardFormats` 检查 `ExcludeClipboardContentFromMonitorProcessing` / `CanUploadToCloudClipboard`,命中则跳过上传。库的 `Watch` 不检查这些,直接用 = 同步密码。
2. **回环防护**(PLAN R4):自写剪贴板会自增 changeCount/sequenceNumber 触发自己的 Watch 风暴。写入前记 hash + 自写时间窗/序号,Watch 收变更先比对跳过(两端都要拦)。
3. **去重 + debounce**:复用已在 go.mod 的 `github.com/bep/debounce` + 内容 hash。
- **写回本机剪贴板**优先用库的 `Write`(规避 Wails #4132macOS LANG 为空时 `ClipboardSetText` 编码 bug)。
- **go.mod 版本**:库 go.mod 要求 go 1.24,本仓库 `desktop/go.mod` 声明 go 1.23(本机 toolchain 1.26.3 满足)——引入需把模块声明升到 1.24+;若改走自写 purego 封装则只依赖 purego v0.10.x,版本要求更宽松。
- `atotto/clipboard` **排除**shell out pbcopy/pbpaste,无 Watch、无 changeCount)。
> **macOS 剪贴板隐私授权(2025-2026 平台级变化)**macOS 15.4 预览、预计 macOS 26 (Tahoe) 正式启用——非用户 paste UI 触发的程序化读取会弹授权 Alert。Apple 新增 `detectPatterns/detectValues` + `accessBehavior` 可只探测类型不读内容、不触发弹窗,但 `golang.design v0.8.0` 尚未适配。这正中 cdrop「后台自动监听上传」要害,是 D4 最大不确定项,须真机实测。
---
## D5:全局热键 + Quick Send 浮窗
核验结论:热键库可用性 **supported**,但**必须 pin v0.4.1**——这是最关键的一字之差决策。
**决定(热键):**
- **`golang.design/x/hotkey` pin 到 v0.4.1**Carbon `RegisterEventHotKey`)。理由:v0.6.02026-06-06)已把 macOS 从 Carbon 换成 **CGEventTap**,导致**每个**全局热键都要求 App 获 Accessibility / Input Monitoring 授权,否则 `Register()` 直接返回 error(源码 `isAXTrusted`/`registerTap` 返回 NULL)。对「瘦客户端 + tray + Quick Send」产品开机弹授权是显著体验劣化。v0.4.1 的 Carbon 路径对 `Cmd+Shift+V` 这类组合键**零授权弹窗**Electron/VS Code 同此路径)。
- **附带收益**v0.5.0+ 强制 go 1.24v0.4.1 仅需 go 1.17,反而与现有 toolchain 兼容。
- 用法:`hotkey.New([]hotkey.Modifier{hotkey.ModCmd, hotkey.ModShift}, hotkey.KeyV)`Windows 侧 `ModCtrl`),`Cmd+Shift+V` / `Ctrl+Shift+V` 全支持。Windows 走 `RegisterHotKey`,零 cgo、无权限问题。
- **不调 `mainthread.Init`**——Wails 已自带主线程 Cocoa run loop(同 Fyne/Ebiten/Gio 情形),调它会争夺主线程所有权。
- **排除** `ZeronoFreya/go-hotkey`README 自述「只适用于 windows」,非跨平台)。
- 产品化时建议支持热键重绑(规避 `Cmd+Shift+V` 与其他 app 撞车)。
**决定(Quick Send 浮窗):**
- **v2 架构上单 app 单原生窗口**issue #4413/#1480),无法开第二个原生窗口。v2 内只能用「同窗 HTML 浮层/路由切换」或「另起独立 Wails 进程」近似,都不理想。
- 无边框 + 置顶:`Frameless:true` + `AlwaysOnTop:true` + `runtime.WindowSetAlwaysOnTop()`;拖拽用 CSS `--wails-draggable:drag`
- **「失焦自隐」v2 无现成窗口失焦钩子**——前端 blur/visibilitychange 在 WebView 内对 OS 级窗口失焦不可靠,正解是自写 ObjC `NSWindowDelegate windowDidResignKey`v2 路线最脏的一块)。
- **这一条是上 v3 的最强理由**(v3 多窗口每窗一等对象 + 独立生命周期)。
---
## D6codesign / notarize / 打包 + CI
核验结论:「无特殊 entitlement 公证障碍」**核心子句成立**,但捆绑表述 **mixed**,需修正两处。
**决定 / 修正:**
- **公证只校验结构性条件**Hardened Runtime + secure timestamp + Developer ID 全量签名。基于 entitlement 拒绝的**唯一**情形是 `com.apple.security.get-task-allow=true`(调试 entitlement)。accessibility / input-monitoring / 通知授权都不是签名期 entitlement,而是运行期 TCC 授权,公证扫描既不检查也不会因此失败。
- **修正 1**:全局热键是否需「辅助功能弹窗」**取决于实现路径**,非必然——按 D5 选定的 v0.4.1(Carbon)路径**零权限弹窗**;只有 CGEventTapv0.5.0+)才强制 Accessibility。
- **修正 2**:macOS 通知有一道硬门槛被「仅需弹窗」框架略过——`UNUserNotificationCenter` 要求 app 有 `CFBundleIdentifier` 且已签名,dev/未签名不工作。这是对签名的**运行期依赖**(不是公证 entitlement 障碍),但意味着 D3 无法完全脱离 D6 独立验收。
- **工具链**:用 `notarytool``gon`/`altool` 已退役,Wails #3290 的公证失败实为工具链而非 entitlement)。非沙箱 Developer ID 分发**不必**为通知/网络/剪贴板声明专门 entitlement——PLAN.md L180 把这些列入 entitlement 有过度声明之嫌,应收敛。
- **跨平台纪律**:平台代码用 Go build tag,单 monorepo 单分支,Windows 只 pull 不 commit。
---
## 关键来源
- Casdoor loopback / PKCE / aud`util/validation.go``IsValidOrigin`)、`object/application_util.go``IsRedirectUriValid`)、`object/token_jwt.go:544``object/token_oauth_util.go`PR #3301、commit `ea56cfec`RFC 8252 §7.3、RFC 8707。
- 本仓库根因:`internal/jwtauth/middleware.go:150``internal/httpapi/auth.go:92``docker/compose.snippet.yaml:28``desktop/build/darwin/Info.plist`
- Wails 托盘/窗口:Discussion #4514 / #1438、Issue #4990closed not planned)、#4413 / #1480v3.wails.io systray/multiple-windows;本地 `wails/v2@v2.12.0/pkg/options/options.go`
- 热键:golang-design/hotkey releasesv0.6.0 CGEventTap、v0.5.0 Go 1.24)、`hotkey_darwin.go` @v0.4.1 vs @main、electrobun #334、dev.to「macOS Global Shortcut」。
- 剪贴板:golang-design/clipboard `clipboard_windows.go` / `clipboard_darwin.go`、v0.8.0 releasenspasteboard.orgMS Learn `AddClipboardFormatListener` / Clipboard Formats9to5Mac / MacRumorsmacOS detect API);Wails #4132
- 通知:Wails PR #4098macOS 须签名+bundle id)、Issue #4449go-toast v2 / wintoast pkg.go.devApple LSUIElement 论坛 thread/749689。
- 公证:Apple Developer News id=09032019a、Eclectic Light「Notarization: the hardened runtime」、Apple 论坛 thread/735223、Wails #3290Wails #4592 / commit 12b9f3amacOS 26 Tahoe webview 崩溃修复)。
---
> 以下两节为 sweep 中两个研究 agent 瞬时 API 断连失败后的补跑产物(2026-06-13 补全),分别覆盖 D1 OAuth 的客户端实现机制与 D6 的具体打包命令。引号风格用弯引号,与上方合成简报的角括号略有出入,内容以本节为准。
## OAuth 原生回调通道——Wails 客户端机制(D1 补充)
本节只覆盖 Wails/Go 客户端侧的实现机制。前提已确定:public client + PKCE,回调用 `http://127.0.0.1:<临时端口>/callback`Casdoor 对 loopback 任意端口放行,后端 audience 改多值校验。Casdoor 端点:authorize=`/login/oauth/authorize`token=`/api/login/oauth/access_token`
### 1. loopback vs 自定义 scheme `cdrop://` 的取舍(Wails 语境)
两条路线都符合 RFC 8252 对原生应用的要求(§7.1 私有 scheme、§7.3 loopback),但在 Wails 下实现成本差异明显。
**loopback`http://127.0.0.1:<port>`):**
- 实现完全在 Go 进程内:起一个临时 `net/http` 监听、`runtime.BrowserOpenURL` 打开授权页、回调命中本地端口即拿到 `code`。无需任何 OS 级注册。
- 不需要单实例锁。授权流全程在同一个已运行的进程里闭环,浏览器回跳打到的是本进程开的端口,不会拉起第二个 app 实例。
- 跨平台零差异:macOS / Windows / Linux 代码一致,只依赖 `net/http`
- 必须用 IP 字面量 `127.0.0.1`(或 `[::1]`),不要用 `localhost`——RFC 8252 §8.3:用 `localhost` 可能因 DNS / hosts 解析意外监听到非回环接口。端口用 OS 分配的临时端口(`:0`),符合 RFC 8252 §7.3“服务器 MUST 允许请求时指定任意端口”。
**自定义 scheme`cdrop://callback`):**
- 需要 OS 级登记:macOS 在 `Info.plist``CFBundleURLTypes``CFBundleURLSchemes`(值 `cdrop`);Windows 要在注册表 `HKEY_CLASSES_ROOT\cdrop`(或 per-user `HKCU\Software\Classes\cdrop`)写 `URL Protocol` 键 + `shell\open\command` 指向 exeLinux 走 `.desktop``x-scheme-handler/cdrop`
- 必须配单实例锁。点 `cdrop://` 时 OS 会重新拉起 app 的“第二个实例”,深链作为命令行参数传入。需要 `options.App.SingleInstanceLock` 把这个 deep link 转发给首实例,否则 token 落在一个马上要退出的临时进程里,拿不到。
- macOS 上单实例锁与深链协作有已知坑(见 wails issue #5089v3 的 single instance lock 与 `OpenedWithURL` 不兼容;v2 也需自己从 `Args` 解析 URL 并 `WindowUnminimise` + `Show`)。
scheme 路线的 `SingleInstanceLock` 形态(作为对比,**本项目不采用**):
```go
// 仅作对比:scheme 方案才需要这一层;loopback 方案不需要
SingleInstanceLock: &options.SingleInstanceLock{
UniqueId: "net.commilitia.cdrop",
OnSecondInstanceLaunch: func(d options.SecondInstanceData) {
// d.Args 里含被 OS 透传的 cdrop://callback?code=...&state=...
for _, arg := range d.Args {
if strings.HasPrefix(arg, "cdrop://") {
runtime.WindowUnminimise(appCtx) // 回调不会自动聚焦窗口
runtime.Show(appCtx)
runtime.EventsEmit(appCtx, "oauth:callback", arg)
}
}
},
},
```
**推荐:loopback。** 在 Wails 下它省掉了单实例锁、`Info.plist`/注册表登记、深链解析与跨平台分叉这一整套,授权流在单进程内自洽,代码量和真机调试面都小得多。`cdrop://` 的唯一优势是不占端口、回调 URL 更“原生”,但对瘦客户端不值这些成本。scheme 留作未来若需“浏览器里点 cdrop 链接唤起桌面端”的备选——那是另一个用例(深链唤起),与本次登录回调无关。
### 2. `runtime.BrowserOpenURL` 用法与注意
签名:
```go
func BrowserOpenURL(ctx context.Context, url string)
```
它调用系统默认浏览器打开 URL,本身不阻塞、无返回值(错误只在内部记日志,见 wails issue #3261,因此打开后要靠 loopback 回调或超时判定结果,不能依赖返回值)。`ctx` 用 Wails 在 `OnStartup` 注入并由你保存的 app context。
**为什么不能在 WebView 内用 `window.location = authorizeURL`** Wails 的前端跑在系统 WebView 里,`window.location` 跳转会把 **WebView 自身**导航到 Casdoor 授权页,等于在嵌入式 user-agent 里做授权——这恰恰违反 RFC 8252 §8.12(原生应用 MUST NOT 用 embedded user-agent 做授权请求)与 §5MUST 用 external user-agent)。后果实际可见:你的 React UI 整个被 authorize 页替换掉,回跳后 WebView 停在 `127.0.0.1` 而非应用界面,且拿不到系统浏览器里已有的 SSO 会话。正确做法是 `runtime.BrowserOpenURL`,把授权放到独立的系统浏览器,WebView 原地不动,靠 loopback 回调拿结果。
### 3. 完整 PKCE 桥接流程 + 代码骨架
数据流:
React 点登录 → 调 Wails 绑定的 Go 方法 `StartLogin()` → Go 生成 PKCE `verifier`/`challenge` 与随机 `state`,起 `127.0.0.1:0` 临时监听,拼 authorize URL → `runtime.BrowserOpenURL` 打开系统浏览器 → 用户授权 → 浏览器回跳 loopback,Go 捕获 `code` 并校验 `state` → **Go 侧**用 `verifier` 完成 token 交换 → `runtime.EventsEmit` 把 token 结果推回前端 → React `EventsOn` 收到后落 store。
**token 交换放 Go 侧(强烈建议)。** PKCE `verifier` 与换得的 token 全程不进 WebView/JS 上下文,避免 `verifier` 或 refresh token 暴露在前端可被注入脚本读取的内存里;Go 还能把 token 直接持久化进 OS keychain(后续里程碑)。前端只收一个“登录成功/失败 + 必要的展示信息”事件。
Go 端骨架(loopback server 起停、超时、state 校验):
```go
package backend
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"net"
"net/http"
"net/url"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
const (
AUTHORIZE_ENDPOINT = "https://<casdoor-host>/login/oauth/authorize"
TOKEN_ENDPOINT = "https://<casdoor-host>/api/login/oauth/access_token"
DESKTOP_CLIENT_ID = "<desktop-client-id>"
LOGIN_TIMEOUT = 3 * time.Minute
)
type App struct
{
ctx context.Context
}
// OnStartup 时保存 ctx,供 runtime.* 调用
func (a *App) OnStartup(ctx context.Context)
{
a.ctx = ctx
}
// StartLogin 由前端通过 Wails 绑定调用;整个授权流在 goroutine 里跑,
// 结果通过 EventsEmit 推回,不在此方法的返回值里阻塞前端。
func (a *App) StartLogin()
{
go a.runLoginFlow()
}
func (a *App) runLoginFlow()
{
verifier, challenge, err := newPKCE()
if err != nil
{
a.emitErr("PKCE 生成失败", err)
return
}
state, err := randString(24)
if err != nil
{
a.emitErr("state 生成失败", err)
return
}
// :0 让 OS 分配临时端口;只绑回环
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil
{
a.emitErr("无法监听回环端口", err)
return
}
port := ln.Addr().(*net.TCPAddr).Port
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", port)
// 用 channel 把回调结果带出 HTTP handler
type result struct
{
code string
err error
}
resCh := make(chan result, 1)
mux := http.NewServeMux()
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request)
{
q := r.URL.Query()
if e := q.Get("error"); e != ""
{
writeClosePage(w, false) // 见第 4 节
resCh <- result{err: fmt.Errorf("授权被拒绝:%s", e)}
return
}
if q.Get("state") != state
{
writeClosePage(w, false)
resCh <- result{err: fmt.Errorf("state 不匹配,疑似 CSRF")}
return
}
writeClosePage(w, true)
resCh <- result{code: q.Get("code")}
})
srv := &http.Server{Handler: mux}
go srv.Serve(ln)
defer srv.Close()
authURL := AUTHORIZE_ENDPOINT + "?" + url.Values{
"response_type": {"code"},
"client_id": {DESKTOP_CLIENT_ID},
"redirect_uri": {redirectURI},
"scope": {"openid profile groups"},
"state": {state},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}.Encode()
runtime.BrowserOpenURL(a.ctx, authURL)
select
{
case res := <-resCh:
{
if res.err != nil
{
a.emitErr("授权失败", res.err)
return
}
// token 交换在 Go 侧;verifier 不入 WebView
tok, err := exchangeToken(res.code, verifier, redirectURI)
if err != nil
{
a.emitErr("token 交换失败", err)
return
}
// 持久化(keychain 在后续里程碑)后,只把展示所需信息推前端
runtime.EventsEmit(a.ctx, "oauth:success", map[string]any{
"expiresIn": tok.ExpiresIn,
})
}
case <-time.After(LOGIN_TIMEOUT):
{
a.emitErr("登录超时", fmt.Errorf("%s 内未完成授权", LOGIN_TIMEOUT))
}
}
}
func (a *App) emitErr(msg string, err error)
{
runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{
"message": msg,
"detail": err.Error(),
})
}
// --- PKCE 工具 ---
func newPKCE() (verifier, challenge string, err error)
{
verifier, err = randString(64) // RFC 763643-128 字符
if err != nil
{
return "", "", err
}
sum := sha256.Sum256([]byte(verifier))
challenge = base64.RawURLEncoding.EncodeToString(sum[:])
return verifier, challenge, nil
}
func randString(n int) (string, error)
{
b := make([]byte, n)
if _, err := rand.Read(b); err != nil
{
return "", err
}
return base64.RawURLEncoding.EncodeToString(b)[:n], nil
}
```
前端桥骨架(`EventsOn` 落 store`EventsOn` 返回取消函数,组件卸载时调用):
```ts
import { EventsOn } from "../wailsjs/runtime/runtime";
import { StartLogin } from "../wailsjs/go/backend/App";
// 在 app 初始化处注册一次;data 即 Go 端 EventsEmit 的 optionalDataJSON 反序列化后逐个作为参数)
export function wireOAuthEvents(store: AuthStore): () => void
{
const offSuccess = EventsOn("oauth:success", (payload: { expiresIn: number }) =>
{
store.setLoggedIn(payload);
});
const offError = EventsOn("oauth:error", (payload: { message: string; detail: string }) =>
{
store.setError(payload.message);
});
// 返回组合取消函数
return () =>
{
offSuccess();
offError();
};
}
// 登录按钮 onClick
export async function onLoginClick(): Promise<void>
{
await StartLogin(); // 仅触发流程;真正结果走上面的事件
}
```
要点:`EventsEmit(ctx, name, data...)``optionalData``JSON.stringify` 传输,前端回调以 `callback.apply(null, data)` 接收——发单个对象即对应回调首个参数。`wails dev` 下事件走 WebSocket、生产走原生 IPC,协议一致,无需区分。
### 4. loopback server 收尾(回浏览器页 + 关监听)
拿到 `code` 后给浏览器返回一段静态 HTML,提示已登录并尝试自关闭标签页(`window.close()` 仅对脚本打开的窗口可靠,授权跳转打开的标签页常关不掉,故同时给出可读文案兜底)。随后 server 凭 `defer srv.Close()` 关闭监听释放临时端口。
```go
func writeClosePage(w http.ResponseWriter, ok bool)
{
w.Header().Set("Content-Type", "text/html; charset=utf-8")
msg := "已登录,可关闭本页返回 cdrop。"
if !ok
{
msg = "登录未完成,可关闭本页返回 cdrop 重试。"
}
fmt.Fprintf(w, `<!doctype html><html lang="zh-Hans"><head><meta charset="utf-8">`+
`<title>cdrop</title></head><body style="font-family:sans-serif;text-align:center;margin-top:20vh">`+
`<p>%s</p><script>setTimeout(function(){window.close();},800);</script>`+
`</body></html>`, msg)
}
```
`srv.Close()` 立即断开监听与活动连接;因为响应已写完且 `code` 已通过 channel 送出,在 `select` 分支返回后由 `defer` 触发关闭即可,不必等浏览器读完。
### 已确定 vs 待真机实测(OAuth)
**已确定(来自官方 API 签名 / RFC):**
- `runtime.BrowserOpenURL(ctx, url)``runtime.EventsEmit(ctx, name, data...)`、前端 `EventsOn(name, cb)` 返回取消函数——签名稳定。
- loopback 必须用 `127.0.0.1` 而非 `localhost`,端口用临时端口;public client 必须 PKCE;必须用系统浏览器而非 WebView——均为 RFC 8252 明文要求。
- 选 loopback 即无需 `SingleInstanceLock` / `Info.plist` / 注册表。
**待真机实测:**
- Casdoor 对 `redirect_uri` 的 loopback 匹配是否真的“任意端口”放行(前提已声明,仍需端到端验一次端口变动下不报 `redirect_uri_mismatch`)。
- `scope` 取值(示例写 `openid profile groups`)需与 Casdoor 实际 scope / groups claim 配置对齐。
- token 交换接口 `/api/login/oauth/access_token` 的请求体格式(form vs JSON)与 `exchangeToken` 实现需按 Casdoor 实测确定。
- macOS WebViewWKWebView)与 Windows WebView2 下 `BrowserOpenURL` 是否都正确落到“系统默认浏览器”而非内置浏览器,需各平台各验一次。
- 浏览器标签页 `window.close()` 在 Chrome / Safari / Edge 上的实际可关闭性(多数授权跳转开的标签关不掉,文案兜底是否够友好)。
### 来源(OAuth
- Wails RuntimeBrowserOpenURL / Events 签名):https://pkg.go.dev/github.com/wailsapp/wails/v2/pkg/runtime
- Wails optionsSingleInstanceLock / SecondInstanceData 结构):https://pkg.go.dev/github.com/wailsapp/wails/v2/pkg/options
- Wails Single Instance Lock 指南:https://wails.io/docs/guides/single-instance-lock/
- Wails Custom Protocol Schemes 指南:https://wails.io/docs/guides/custom-protocol-schemes/
- Wails Browser 运行时参考:https://wails.io/docs/reference/runtime/browser/
- macOS 单实例锁与深链不兼容 issuehttps://github.com/wailsapp/wails/issues/5089
- BrowserOpenURL 错误不记录 issuehttps://github.com/wailsapp/wails/issues/3261
- RFC 8252 OAuth 2.0 for Native Apps(§5/§6/§7.1/§7.3/§8.3/§8.12):https://datatracker.ietf.org/doc/html/rfc8252
- RFC 7636 PKCEhttps://datatracker.ietf.org/doc/html/rfc7636
---
## 打包·签名·公证操作手册(D6 补充)
本节只补**具体命令与 CI 配置**;entitlement/公证“会不会被拒”的判断已在前文完成。环境基准:Wails v2.12.0Go),macOS universal 优先 + Windows x64,瘦客户端。
> 重要前提:Wails 官方 signing 指南至今仍推荐 `gon`,但该工具已停止维护、且依赖已废弃的 `altool` 语义。本手册一律改用 Apple 现行的 `codesign` + `notarytool` + `stapler` 链路,这是已确定的正确路径。
### 1. macOS
#### 1.1 构建产物与已知坑(已确定)
```bash
wails build -platform darwin/universal -clean
```
- 产物:`build/bin/<AppName>.app`universal `.app` bundle`lipo` 已合并 arm64 + amd64 两份 Go 二进制)。`<AppName>` 来自 `wails.json``outputfilename`/项目名。
- 项目脚手架在 `build/darwin/` 下生成 `Info.plist`(模板 `Info.plist``Info.dev.plist`),这是 `.app``Contents/Info.plist` 的来源。改 bundle ID、版本号、`LSMinimumSystemVersion` 等都改这里。
- 已知坑:
- universal 构建要求**本机同时具备 arm64 与 amd64 的 CGO 工具链**。在 Apple Silicon 的 `macos-latest`macos-14/15runner 上原生满足;不要尝试从 Linux 交叉编译 darwinCGO + macOS SDK 缺失,社区反复确认不可行)。
- `wails build` 本身**不签名、不公证 macOS 产物**(与 Windows 的 `-nsis` 不同,没有内建签名参数)。签名/公证完全是构建后的独立步骤。
- Wails 没有内建 DMG 封装,需自己做(见 1.4)。
#### 1.2 codesignDeveloper ID Application,启用 hardened runtime
公证的前置硬性要求:**hardened runtime`--options runtime`+ secure timestamp`--timestamp`+ Developer ID Application 证书**。
```bash
APP="build/bin/YourApp.app"
IDENTITY="Developer ID Application: Your Name (TEAMID1234)"
# 先确认本机/keychain 里的签名身份
security find-identity -v -p codesigning
codesign --force --deep \
--options runtime \
--timestamp \
--entitlements build/darwin/entitlements.plist \
--sign "$IDENTITY" \
"$APP"
# 校验
codesign --verify --deep --strict --verbose=2 "$APP"
codesign --display --entitlements - "$APP"
```
`--deep` 取舍(已确定):瘦客户端的 `.app` 里只有一个主可执行文件,`--deep` 加不加都能签上;但 `--deep` 是 Apple 明确**不推荐**用于发布签名的(盲签所有嵌套项、掩盖结构问题)。当前瘦客户端单条命令即可;若将来加入嵌套二进制,改为从内到外逐个签。
#### 1.3 notarytool 提交 + stapler 装订(已确定)
`notarytool` **不接受裸 `.app`**,必须先打成 zip`ditto`,保留 bundle 结构)或 DMG 再提交。
```bash
# 1) 用 ditto 打 zip--keepParent 保留 .app 顶层目录)
ditto -c -k --keepParent "build/bin/YourApp.app" "YourApp.zip"
# 2A) Apple ID + app-specific password
xcrun notarytool submit "YourApp.zip" \
--apple-id "you@example.com" \
--password "$APP_SPECIFIC_PASSWORD" \
--team-id "TEAMID1234" \
--wait
# 2B) 或用 App Store Connect API keyCI 首选)
xcrun notarytool submit "YourApp.zip" \
--key "AuthKey_KEYID.p8" \
--key-id "KEYID12345" \
--issuer "ISSUER-UUID" \
--wait
# 失败时拉日志
xcrun notarytool log <submission-id> \
--key "AuthKey_KEYID.p8" --key-id "KEYID12345" --issuer "ISSUER-UUID"
# 3) 公证通过后把 ticket 装订进 .appstaple 的是 .app,不是 zip
xcrun stapler staple "build/bin/YourApp.app"
xcrun stapler validate "build/bin/YourApp.app"
```
#### 1.4 DMG 封装(已确定)
最终分发媒介里**只放已公证 + 已装订的 `.app`**,再对 DMG 本身签名,然后对 DMG 单独走一遍公证 + staple(推荐)。
```bash
# 方式 Acreate-dmgbrew install create-dmg
create-dmg \
--volname "YourApp" \
--app-drop-link 450 190 \
--codesign "Developer ID Application: Your Name (TEAMID1234)" \
"YourApp.dmg" \
"build/bin/" # 源目录,里面放已公证的 YourApp.app
# 方式 B:纯 hdiutil + codesign(无额外依赖)
hdiutil create -volname "YourApp" -srcfolder "build/bin/YourApp.app" -ov -format UDZO "YourApp.dmg"
codesign --force --timestamp --sign "Developer ID Application: Your Name (TEAMID1234)" "YourApp.dmg"
# 对 DMG 再公证 + staple
xcrun notarytool submit "YourApp.dmg" --key ... --key-id ... --issuer ... --wait
xcrun stapler staple "YourApp.dmg"
```
#### 1.5 最小 entitlements.plist(已确定)
位置:`build/darwin/entitlements.plist`Wails 约定路径,由 `--entitlements` 引用)。瘦客户端(Developer ID 渠道、**非** Mac App Store)的最小集合只需网络客户端:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
```
- **不要**加 `com.apple.security.app-sandbox`App Sandbox 只有上架 Mac App Store 才强制;Developer ID 渠道开启沙盒只会平添文件访问麻烦。
- **purego `dlopen` 系统 framework 在 hardened runtime 下:不需要任何额外 entitlement**。Library Validation 只拦“非 Apple 签名、且非同 Team ID 签名”的代码;系统 frameworkCarbonCocoaCoreFoundation 等)由 Apple 签名,`dlopen` 它们**不触发**拦截。因此**不需要** `com.apple.security.cs.disable-library-validation`,也**不需要** `allow-dyld-environment-variables`(这两个键只在加载第三方/未签名 dylib 时才需要,加了反而削弱 Gatekeeper 评估)。
- **Carbon 全局热键(RegisterEventHotKey)不需要任何 entitlement**——普通系统 API,不涉及输入监控类隐私权限。
> 待真机实测:purego 在 hardened runtime 下 `dlopen` 系统 framework 应无碍,但请在“签名 + 公证后的 `.app`”上跑一次,确认热键与网络功能正常、没有因 `dlopen` 路径写法意外触发限制。
#### 1.6 Wails 对 darwin 签名的内建支持(已确定)
`wails build -platform darwin/universal` **无签名参数**,不做 codesignnotarize;签名、公证、DMG 全是构建后的外部步骤。`build/darwin/` 下有 `Info.plist``Info.dev.plist``entitlements.plist` 非自动生成,需手动放进该路径。universal 由 Wails 内部 `lipo` 合并,无需手动 `lipo`
### 2. Windows
#### 2.1 构建产物(已确定)
```bash
wails build -platform windows/amd64 -clean # 仅 exe
wails build -platform windows/amd64 -nsis -clean # 附 NSIS 安装器
```
NSIS 配置在 `build/windows/installer/``info.json` + `.nsi`),数据取自 `wails.json``Info` 段,安装器输出到 `build/bin/`。瘦客户端用内建 NSIS 已足够,不必引入 WiX。
#### 2.2 signtool 签名(已确定)
证书用标准代码签名证书即可(**不需要 EV**)。注:2023 年起 OV 证书私钥须存硬件 token/HSM 或云签名服务,CI 多走云签名(SSL.com eSignerDigiCert KeyLocker);下面是本地 `.pfx` 经典流程。
```powershell
# /fd 文件摘要; /tr RFC3161 时间戳(必须,证书过期后签名仍有效); /td 时间戳摘要
signtool sign /fd sha256 /tr http://ts.ssl.com /td sha256 /f certificate.pfx /p "%CERT_PASSWORD%" .\build\bin\YourApp.exe
```
**NSIS 的已知坑(已确定)**`wails build -nsis` 只签**安装器和卸载器**,**不签**安装后落到 `Program Files` 的主程序 `.exe`。正确顺序:先 signtool 签主 `.exe` → 再生成安装器 → 再签安装器(或在 `project.nsi``!finalize``!uninstfinalize` 调 signtool)。
### 3. GitHub Actions
#### 3.1 macOS job 骨架(已确定)
`apple-actions/import-codesign-certs`v7,把 base64 的 `.p12` 导入临时 keychain+ App Store Connect API key 跑 notarytool。secrets`APPLE_CERT_P12_BASE64``APPLE_CERT_PASSWORD``AC_API_KEY_P8``AC_API_KEY_ID``AC_API_ISSUER_ID``APPLE_SIGN_IDENTITY`
```yaml
jobs:
macos:
runs-on: macos-latest # Apple Silicon,原生支持 universal CGO
steps:
- uses: actions/checkout@v4
with: { submodules: recursive } # cjk-autospace 等 submodule
- uses: actions/setup-go@v5
with: { go-version: '1.22' } # Wails v2.12 要求 Go >= 1.21
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0
- run: wails build -platform darwin/universal -clean
- uses: apple-actions/import-codesign-certs@v7
with:
p12-file-base64: ${{ secrets.APPLE_CERT_P12_BASE64 }}
p12-password: ${{ secrets.APPLE_CERT_PASSWORD }}
- name: Codesign
run: |
codesign --force --options runtime --timestamp \
--entitlements build/darwin/entitlements.plist \
--sign "${{ secrets.APPLE_SIGN_IDENTITY }}" "build/bin/YourApp.app"
codesign --verify --deep --strict --verbose=2 "build/bin/YourApp.app"
- name: Notarize + staple
env: { AC_API_KEY_P8: '${{ secrets.AC_API_KEY_P8 }}' }
run: |
echo "$AC_API_KEY_P8" > /tmp/AuthKey.p8
ditto -c -k --keepParent "build/bin/YourApp.app" "/tmp/YourApp.zip"
xcrun notarytool submit "/tmp/YourApp.zip" \
--key /tmp/AuthKey.p8 --key-id "${{ secrets.AC_API_KEY_ID }}" \
--issuer "${{ secrets.AC_API_ISSUER_ID }}" --wait
xcrun stapler staple "build/bin/YourApp.app"
rm /tmp/AuthKey.p8
- name: Package DMG
run: |
brew install create-dmg
create-dmg --volname "YourApp" --app-drop-link 450 190 \
--codesign "${{ secrets.APPLE_SIGN_IDENTITY }}" "YourApp.dmg" "build/bin/"
- uses: actions/upload-artifact@v4
with: { name: YourApp-macos, path: YourApp.dmg }
```
#### 3.2 Windows job 骨架(已确定)
```yaml
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with: { submodules: recursive }
- uses: actions/setup-go@v5
with: { go-version: '1.22' }
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0
- run: wails build -platform windows/amd64 -clean
- name: Restore signing certificate
shell: pwsh
run: |
$bytes = [Convert]::FromBase64String("${{ secrets.WIN_SIGNING_CERT }}")
[IO.File]::WriteAllBytes("$env:RUNNER_TEMP\cert.pfx", $bytes)
- name: Sign main exe
shell: pwsh
run: |
$signtool = (Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" `
| Sort-Object FullName -Descending | Select-Object -First 1).FullName
& $signtool sign /fd sha256 /tr http://ts.ssl.com /td sha256 `
/f "$env:RUNNER_TEMP\cert.pfx" /p "${{ secrets.WIN_SIGNING_CERT_PASSWORD }}" .\build\bin\YourApp.exe
- run: wails build -platform windows/amd64 -nsis -skipbindings
- name: Sign installer
shell: pwsh
run: |
$signtool = (Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" `
| Sort-Object FullName -Descending | Select-Object -First 1).FullName
& $signtool sign /fd sha256 /tr http://ts.ssl.com /td sha256 `
/f "$env:RUNNER_TEMP\cert.pfx" /p "${{ secrets.WIN_SIGNING_CERT_PASSWORD }}" `
.\build\bin\YourApp-amd64-installer.exe
- uses: actions/upload-artifact@v4
with: { name: YourApp-windows, path: build/bin/*installer.exe }
```
### 已确定 vs 待真机实测(D6)
- **已确定**:所有命令标志语义、产物路径(`build/bin/<App>.app``build/darwin/entitlements.plist``build/windows/installer/`)、最小 entitlements(仅 `network.client`)、purego dlopen 系统 framework 不需 `disable-library-validation`、Carbon 热键不需 entitlement、notarytool 必须先 zipDMG、NSIS 不签主 exe 的坑。
- **待真机实测**:① 签名+公证后的 `.app` 在真机上 purego dlopen + 全局热键 + 网络全链路实跑;② Windows 安装器实际产物文件名;③ `signtool` 路径随 runner SDK 版本变化、通配是否仍命中。
### 来源(D6
- Wails Code Signing 指南:https://wails.io/docs/guides/signing/
- Wails Mac App Store 指南(entitlements/codesign --options=runtime、build/darwin 路径):https://wails.io/docs/guides/mac-appstore/
- Wails NSIS installer 指南(build/windows/installer、-nsis):https://wails.io/docs/guides/windows-installer/
- Wails NSIS 不签主 exe 的 issue #3716https://github.com/wailsapp/wails/issues/3716
- Wails Crossplatform buildGitHub Actions、darwin/universal 矩阵):https://wails.io/docs/guides/crossplatform-build/
- notarytool man pagehttps://keith.github.io/xcode-man-pages/notarytool.1.html
- Apple TN3147 迁移到新公证工具:https://developer.apple.com/documentation/technotes/tn3147-migrating-to-the-latest-notarization-tool
- Apple Disable Library Validation entitlementhttps://developer.apple.com/documentation/bundleresources/entitlements/com_apple_security_cs_disable-library-validation
- Apple 论坛「系统 framework 由 Apple 签名不触发 LV」:https://developer.apple.com/forums/thread/115451
- apple-actions/import-codesign-certshttps://github.com/Apple-Actions/import-codesign-certs
- create-dmghttps://github.com/create-dmg/create-dmg