Files
Commilitia-Drop/desktop/RESEARCH.md
T
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 Blame History

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 clientPKCECodeChallenge 非空)时 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_idtoken_jwt.go:544)。
  • 本仓库已核实根因:internal/jwtauth/middleware.go:150 当前是单值 expected.AnyAudience = jwt.Audience{a.cfg.OIDCAudience}docker/compose.snippet.yaml:28CDROP_OIDC_AUDIENCE 设为 web cdrop client_id。桌面新 client 的 token aud=<desktop client_id> 会被这条按 audience 100% 拒(401)。
  • internal/httpapi/auth.go:92-93148-149 已核实:/api/auth/exchangeOIDCClientSecret 非空时附 client_secretconfidential 代理)——桌面端不应复用

最小落地路径(解锁 D1):

  1. Casdoor 新建独立 application cdrop-desktoptoken format = JWTclient_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/authorizetoken = /api/login/oauth/access_token(非标准 /oauth/token)。authorize 与 token 两步的 redirect_uri 须逐字一致(先定端口、全程同一 URL 串)。
  3. 后端唯一改动(方案 A,推荐):把 CDROP_OIDC_AUDIENCE 从单值扩成逗号分隔多值(web + desktop 两个 client_id),middleware.go:150 展开进 AnyAudiencego-jose 的 AnyAudience 已是「命中其一即可」语义)。issuer + JWKS + 签名校验与 web 完全一致,无需动。
    • 备选 B:桌面带 RFC 8707 resource 参数令 aud 收敛——依赖 Casdoor 较新版本且 authorize/token 两步须带同一 resource,不确定性更高,不推荐。
    • 备选 CCDROP_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.AppTray 字段,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) boolruntime.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 图标(accessorymac.Options.ActivationPolicy 在 v2.12 仍被注释掉、不可用。已核实本仓库 desktop/build/darwin/Info.plistCFBundleIdentifierLSUIElement——需手动加 <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)。

决定:

  • macOSUNUserNotificationCentercgo + 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.32025-01-17,仍维护)。Notification.Actions 加 3 个 toast.Action{Type: Foreground, Content, Arguments}toast.SetActivationCallbackArguments 区分。:须先 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.02026-06-07,活跃;macOS 已 purego/objc 无 cgoWindows/Linux 纯 syscall)覆盖 macOSchangeCount 轮询)+ WindowsGetClipboardSequenceNumber 1s 轮询)。纠正 PLAN.md L167:该库 Windows 是轮询不是 AddClipboardFormatListener 事件;两端机制其实一致(diff 计数器轮询),轮询间隔写死 1s 不可配。
  • 外包一层 ClipboardSource 接口做 D4 业务逻辑(库一概不替你做):
    1. 敏感内容过滤macOS 读 -types 比对 org.nspasteboard.ConcealedType / TransientType / AutoGeneratedType 及私有 UTIcom.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.1Carbon 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-hotkeyREADME 自述「只适用于 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 windowDidResignKeyv2 路线最脏的一块)。
  • 这一条是上 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.1Carbon)路径零权限弹窗;只有 CGEventTapv0.5.0+)才强制 Accessibility。
  • 修正 2:macOS 通知有一道硬门槛被「仅需弹窗」框架略过——UNUserNotificationCenter 要求 app 有 CFBundleIdentifier 且已签名,dev/未签名不工作。这是对签名的运行期依赖(不是公证 entitlement 障碍),但意味着 D3 无法完全脱离 D6 独立验收。
  • 工具链:用 notarytoolgon/altool 已退役,Wails #3290 的公证失败实为工具链而非 entitlement)。非沙箱 Developer ID 分发不必为通知/网络/剪贴板声明专门 entitlement——PLAN.md L180 把这些列入 entitlement 有过度声明之嫌,应收敛。
  • 跨平台纪律:平台代码用 Go build tag,单 monorepo 单分支,Windows 只 pull 不 commit。

关键来源

  • Casdoor loopback / PKCE / audutil/validation.goIsValidOrigin)、object/application_util.goIsRedirectUriValid)、object/token_jwt.go:544object/token_oauth_util.goPR #3301、commit ea56cfecRFC 8252 §7.3、RFC 8707。
  • 本仓库根因:internal/jwtauth/middleware.go:150internal/httpapi/auth.go:92docker/compose.snippet.yaml:28desktop/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:<临时端口>/callbackCasdoor 对 loopback 任意端口放行,后端 audience 改多值校验。Casdoor 端点:authorize=/login/oauth/authorizetoken=/api/login/oauth/access_token

