# cdrop 桌面端 D1 前决策就绪简报

> 框架基线：Wails v2.12.0（Go 1.23 模块声明，本机 toolchain 1.26.3）+ 系统 WebView，瘦客户端，业务全走 `drop.commilitia.net`。平台优先级 macOS（Apple Silicon + Intel universal），Windows x64 兼容。
> 已对四项关键论断做对抗式核验：托盘能力 **refuted**、热键库可用性 **supported（附 pin 条件）**、Casdoor/R1 **mixed（需一处后端改动 + 版本前提）**、签名/公证 entitlement **mixed（核心子句成立，捆绑表述需修正）**。

---

## R1：OAuth 后端 / Casdoor 协调（解锁 D1 的最小路径）

核验结论：**mixed**。「public client + PKCE + loopback」成立，但「后端零改动」为假——需且仅需一处后端 audience 改动，且依赖 Casdoor 版本 ≥ ~v3.3。

**已确定（源码级）：**
- Casdoor 支持 public client：PKCE（`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-05（commit `ea56cfec`）才接入 redirect 校验，首个含此改动的 release 约 v3.3.0（2026-04-12）。早于此的 Casdoor 会拒未注册的 `127.0.0.1:<port>`。prod 自建 Casdoor 版本未锁定，须先核实 ≥ v3.3（建议直接升近版，最新 v3.89.0 / 2026-06-13）。

---

## D2：menubar / 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 v3（alpha.92，API 已稳定、有生产案例）原生覆盖跨平台 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）覆盖 macOS（changeCount 轮询）+ 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 #4132：macOS 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.0（2026-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.24，v0.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 多窗口每窗一等对象 + 独立生命周期）。

---

## D6：codesign / 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）路径**零权限弹窗**；只有 CGEventTap（v0.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 #4990（closed not planned）、#4413 / #1480；v3.wails.io systray/multiple-windows；本地 `wails/v2@v2.12.0/pkg/options/options.go`。
- 热键：golang-design/hotkey releases（v0.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 release；nspasteboard.org；MS Learn `AddClipboardFormatListener` / Clipboard Formats；9to5Mac / MacRumors（macOS detect API）；Wails #4132。
- 通知：Wails PR #4098（macOS 须签名+bundle id）、Issue #4449；go-toast v2 / wintoast pkg.go.dev；Apple LSUIElement 论坛 thread/749689。
- 公证：Apple Developer News id=09032019a、Eclectic Light「Notarization: the hardened runtime」、Apple 论坛 thread/735223、Wails #3290；Wails #4592 / commit 12b9f3a（macOS 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` 指向 exe；Linux 走 `.desktop` 的 `x-scheme-handler/cdrop`。
- 必须配单实例锁。点 `cdrop://` 时 OS 会重新拉起 app 的“第二个实例”，深链作为命令行参数传入。需要 `options.App.SingleInstanceLock` 把这个 deep link 转发给首实例，否则 token 落在一个马上要退出的临时进程里，拿不到。
- macOS 上单实例锁与深链协作有已知坑（见 wails issue #5089：v3 的 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 做授权请求）与 §5（MUST 用 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 7636：43-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 的 optionalData（JSON 反序列化后逐个作为参数）
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 WebView（WKWebView）与 Windows WebView2 下 `BrowserOpenURL` 是否都正确落到“系统默认浏览器”而非内置浏览器，需各平台各验一次。
- 浏览器标签页 `window.close()` 在 Chrome / Safari / Edge 上的实际可关闭性（多数授权跳转开的标签关不掉，文案兜底是否够友好）。

### 来源（OAuth）

- Wails Runtime（BrowserOpenURL / Events 签名）：https://pkg.go.dev/github.com/wailsapp/wails/v2/pkg/runtime
- Wails options（SingleInstanceLock / 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 单实例锁与深链不兼容 issue：https://github.com/wailsapp/wails/issues/5089
- BrowserOpenURL 错误不记录 issue：https://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 PKCE：https://datatracker.ietf.org/doc/html/rfc7636

---

## 打包·签名·公证操作手册（D6 补充）

本节只补**具体命令与 CI 配置**；entitlement／公证“会不会被拒”的判断已在前文完成。环境基准：Wails v2.12.0（Go），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/15）runner 上原生满足；不要尝试从 Linux 交叉编译 darwin（CGO + macOS SDK 缺失，社区反复确认不可行）。
  - `wails build` 本身**不签名、不公证 macOS 产物**（与 Windows 的 `-nsis` 不同，没有内建签名参数）。签名／公证完全是构建后的独立步骤。
  - Wails 没有内建 DMG 封装，需自己做（见 1.4）。

#### 1.2 codesign（Developer 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 key（CI 首选）
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 装订进 .app（staple 的是 .app，不是 zip）
xcrun stapler staple "build/bin/YourApp.app"
xcrun stapler validate "build/bin/YourApp.app"
```

#### 1.4 DMG 封装（已确定）

最终分发媒介里**只放已公证 + 已装订的 `.app`**，再对 DMG 本身签名，然后对 DMG 单独走一遍公证 + staple（推荐）。

```bash
# 方式 A：create-dmg（brew 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 签名”的代码；系统 framework（Carbon／Cocoa／CoreFoundation 等）由 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` **无签名参数**，不做 codesign／notarize；签名、公证、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 eSigner／DigiCert 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 必须先 zip／DMG、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 #3716：https://github.com/wailsapp/wails/issues/3716
- Wails Crossplatform build（GitHub Actions、darwin/universal 矩阵）：https://wails.io/docs/guides/crossplatform-build/
- notarytool man page：https://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 entitlement：https://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-certs：https://github.com/Apple-Actions/import-codesign-certs
- create-dmg：https://github.com/create-dmg/create-dmg