1. loopback vs 自定义 scheme cdrop:// 的取舍(Wails 语境)

两条路线都符合 RFC 8252 对原生应用的要求(§7.1 私有 scheme、§7.3 loopback),但在 Wails 下实现成本差异明显。

loopbackhttp://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 允许请求时指定任意端口”。

自定义 schemecdrop://callback):

  • 需要 OS 级登记:macOS 在 Info.plistCFBundleURLTypesCFBundleURLSchemes(值 cdrop);Windows 要在注册表 HKEY_CLASSES_ROOT\cdrop(或 per-user HKCU\Software\Classes\cdrop)写 URL Protocol 键 + shell\open\command 指向 exeLinux 走 .desktopx-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 形态(作为对比,本项目不采用):

// 仅作对比: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 用法与注意

签名:

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 并校验 stateGo 侧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 校验):

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 落 storeEventsOn 返回取消函数,组件卸载时调用):

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...)optionalDataJSON.stringify 传输,前端回调以 callback.apply(null, data) 接收——发单个对象即对应回调首个参数。wails dev 下事件走 WebSocket、生产走原生 IPC,协议一致,无需区分。

4. loopback server 收尾(回浏览器页 + 关监听)

拿到 code 后给浏览器返回一段静态 HTML,提示已登录并尝试自关闭标签页(window.close() 仅对脚本打开的窗口可靠,授权跳转打开的标签页常关不掉,故同时给出可读文案兜底)。随后 server 凭 defer srv.Close() 关闭监听释放临时端口。

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


打包·签名·公证操作手册(D6 补充)

本节只补具体命令与 CI 配置entitlement/公证“会不会被拒”的判断已在前文完成。环境基准:Wails v2.12.0Go),macOS universal 优先 + Windows x64,瘦客户端。

重要前提:Wails 官方 signing 指南至今仍推荐 gon,但该工具已停止维护、且依赖已废弃的 altool 语义。本手册一律改用 Apple 现行的 codesign + notarytool + stapler 链路,这是已确定的正确路径。

1. macOS

1.1 构建产物与已知坑(已确定)

wails build -platform darwin/universal -clean
  • 产物:build/bin/<AppName>.appuniversal .app bundlelipo 已合并 arm64 + amd64 两份 Go 二进制)。<AppName> 来自 wails.jsonoutputfilename/项目名。
  • 项目脚手架在 build/darwin/ 下生成 Info.plist(模板 Info.plistInfo.dev.plist),这是 .appContents/Info.plist 的来源。改 bundle ID、版本号、LSMinimumSystemVersion 等都改这里。
  • 已知坑:
    • universal 构建要求本机同时具备 arm64 与 amd64 的 CGO 工具链。在 Apple Silicon 的 macos-latestmacos-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 证书

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,必须先打成 zipditto,保留 bundle 结构)或 DMG 再提交。

# 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(推荐)。

# 方式 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.plistWails 约定路径,由 --entitlements 引用)。瘦客户端(Developer ID 渠道、 Mac App Store)的最小集合只需网络客户端:

<?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-sandboxApp 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.plistInfo.dev.plistentitlements.plist 非自动生成,需手动放进该路径。universal 由 Wails 内部 lipo 合并,无需手动 lipo

2. Windows

2.1 构建产物(已确定)

wails build -platform windows/amd64 -clean            # 仅 exe
wails build -platform windows/amd64 -nsis -clean      # 附 NSIS 安装器

NSIS 配置在 build/windows/installer/info.json + .nsi),数据取自 wails.jsonInfo 段,安装器输出到 build/bin/。瘦客户端用内建 NSIS 已足够,不必引入 WiX。

2.2 signtool 签名(已确定)

证书用标准代码签名证书即可(不需要 EV)。注:2023 年起 OV 证书私钥须存硬件 token/HSM 或云签名服务,CI 多走云签名(SSL.com eSignerDigiCert KeyLocker);下面是本地 .pfx 经典流程。

# /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-certsv7,把 base64 的 .p12 导入临时 keychain+ App Store Connect API key 跑 notarytool。secretsAPPLE_CERT_P12_BASE64APPLE_CERT_PASSWORDAC_API_KEY_P8AC_API_KEY_IDAC_API_ISSUER_IDAPPLE_SIGN_IDENTITY

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 骨架(已确定)

  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>.appbuild/darwin/entitlements.plistbuild/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