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)。

本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
This commit is contained in:
2026-06-15 21:38:28 +08:00
commit f21fa5b5e8
239 changed files with 29010 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true
max_line_length = 100
[*.{md,markdown}]
trim_trailing_whitespace = false
[*.{json,yaml,yml,html}]
indent_size = 2
[Makefile]
indent_style = tab
+30
View File
@@ -0,0 +1,30 @@
# dev 模式(本机开发,brief 实施原则 5)
# 二者必须同时设置;后端启动期校验,缺一即 panic
CDROP_AUTH_MODE=dev
CDROP_DEV_TOKEN=replace-with-32-byte-random-base64
# prod 模式所需(填入你的 OIDC provider,如自建 Casdoor / Keycloak
# CDROP_AUTH_MODE=prod
# CDROP_OIDC_AUTHORIZE_URL=https://your-idp.example/login/oauth/authorize
# CDROP_OIDC_TOKEN_URL=https://your-idp.example/api/login/oauth/access_token
# CDROP_OIDC_JWKS_URL=https://your-idp.example/.well-known/jwks
# CDROP_OIDC_ISSUER=https://your-idp.example/
# prod 强制非空:填你的 OAuth client_id(多值逗号分隔,web + 桌面端共用时填同一个)
# CDROP_OIDC_AUDIENCE=your-client-id
# CDROP_OIDC_CLIENT_ID=your-client-id
# CDROP_OIDC_REDIRECT_URI=https://your-domain.example/oauth/callback
# CDROP_OIDC_SCOPES=openid profile email
# CDROP_HS256_SECRET=
# CDROP_TURN_URL=stun:your-stun.example:3478
# 通用
CDROP_DB_PATH=./cdrop.db
CDROP_LISTEN=:8080
# Device row max age (sliding via auth-middleware UPSERT and SSE keepalive).
# Bound by brief §2 refresh_token sliding window (196h ≈ 8 days). Default 196.
CDROP_DEVICE_TTL_HOURS=196
# Cloudflare Realtime TURN(可选;不配则 /api/calls/credentials 返回 STUN-only fallback)。
# 申请:dash.cloudflare.com → Realtime → TURN → "Create TURN App",拿到 Key ID 与 API Token。
# CDROP_CF_TURN_KEY_ID=
# CDROP_CF_TURN_API_TOKEN=
+57
View File
@@ -0,0 +1,57 @@
# Backend build artifacts
/cdrop
/cdropd
/dist/
# Go test / coverage / profile output
coverage.out
coverage.html
*.test
*.prof
# SQLite runtime files
*.db
*.db-shm
*.db-wal
# Frontend
/web/node_modules/
/web/dist/
/web/.vite/
/web/*.tsbuildinfo
/web/vite.config.js
/web/vite.config.d.ts
/web/src/routeTree.gen.ts
# Embedded frontend bundle (Dockerfile populates this)
/internal/webui/dist/*
!/internal/webui/dist/.gitkeep
# Editor / OS
**/.DS_Store
.idea/
.vscode/
*.swp
*.swo
*~
*.orig
*.bak
# Env files — track only the .example templates
/.env*
!/.env.example
/web/.env*
!/web/.env.example
/data/
# Deploy mirror (M13 pull-before-modify baselinenever入库)
/docker/.remote/
# Desktop client (Wails) 产物
/desktop/build/bin/
/desktop/frontend/node_modules/
/desktop/frontend/dist/
/desktop/frontend/wailsjs/
# Scratch / ad-hoc artifacts
/.scratch/
+3
View File
@@ -0,0 +1,3 @@
[submodule "web/src/lib/cjk-autospace"]
path = web/src/lib/cjk-autospace
url = https://git.commilitia.net/admin/cjk-autospace.git
+83
View File
@@ -0,0 +1,83 @@
set dotenv-load := true
default:
@just --list
dev-back:
go run ./cmd/cdropd
dev-front:
cd web && npm run dev
build-back:
mkdir -p dist
go build -o dist/cdropd ./cmd/cdropd
build-front:
cd web && npm run build
build: build-back build-front
test:
go test ./...
typecheck-front:
cd web && npm run typecheck
fmt:
gofmt -w .
vet:
go vet ./...
# ---- desktop client (Wails · macOS 主开发,Windows 在 Parallels 验证) ----
# wails CLI 在 go install 后落在 $(go env GOPATH)/bin/wails
# 用绝对路径避免依赖 shell PATH 配置。
wails := `go env GOPATH` + "/bin/wails"
# 起 wails dev 热重载,前端走 Vite dev server。
desktop-dev:
cd desktop && {{wails}} dev
# 构建 macOS universal .app(含 darwin/arm64 + darwin/amd64)。
# 不加 -clean,让 mac/win 产物在 build/bin/ 共存;清理走 desktop-clean。
#
# ⚠️ 先手动构建前端再 `wails build -s`(跳过 wails 自带前端步骤):wails build 的
# frontend:build 在本仓库结构下不可靠(不实际重建 web/ → 嵌入旧 JS)。显式构建
# web → desktop/frontend/dist,再用 -s 让 go:embed 嵌入这份新 dist。
desktop-build-mac:
cd web && npm run build -- --outDir ../desktop/frontend/dist --emptyOutDir
cd desktop && {{wails}} build -s -platform darwin/universal
# 交叉编译 Windows amd64 .exe。macOS 原生即可——Windows 侧无 cgodarwin 的
# NSStatusBar/NSPasteboard CGO 走 build tag 排除,WebView2 是纯 Go),不需 mingw。
# 与 mac 同:先手动构建前端再 -swails 自带 frontend:build 在本仓库不可靠)。
# 产物 build/bin/desktop.exe;真测试经 RDP 到 Windows 机器跑。
desktop-build-win:
cd web && npm run build -- --outDir ../desktop/frontend/dist --emptyOutDir
cd desktop && {{wails}} build -s -platform windows/amd64
# 清空 build/bin/ 产物。
desktop-clean:
rm -rf desktop/build/bin
# 环境自检。
desktop-doctor:
{{wails}} doctor
# ---- deploy plumbing ----
# 一次性预热:把 Go modules + node_modules 烤进 cdrop-base:latest。
# 仅 go.mod/go.sum 或 web/package*.json 改动时需要重跑。
# 用宿主原生 archmacOS arm64 → linux/arm64),让 BUILDPLATFORM 跨编译走原生。
docker-base:
docker buildx build -f docker/Dockerfile.base -t cdrop-base:latest --load .
# 应用镜像:从 cdrop-base 起步只编译源码,最终落 linux/amd64 distroless。
# dev mode 部署时 export VITE_CDROP_DEV_TOKEN=<同后端 token> 把它烤进前端 bundle。
docker-image:
docker buildx build --platform linux/amd64 \
--build-arg VITE_CDROP_DEV_TOKEN="${VITE_CDROP_DEV_TOKEN:-}" \
--build-arg VITE_CDROP_STUN_URLS="${VITE_CDROP_STUN_URLS:-}" \
-f docker/Dockerfile -t cdrop:latest --load .
+161
View File
@@ -0,0 +1,161 @@
# cdrop — Commilitia Drop
跨 OS 剪贴板与文件传输服务。架构文档(`PROJECT_BRIEF.md` / `FRONTEND_DESIGN.md`)与 Changelog 位于 **`docs` 分支**
```sh
git worktree add ../cdrop-docs docs # 并列检出,main / docs 互不扰动
open ../cdrop-docs/PROJECT_BRIEF.md
open ../cdrop-docs/CHANGELOG.html
```
## 克隆
前端 `web/src/lib/cjk-autospace/` 为 submodule(聚珍 / Juzhen 2.0CJK 综合排版——中西间隙 / 长英文断词 / 标点挤压 / 禁则 / justify,跨 Claude / Nvim / cdrop 共享)。首次 clone 须带 `--recurse-submodules`,否则 `vite build` 会因找不到 import 而失败:
```sh
git clone --recurse-submodules <URL> cdrop
# 已 clone 但未拉 submodule
git submodule update --init --recursive
```
## 当前阶段
MVPM0M13)已上线 prodOIDC PKCE 接入自建 CasdoorCloudflare Realtime TURN over TLS)。其后进入阶段二,已落地:
- **剪贴板同步**:单条覆写式云剪贴板(短 TTL 限暴露面)+ 文本消息一次性通道
- **桌面客户端**Wails v2macOS universal `.app` + Windows `.exe`)—— 瘦客户端复用 web,业务全走后端 APIrefresh_token 存系统密钥库(信封加密)
- **iOS Shortcut**HS256 scoped token 手动签发路径(网页签发 UI 暂停)
- **安全加固**:中继会话防耗尽、HS256 强制 jti、prod 强制 audience、TURN 短 TTL、OAuth 端点限流(详见 CHANGELOG
MVP 里程碑(历史记录):
| 里程碑 | 内容 | 状态 |
|---|---|---|
| M0M5 | 后端:chi / sqlite WAL / dev+prod auth 中间件 / SSE Hub / signaling + 状态机 / Relay ring buffer | 完成 |
| M6M10 | 前端:Mantine + TanStack Router + Zustand(其后叠加自定义设计 token 与 Theme B 重塑)/ 自实现 SSE / WebRTC P2P / Relay 回退 / SSE 重连 + chunk 重试 | 完成 |
| M11 | 本机双 tab 端到端验收(dev mock auth+ 多轮 bug 修复(race / 状态机 / DOM nesting / 状态着色) | 完成 |
| M12 | OIDC PKCE:后端 /api/auth/{config,exchange,refresh} 代理 token endpoint,前端 PKCE redirect + callbackCasdoor confidential client + client_secret | 完成 |
| M13 | 部署:cdrop-base 缓存层 + 应用镜像(BUILDPLATFORM 跨编译,6 MB distroless),Cloudflare Realtime TURN App,反代 reverse_proxy + flush_interval -1SSE 友好),h3 已启 | 完成 |
## 本机开发:两 tab 验收流程(M11)
### 1. 准备 dev token
把同一个随机 base64 串同时写到两份 env 文件:
```bash
TOKEN=$(openssl rand -hex 32)
cp .env.example .env
sed -i '' "s|replace-with-32-byte-random-base64|$TOKEN|" .env
cp web/.env.example web/.env.local
sed -i '' "s|replace-with-32-byte-random-base64|$TOKEN|" web/.env.local
```
`.env` 会被根目录的 Justfile 自动加载(`set dotenv-load := true`)。
### 2. 起后端(dev 模式)
```bash
just dev-back
# 监听 :8080json 日志到 stdout
```
后端要求 `CDROP_AUTH_MODE=dev``CDROP_DEV_TOKEN` 同时设置;缺一会 panic 拒启动(防止误带到生产)。`.env` 文件由后端自动加载(koanf)。
### 3. 起前端(vite dev
另开一个终端:
```bash
just dev-front
# 默认 http://localhost:5173/api 自动代理到 :8080
```
### 4. 打开两个浏览器 tab
| tab | URL | 期望 |
|---|---|---|
| tab-1 | `http://localhost:5173/login?dev_user=alice` | 输入 `Continue` → Setup 输入设备名 `tab-1` → Home |
| tab-2 | `http://localhost:5173/login?dev_user=alice` | 同上但设备名填 `tab-2` |
两 tab 的 Home 页应在 1–2 秒内互相看到对方在线,顶部 `● online` 亮绿。
### 5. 验收 brief §7 五条
| # | 步骤 | 通过标准 |
|---|---|---|
| 1 | 双 tab Casdoor 登录 | M11 阶段使用 dev mock 等价:两 tab 都进 Home |
| 2 | Home 页看到对方在线 | DeviceStrip 互相显示对方卡片,绿点 |
| 3 | 拖拽文件 → P2P 直传 | tab-1 选 tab-2 + 拖文件 → Sendtab-2 浏览器自动下载,sha256 与原文件一致 |
| 4 | 模拟 NAT 阻塞 → fallback Relay | DevTools console 注入 `RTCPeerConnection.prototype._origCreateOffer = RTCPeerConnection.prototype.createOffer; RTCPeerConnection.prototype.createOffer = function() { return new Promise(()=>{}) }`(永远 pending),30s 后看 transfer 状态切到 `RELAY_ACTIVE`,依然下载成功 |
| 5 | 传输中关闭一方 → 重连续传 | M11 范围内可手动 kill backend 几秒再起;或断网卡 30s |
`> 100 MB` 文件不会自动接受(brief §2 阈值);当前 MVP 还没接 UI 二次确认,可暂停在大文件场景验证。
### 6. 单元测试
```bash
just test # go 测试
just typecheck # 后端 go vet 由 just vet 触发
cd web && npm run typecheck && npm run build
```
## 部署
后端编译进单个 distroless 镜像(前端经 Dockerfile 构建后 `go:embed` 进二进制)。镜像约 6 MB。
### 构建镜像
```bash
# 依赖预热(仅 go.mod/go.sum 或 web/package*.json 变动时需重跑):把 Go modules
# 与 node_modules 烤进 cdrop-base:latest,加速后续源码层构建。
just docker-base
# 应用镜像:从 cdrop-base 起步编前端 + 跨编译 Go,落 linux/amd64 distroless。
just docker-image # → cdrop:latest
```
宿主原生 arch 即可(`BUILDPLATFORM` 让 Go 跨编译走原生,无需 qemu 执行 amd64)。
### 上线
镜像送到目标主机(二选一):
```bash
# A. 本地构建 → 传镜像(无需在服务器装 Node/Go)
docker save cdrop:latest | gzip | ssh <user>@<host> 'docker load'
# B. 或把源码同步到服务器,在服务器上 docker build
```
服务条目模板见 [`docker/compose.snippet.yaml`](docker/compose.snippet.yaml):填进你的 `docker-compose.yaml`,挂一个目录到 `/data`(存 SQLite 库),不开放端口、由反代访问 `<service>:8080`
```bash
docker compose up -d <service>
curl -s https://<your-domain>/healthz # {"status":"ok","auth_mode":"prod","db_ok":true}
```
### 反代(SSE 要点)
cdrop 用 SSE 推事件,反代**必须关闭响应缓冲**否则事件不实时。Caddy 示例:
```caddyfile
your-domain.example {
reverse_proxy <service>:8080 {
flush_interval -1 # 关闭缓冲,SSE 即时下发
}
}
```
### prod 配置
env 见 [`.env.example`](.env.example) / `compose.snippet.yaml`。要点:
1. `CDROP_AUTH_MODE=prod`,删掉 `CDROP_DEV_TOKEN`
2.`CDROP_OIDC_*`(你的 OIDC provider,如自建 Casdoor / Keycloak);在 IdP 建 confidential + PKCE client,回调白名单加 `https://<your-domain>/oauth/callback`
3. **`CDROP_OIDC_AUDIENCE` 必须非空**(你的 client_id,多值逗号分隔)—— 留空会跳过 audience 校验,同一 JWKS 下其他应用的 token 也能验过;后端 prod 启动期强制此项,缺则拒启动
4.`CDROP_HS256_SECRET`iOS Shortcut scoped token 用,不用可留空)
5. 可选 `CDROP_CF_TURN_KEY_ID` + `CDROP_CF_TURN_API_TOKEN` 启用 Cloudflare Realtime TURN over TLS(不配则前端 STUN-only 兜底)
+107
View File
@@ -0,0 +1,107 @@
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"commilitia.net/cdrop/internal/calls"
"commilitia.net/cdrop/internal/clipboard"
"commilitia.net/cdrop/internal/config"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/httpapi"
"commilitia.net/cdrop/internal/hub"
"commilitia.net/cdrop/internal/jwtauth"
"commilitia.net/cdrop/internal/relay"
"commilitia.net/cdrop/internal/transfer"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
cfg, err := config.Load()
if err != nil {
slog.Error("config load failed", "err", err)
os.Exit(1)
}
slog.Info("cdropd booting",
"auth_mode", cfg.AuthMode,
"listen", cfg.Listen,
"db_path", cfg.DBPath,
)
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
dbConn, err := db.Open(cfg.DBPath)
if err != nil {
slog.Error("db open failed", "err", err)
os.Exit(1)
}
defer dbConn.Close()
if err := db.Bootstrap(ctx, dbConn); err != nil {
slog.Error("db bootstrap failed", "err", err)
os.Exit(1)
}
queries := db.New(dbConn)
auth := jwtauth.New(cfg, queries)
h := hub.New(queries)
defer h.Close()
relayMgr := relay.NewManager(relay.DefaultMaxSessions, relay.DefaultSessionCap)
transfers := transfer.NewService(queries, h, relayMgr)
var cfTurn *calls.Provider
if cfg.CFTurnKeyID != "" && cfg.CFTurnAPIToken != "" {
cfTurn = calls.NewProvider(cfg.CFTurnKeyID, cfg.CFTurnAPIToken, calls.DefaultTTL)
slog.Info("cloudflare realtime TURN enabled")
} else {
slog.Info("cloudflare TURN not configured; falling back to STUN-only")
}
clip := clipboard.New(
queries,
h,
cfg.ClipboardMaxBytes,
time.Duration(cfg.ClipboardDebounceSec)*time.Second,
time.Duration(cfg.ClipboardTTLSec)*time.Second,
)
go transfer.RunSweeper(ctx, transfers)
go relayMgr.RunReaper(ctx)
go jwtauth.RunDeviceSweeper(ctx, queries, time.Duration(cfg.DeviceTTLHours)*time.Hour)
if cfg.ClipboardTTLSec > 0 {
go clipboard.RunSweeper(ctx, clip)
}
srv := &http.Server{
Addr: cfg.Listen,
Handler: httpapi.New(cfg, dbConn, queries, auth, h, transfers, relayMgr, cfTurn, clip).Handler(),
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
slog.Info("http server listening", "addr", cfg.Listen)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("http server error", "err", err)
cancel()
}
}()
<-ctx.Done()
slog.Info("shutdown initiated")
shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutCancel()
if err := srv.Shutdown(shutCtx); err != nil {
slog.Error("http shutdown error", "err", err)
}
slog.Info("cdropd stopped")
}
+3
View File
@@ -0,0 +1,3 @@
build/bin
node_modules
frontend/dist
+403
View File
@@ -0,0 +1,403 @@
# cdrop 桌面客户端实施计划
> Wails v2 · macOS 优先 + Windows 已落地
> 状态:**macOS 与 Windows 双端均已实现并 RDP/真机实测通过**——A2 前端复用 + 托盘/菜单栏 + 剪贴板双向同步 + 桌面设置 + session 持久化 + 开机自启(含自启静默驻留)+ 设备类型登记。macOS 见 §10Windows 落地(含「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. 范围、非范围与本轮关键决策
**范围**:常驻桌面客户端,补浏览器做不到的——后台常驻、剪贴板自动监听上传、文件落盘、系统通知、全局快捷键。平台 **macOSApple 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 + 多窗口 + ActivationPolicyD2/D5 前定夺 |
| 淘汰 | Electron / Tauri / 原生双栈 / PWA | Electron 200+ MBTauri 多一门 Rust;原生双 codebasePWA 无后台常驻 |
| 资源预算 | macOS idle 80120 MBWindows idle 100150 MB;包体 1020 MB | WebView 复用系统组件 |
### 1.2 瘦客户端架构
- **全部业务走 `drop.commilitia.net`**:桌面端沿用 Web 的 API 客户端,本地只存 token + 少量设置。
- **Go 外壳只做四类原生事**:①把 OS 事件源(剪贴板变更、热键、OAuth 回调)翻译成事件推给 WebView;②把 WebView 的请求(开系统浏览器、写文件、发通知)翻译成 OS 调用;③后台常驻;④打包。
### 1.3 与后端的契约(桌面端用到的端点)
沿用 Web 现有 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 回调 + 注入(§3D1 |
| **token 存储** | zustand store | Go 侧 Keychain / DPAPI,启动注入 |
| **通知** | Web Notification(前台限定) | 系统通知中心(后台也行,D3) |
| **文件落盘** | 浏览器下载 | Go 写可配置目录(默认 `~/Downloads`)+ 揭示 Finder(✅ 已落地) |
> 铁律:**WebView 内绝不 `window.location.href` 跳外部 authorize**(会把 WebView 自身导航走、应用白屏,且违反 RFC 8252 §8.12 嵌入式 user-agent 禁令)。登录走系统浏览器 + 原生回调桥。
---
## 3. D1OAuth 登录闭环(已据 sweep 定型)
sweep 把 D1 从“最高风险未知”变成“路径已明、含一处后端改动”。完整客户端机制 + 代码骨架见 `desktop/RESEARCH.md` 的“OAuth 原生回调通道——Wails 客户端机制”。要点:
### 3.1 回调通道:loopback(已定,弃 scheme
采用 **RFC 8252 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 clientPKCE 时 `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)。
- [ ] Goloopback server + `runtime.BrowserOpenURL` + state 校验 + token 交换 + `EventsEmit`token 持久化(Keychain / DPAPI)。
- **验收**:冷启动 → 点登录 → 系统浏览器授权 → 自动回应用已登录;杀进程重启仍登录;refresh 跑通。dev 模式(`VITE_CDROP_DEV_TOKEN`)可先绕 Casdoor 打通壳。
---
## 4. D2D6 详解(含 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` 手加 `<key>LSUIElement</key><true/>`(启动瞬间 dock 闪现约 10ms 是系统通病、无法消除)。
- **决策闸**:D2 是 v2/v3 取舍的触发点(见 §9)。若决定上 v3,D2 用 v3 原生 systray 重做。
### D3 · 系统通知 + 文件落盘 + SSE 接入
- **SSE 由 Go 持有**(非 WebView):后台常驻、窗口隐藏仍收推送、免 WebView 休眠断流。事件分流:要弹通知的走 Go 通知 API,要更新 UI 的经 `EventsEmit` 推 WebView。
- **macOS 通知**`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` 及私有 UTIWindows 读后 `EnumClipboardFormats``ExcludeClipboardContentFromMonitorProcessing` / `CanUploadToCloudClipboard`,命中跳过上传。
2. **回环防护**(R4):自写剪贴板会自增计数触发自己的 Watch 风暴;写入前记 hash + 自写序号,Watch 收变更先比对跳过。
3. **去重 + debounce**:复用已在 go.mod 的 `github.com/bep/debounce` + 内容 hash。
- **写回本机剪贴板**优先用库的 `Write`(规避 Wails #4132macOS LANG 为空时编码 bug)。
- **go 版本**:该库 go.mod 要求 go 1.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.24v0.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>.app``lipo` 合并 arm64 + amd64)。**不公证、不 DMG、无 Developer ID**。为满足 D3 通知与稳定身份,做 **ad-hoc 签名** `codesign -s - --force --deep <App>.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`
- **版本检查**:启动 / 定时拉版本端点,过期提示去下载,**不自更新**。
- **将来获证书**:完整 codesignDeveloper ID+ notarytool + DMG + CI 流程见 `desktop/RESEARCH.md` D6 手册,届时直接套用。
- **CI**D6 后 GitHub Actions 双 runnermacos-latest / windows-latest)跑构建 + 防回归(无证书阶段仅构建 + ad-hoc,不跑公证)。
---
## 5. 跨平台代码组织(防分叉硬约束)
```
desktop/
main.go # Wails 入口(窗口 / bind / 托盘装配)
app.go # App struct + 暴露给 WebView 的方法(bindStartLogin 等)
platform/
oauth.go # loopback server + PKCE(平台无关)
clipboard.go # ClipboardSource 接口 + 公共逻辑(过滤 / 去重 / 回环防护)
clipboard_darwin.go //go:build darwin
clipboard_windows.go //go:build windows
tray_darwin.go / tray_windows.go
hotkey_darwin.go / hotkey_windows.go
notify_darwin.go / notify_windows.go
download_darwin.go / download_windows.go
secrets_darwin.go # Keychain
secrets_windows.go # DPAPI / Credential Manager
frontend/ # 薄入口 + Platform desktop 实现,UI import 自 web
```
**铁律**:①平台代码全用 build tag,同 monorepo 同分支,**绝不开 windows-branch**;②WindowsParallels Win11 ARM VM)只 `git pull`、**从不 commit**Windows-only 操作封进 `just` target;③ARM Win11 跑 x64 emulator 性能 -2040%**不能跑资源 / 性能基准**,D6 借真 x64 PC 锁数字;④D6 后 CI 防退化。
---
## 6. 安全审查
| 面 | 风险 | 缓解 |
|---|---|---|
| **token 存储** | 明文落盘即被盗即接管 | macOS Keychain / Windows DPAPI;不写明文文件 / localStorage |
| **OAuth 回调** | scheme 劫持 / CSRF | 用 loopback(无 scheme 抢注面);强制 PKCE S256 + state 校验;token 交换走 HTTPS 直连 Casdoor |
| **剪贴板隐私** | 自动上传密码 / 验证码 | 自写 ClipboardSource 做敏感类型过滤(§4 D4);默认策略待定;提供暂停 + 排除 |
| **更新** | 无自更新 = 不引入更新通道劫持面 | 仅版本检查提示 |
| **无签名分发** | 用户绕过 Gatekeeper 习惯被培养、易被仿冒 | 自用 / 小范围可接受;公开分发前应申请 Developer 证书签名公证(见 RESEARCH.md |
| **WebView ↔ Go 桥** | 暴露的 Go 方法被 XSS 滥用 | bind 方法最小化 + 参数校验;只加载本地 embed 资源 + 固定 API origin |
---
## 7. 验证与测试策略
- **每里程碑**`just desktop-build-mac` 冷 / 增量、`desktop-build-win` cross-compile 双绿,再真机手测(各 D 的“验收”)。
- **真机实测清单(sweep 标注,资料无法定论)**:① macOS 26 剪贴板隐私授权对轮询的行为(D4);② ad-hoc 签名能否让 `UNUserNotificationCenter` 本机通知工作(D3);③ hotkey v0.4.1 在 Wails 主线程下稳定收事件(D5);④ Casdoor loopback 任意端口端到端不报 `redirect_uri_mismatch`D1)。
- **资源基准**:idle 内存在真机测(mac 本机、Windows 借真 x64 PCARM VM 不算数),D6 锁定。
---
## 8. 风险登记表
| # | 风险 | 影响 | 缓解 / 状态 |
|---|---|---|---|
| R1 | OAuth 后端协调 | D1 | **已降级**:sweep 给出最小路径(多值 audience 一处改动 + Casdoor ≥ v3.3),不再是未知阻塞 |
| R2 | 前端复用污染 web 构建 | 维护 / 分叉 | §2.1 A2 隔离 + Platform 接口 + CI 校验 |
| R3 | **Wails v2 缺托盘 + 单窗口** | D2 / D5 | 决策 C:推迟 v2/v3 至 D2/D5 前;v2 走第三方 systray + 自写 NSWindowDelegate,或升 v3 |
| R4 | 剪贴板回环(自写触发自传) | 上传风暴 | ClipboardSource 层来源标记 + hash 去重 + debounce |
| R5 | **macOS 26 剪贴板隐私授权** | D4 自动同步可能被弹窗打断 | 真机实测 changeCount 轮询是否触发;必要时改用 detect API |
| R6 | **无证书 → D3 通知 / Gatekeeper** | D3 通知可能不工作、分发受阻 | ad-hoc 签名 + 真机实测;不行则通知降级 / 推迟;公开分发前补证书 |
| R7 | ARM VM 测不了性能 / 真签名 | 数字不可信 | D6 借真 x64 PC |
---
## 9. 执行顺序与 v2/v3 决策闸
**顺序(决策 C**
1. **R1 后端协调**(非代码,可并行起草)+ Casdoor 建桌面 client。
2. **D1**(前端复用 + loopback OAuth)——框架无关,最高价值。
3. **D3**(SSE 常驻 + 通知 + 落盘)+ **D4**(剪贴板核心价值)——框架无关。
4. **⟵ v2/v3 决策闸**:动 D2 / D5 前,正式评估三选一:
- **A) 留 v2**:稳定、文档全;D2 托盘 + D5 多窗靠第三方 / 自写 ObjC,技术债重。
- **B) 升 v3-alpha**:原生 systray + 多窗口 + ActivationPolicy;但 alpha 有风险、文档薄、需重做 D0 脚手架与 D1–D4 的少量框架接缝。
- **C) 混合**v2 上把 D2/D5 做到“可用即可”(不追求完美失焦自隐等),待 v3 稳定再迁移。
5. **D2**(托盘 + 生命周期)→ **D5**(热键 + Quick Send)。
6. **D6**(打包:当前无证书仅 `.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 的 URLweb 留空(相对、不变),desktop 构建设 `https://drop.commilitia.net`WebView 内相对 `/api` 到不了后端)。
- **登录桥**login 路由检测 desktopWails 注入 `window.runtime`)→ 调 Go `StartLogin` → Go 直连 Casdoor 换 token + 解 access_token JWT 取 sub/name → `EventsEmit {access_token, refresh_token, user}` → JS `setAuth`。即 **token 进 JS store**(A2 必需、本机可接受,放弃「token 只在 Go」)。
- **refresh**desktop 的 `refreshTokens` 走 Go `Flow.Refresh`(绕开 `/api/auth/refresh` 后端代理)→ emit 新 token。
- **设备名**desktop 取主机名(`os.Hostname`)填 `selfDeviceName`
- **构建接线**desktop/frontend 引用 webnpm workspace / `file:` 或 vite 指 `web/src`),保留 wailsjs 绑定;以能 `wails build` 为准、方式待落地时定。
### 10.2 剪贴板同步(D4
- **上行**darwin native SourceNSPasteboard changeCount 轮询,puregoclipprobe 已证可跑)→ Monitor(去重 / 回环防护;`Sensitive` 过滤 best-effort、非主防线)→ `api.PutClipboard`
- **下行**Go 持 SSE `/api/hub/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 而非 puregoclang 编译期校验 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 模式下行有效。SensitiveConcealedType 等)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 加 mutexoauth.go 加 IDToken 字段。desktopapp.go(登录桥 / 生命周期 / 剪贴板 / 设置绑定)、main.goproxy + OnBeforeClose)、wails.jsonfrontend 指向 web 构建)。webnet/desktop.ts + DesktopSettings.tsx 新增;login.tsx / auth.ts / clipboard.ts / __root.tsx / settings.tsx / main.tsx 加 guarded 桌面分支。
**待真机 + 鉴权验证(本环境无 GUI、无法登录,以下不可本地达成):**
- [ ] 登录 e2eloopback PKCE → token 入 store → 进首页(含设备名自动取主机名)
- [ ] refreshAPI 401 → Go public-client 刷新成功
- [ ] 菜单栏 item 出现 + 三项菜单可点
- [ ] dock 动态隐藏:关窗 → 仅菜单栏;菜单「显示主窗口」→ 窗口与 dock 复现
- [ ] 剪贴板跨设备:上行(复制即传)/ 下行(远端更新写回本机)/ 回环不抖
- [ ] 开机自启:勾选后写入 LaunchAgent、重登录系统自动起
**已知后续:** 桌面设置 UI 已接入 `t()`zh-CN / zh-TW / en-US 三语);菜单栏标签仍 Go 侧硬编码简体(需启动期把 locale 传入 Go,待后续);`Greet`/`LoggedIn` 为脚手架残留绑定可清理;Windows/Linux 的原生 Source / 托盘 / 自启目前为 stub。
### 10.7 用户首轮真机反馈修复(2026-06-13,本地全绿)
真机实测「功能基本正常」,三处调整已修:
1. **登录 session 本地持久化(Go 文件 + 启动注入;已真机跑通)**
**两个独立根因,第二个掩盖了第一个、害得多轮误判:**
- **根因 A(真正的持久化障碍)**:Wails 从 `wails://` 自定义 scheme 提供页面,**WKWebView 对 custom-scheme origin 不持久化 localStorage / sessionStorage**,故任何「前端存储」跨重启即丢。→ 持久化必须交给 Go。
- **根因 B(构建管线,长期误导)**:`wails 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 `</head>` 前注入 `<script>window.__CDROP_BOOT__={session,device_name}</script>`(启动读文件、`json.Marshal` 默认 HTML 转义防 `</script>` 逃逸、只改文档请求、assets 与 /api SSE 直通、加 `no-store`+删条件头防 304/缓存);③ 内联脚本在 deferred module bundle 前同步执行,**`store/helpers.ts` 初始化即同步读 `window.__CDROP_BOOT__` 水合 session + 设备名**——无 IPC、无异步 boot、不依赖 window.go。设备名同样不能靠 WebView 存储:`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-13web 已部署 prod
吉祥物品牌资产(源见记忆 `cdrop-brand-assets`)接入。web 与 desktop 共享同一前端,故多数改动两端通吃。
**Web(已部署 drop.commilitia.net):**
- **站点图标**`web/public/` favicon-16/32/48/96(源 avatar-head+ apple-touch-180 + PWA icon-192/512(源 app-icon 金底)+ `site.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-14RDP 实测通过)
macOS 优先做完后接 Windows。业务逻辑全跨平台纯 Go,平台差异用 build tag 隔离(`_windows.go` / `_darwin.go` / `_other.go`)。
### 11.1 交叉编译:macOS 原生即可,无需 mingw
Windows 侧无 cgo——darwin 的 NSStatusBar/NSPasteboard CGO 走 build tag 排除,托盘/剪贴板/自启均纯 syscallWebView2 是纯 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 schemeWKWebView 流式正常,零改动)。
### 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 / 任务栏 / 终端)不带标志 → 正常显示窗口。
- macOSLaunchAgent `open <bundle> --args --hidden`(裸 dev 二进制 `exe --hidden`)。
- Windows:注册表值 `"exe" --hidden`
- **自启项自愈**`startup` 时若自启已开,重写一次条目——旧版本(无 `--hidden`)或迁移过位置的 bundle 自动刷新到当前路径+标志,无需手动重勾。
**仍待办**:Windows 剪贴板敏感过滤;菜单栏/托盘标签 i18n(仍硬编码简体);Keychain(待 Developer-ID 签名);Windows 代码签名 / SmartScreen。
+19
View File
@@ -0,0 +1,19 @@
# README
## About
This is the official Wails Vanilla-TS template.
You can configure the project by editing `wails.json`. More information about the project settings can be found
here: https://wails.io/docs/reference/project-config
## Live Development
To run in live development mode, run `wails dev` in the project directory. This will run a Vite development
server that will provide very fast hot reload of your frontend changes. If you want to develop in a browser
and have access to your Go methods, there is also a dev server that runs on http://localhost:34115. Connect
to this in your browser, and you can call your Go code from devtools.
## Building
To build a redistributable, production mode package, use `wails build`.
+694
View File
@@ -0,0 +1,694 @@
# 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
+372
View File
@@ -0,0 +1,372 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"github.com/wailsapp/wails/v2/pkg/menu"
"github.com/wailsapp/wails/v2/pkg/menu/keys"
"github.com/wailsapp/wails/v2/pkg/runtime"
"cdrop-desktop/platform"
)
// loginResultFromToken decodes the identity out of the exchanged tokens and
// assembles the payload the WebView store consumes. Shared by login + refresh.
func loginResultFromToken(tok *platform.TokenResult) (*platform.LoginResult, error) {
user, err := platform.UserFromToken(tok.IDToken, tok.AccessToken)
if err != nil {
return nil, err
}
return &platform.LoginResult{
AccessToken: tok.AccessToken,
RefreshToken: tok.RefreshToken,
ExpiresIn: tok.ExpiresIn,
User: user,
}, nil
}
// App is the Wails application context bound to the WebView.
type App struct {
ctx context.Context
apiBase string
token *platform.TokenResult // in-memory copy; refresh_token persists in the OS secret store (see session.go)
quitting bool // set by the menu bar "Quit" so beforeClose allows the real exit
startHidden bool // set by main when launched via autostart: come up to the menu bar, no window
clipSource platform.Source
clipMonitor *platform.Monitor
}
// NewApp creates a new App application struct.
func NewApp() *App {
return &App{apiBase: envOr("CDROP_API_BASE", "https://drop.commilitia.net")}
}
// startup is called when the app starts. The context is saved so we can call
// the runtime methods, and the menu bar item is installed (macOS; no-op
// elsewhere). The app launches as a regular Dock app with the window visible.
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
// Load the persisted refresh_token into the Go process so refresh works after
// a restart. The refresh_token stays here — it's never handed to the WebView.
if s, err := platform.LoadSession(); err == nil && s != nil {
a.token = &platform.TokenResult{
AccessToken: s.AccessToken,
RefreshToken: s.RefreshToken,
ExpiresIn: s.ExpiresIn,
}
}
// Keep the autostart entry in sync with this binary: when it's enabled,
// re-apply so an entry written by an older build (no --hidden flag) or one
// pointing at a since-moved bundle is refreshed to the current path + flags.
// Idempotent and cheap — a single plist / registry write.
if platform.IsLaunchAtLoginEnabled() {
_ = platform.SetLaunchAtLogin(true)
}
a.startClipboardSync(ctx)
platform.InstallStatusBar(
platform.StatusBarMenu{
Title: "cdrop",
Show: "显示主窗口",
Settings: "设置…",
Quit: "退出 cdrop",
},
// The native menu-action callbacks fire on the AppKit main thread; calling
// Wails runtime methods synchronously there can re-enter the main run loop
// and crash. Hand off to a goroutine — Wails then dispatches to the main
// thread safely.
platform.StatusBarHandler{
OnShowWindow: func() { go a.revealWindow() },
OnOpenSettings: func() { go a.openSettings() },
OnQuit: func() { go a.requestQuit() },
},
)
// Autostart launch: the window already came up hidden (options.StartHidden).
// On macOS also drop the Dock icon to accessory so only the menu bar item
// remains — the same resting state as close-to-menu-bar. (Windows: this is a
// no-op; the tray is installed above and the hidden window keeps the taskbar
// clear. The menu bar item / tray is the way back in.)
if a.startHidden {
platform.SetDockVisible(false)
}
}
// buildMenu returns the macOS application menu. Without it the standard
// shortcuts (⌘C/⌘V/⌘X/⌘A/⌘Z and ⌘W/⌘M) don't work.
//
// Quit is a custom ⌘Q item rather than the role AppMenu's, because Wails routes
// both the window close button and ⌘Q through the same OnBeforeClose("Q")
// channel — indistinguishable there. The custom item sets the quitting flag
// first, so OnBeforeClose lets ⌘Q quit while the close button hides. ⌘W likewise
// hides (close-to-menu-bar), matching the lifecycle.
func (a *App) buildMenu() *menu.Menu {
m := menu.NewMenu()
appSub := menu.NewMenu()
appSub.Append(menu.Text("退出 cdrop", keys.CmdOrCtrl("q"), func(*menu.CallbackData) {
a.requestQuit()
}))
m.Append(menu.SubMenu("cdrop", appSub)) // macOS renders the first menu as the app menu
m.Append(menu.EditMenu()) // Undo/Redo/Cut/Copy/Paste/SelectAll
window := menu.NewMenu()
window.Append(menu.Text("最小化", keys.CmdOrCtrl("m"), func(*menu.CallbackData) {
runtime.WindowMinimise(a.ctx)
}))
window.Append(menu.Text("关闭窗口", keys.CmdOrCtrl("w"), func(*menu.CallbackData) {
a.hideWindow()
}))
m.Append(menu.SubMenu("窗口", window))
return m
}
// hideWindow tucks the window away to the menu bar (window hidden, Dock icon
// gone). Shared by ⌘W and the window's close button (via beforeClose).
func (a *App) hideWindow() {
runtime.WindowHide(a.ctx)
platform.SetDockVisible(false)
}
// beforeClose intercepts the window close. Instead of quitting, it hides the
// window and drops to accessory (Dock icon gone, menu bar item remains the way
// back in). The real exit only happens after the menu bar "Quit" sets quitting.
func (a *App) beforeClose(_ context.Context) bool {
if a.quitting {
return false // allow the quit to proceed
}
a.hideWindow()
return true // prevent the close
}
// revealWindow brings the main window back and restores the Dock icon. Bound to
// the menu bar "Show" item.
func (a *App) revealWindow() {
if a.ctx == nil {
return
}
platform.SetDockVisible(true)
runtime.WindowShow(a.ctx)
runtime.WindowUnminimise(a.ctx)
}
// openSettings reveals the window and asks the WebView to route to settings.
func (a *App) openSettings() {
a.revealWindow()
runtime.EventsEmit(a.ctx, "menu:settings")
}
// requestQuit flags an intentional quit so beforeClose lets it through, then
// asks Wails to terminate.
func (a *App) requestQuit() {
a.quitting = true
runtime.Quit(a.ctx)
}
// startClipboardSync starts the native pasteboard watcher. Local copies are
// emitted to the WebView as "clipboard:native"; the JS layer owns the upload
// decision (auth + the user's sync toggle) and reuses the web app's existing
// upload + dedup path. The download direction is driven by the WebView calling
// ApplyRemoteClipboard. The shared Monitor enforces the loop guard across both
// directions.
func (a *App) startClipboardSync(ctx context.Context) {
a.clipSource = platform.NewClipboardSource()
a.clipMonitor = platform.NewMonitor(func(text string) {
runtime.EventsEmit(a.ctx, "clipboard:native", text)
})
go platform.Run(ctx, a.clipSource, a.clipMonitor)
}
// ApplyRemoteClipboard is bound to the WebView. It writes a remote clipboard
// update to the local pasteboard (download direction). The Monitor arms its loop
// guard before the write so the resulting change isn't re-uploaded, and skips
// this device's own echoes. content is plain text at this stage.
func (a *App) ApplyRemoteClipboard(content, sourceDevice string) {
if a.clipMonitor == nil || a.clipSource == nil {
return
}
a.clipMonitor.ApplyRemote(a.clipSource, platform.ClipboardContent{
Content: content,
ContentType: "text/plain",
SourceDevice: sourceDevice,
}, a.DeviceName())
}
// StartLogin is bound to the WebView. It runs the loopback PKCE flow in the
// background and reports the outcome via Wails events ("oauth:success" /
// "oauth:error"). On success it persists the full session (with refresh_token)
// Go-side and emits only the refresh-free SessionView to JS — the refresh_token
// never enters the WebView (see the credential policy in session.go).
func (a *App) StartLogin() {
go func() {
cfg, err := a.resolveOAuthConfig()
if err != nil {
runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()})
return
}
flow := platform.NewFlow(cfg, func(u string) {
runtime.BrowserOpenURL(a.ctx, u)
})
tok, err := flow.Login(a.ctx)
if err != nil {
runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()})
return
}
res, err := loginResultFromToken(tok)
if err != nil {
runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()})
return
}
a.token = tok
if err := platform.SaveSession(*res); err != nil {
runtime.LogWarningf(a.ctx, "persist session failed: %v", err)
}
view := res.View()
runtime.EventsEmit(a.ctx, "oauth:success", &view)
}()
}
// Refresh is bound to the WebView and takes NO argument: the refresh_token never
// crosses into JS, so Go uses its own in-memory copy. It mints a fresh
// access_token (public-client loopback flow, no client_secret), persists the
// rotated session, and returns only the refresh-free SessionView. The WebView
// calls this when an API request comes back 401.
func (a *App) Refresh() (*platform.SessionView, error) {
if a.token == nil || a.token.RefreshToken == "" {
return nil, errors.New("no refresh token")
}
cfg, err := a.resolveOAuthConfig()
if err != nil {
return nil, err
}
flow := platform.NewFlow(cfg, func(string) {})
tok, err := flow.Refresh(a.ctx, a.token.RefreshToken)
if err != nil {
return nil, err
}
if tok.RefreshToken == "" {
tok.RefreshToken = a.token.RefreshToken // providers that don't rotate: keep the old one
}
res, err := loginResultFromToken(tok)
if err != nil {
return nil, err
}
a.token = tok
if err := platform.SaveSession(*res); err != nil {
runtime.LogWarningf(a.ctx, "persist refreshed session failed: %v", err)
}
view := res.View()
return &view, nil
}
// ClearSession is bound to the WebView; called on logout to drop the persisted
// session file (and the Go-side token copy) so the next launch starts logged out.
func (a *App) ClearSession() error {
a.token = nil
return platform.ClearSession()
}
// GetSettings is bound to the WebView. It returns the persisted desktop config,
// with LaunchAtLogin reflecting the actual LaunchAgent state (the plist is the
// source of truth — the user may have toggled it elsewhere).
func (a *App) GetSettings() platform.DesktopConfig {
cfg, _ := platform.LoadConfig()
cfg.LaunchAtLogin = platform.IsLaunchAtLoginEnabled()
return cfg
}
// SaveSettings is bound to the WebView. It persists the settings the page owns
// and applies the launch-at-login side effect (install / remove the LaunchAgent).
//
// The page's DesktopConfig has no device_name field, so the incoming cfg always
// carries DeviceName="". We merge onto the current config (preserving DeviceName,
// which is owned by SetDeviceName) instead of overwriting — otherwise saving any
// setting would wipe the persisted device name. Config is written before the
// launch-agent side effect so its failure doesn't lose the other preferences.
func (a *App) SaveSettings(cfg platform.DesktopConfig) error {
cur, _ := platform.LoadConfig()
cur.ClipboardSyncEnabled = cfg.ClipboardSyncEnabled
cur.LaunchAtLogin = cfg.LaunchAtLogin
cur.DownloadDir = cfg.DownloadDir
if err := platform.SaveConfig(cur); err != nil {
return err
}
return platform.SetLaunchAtLogin(cur.LaunchAtLogin)
}
// DeviceName is bound to the WebView. Returns the persisted device name, or the
// hostname when the user hasn't renamed it — so the desktop skips the per-device
// naming step the browser shows.
func (a *App) DeviceName() string {
return platform.ResolveDeviceName()
}
// SetDeviceName is bound to the WebView; called when the user renames this device
// so the name survives restart (the WebView's localStorage doesn't, on wails://).
func (a *App) SetDeviceName(name string) error {
cfg, _ := platform.LoadConfig()
cfg.DeviceName = name
return platform.SaveConfig(cfg)
}
// SaveDownload is bound to the WebView. The transfer pipeline hands a received
// file's bytes (base64 over the bridge) to Go, which writes them into the user's
// configured download directory (default ~/Downloads) without overwriting. The
// absolute path is returned so the page can tell the user where it landed.
func (a *App) SaveDownload(name string, data []byte) (string, error) {
return platform.SaveDownload(platform.ResolveDownloadDir(), name, data)
}
// ChooseDownloadDir opens a native directory picker (title localised by the
// caller) and returns the chosen path, or "" when the user cancels. The page
// persists the result via SaveSettings.
func (a *App) ChooseDownloadDir(title string) (string, error) {
return runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{Title: title})
}
// EffectiveDownloadDir returns the directory downloads currently land in — the
// configured override or the system default — for display in the settings page.
func (a *App) EffectiveDownloadDir() string {
return platform.ResolveDownloadDir()
}
// resolveOAuthConfig prefers explicit env overrides (dev / a pinned client);
// otherwise it fetches /api/auth/config from the backend and reuses the web
// app's client_id for the desktop's loopback PKCE flow (see desktop/PLAN.md §3).
func (a *App) resolveOAuthConfig() (platform.OAuthConfig, error) {
if env := oauthConfigFromEnv(); env.ClientID != "" {
return env, nil
}
return platform.FetchOAuthConfig(a.ctx, a.apiBase)
}
func oauthConfigFromEnv() platform.OAuthConfig {
return platform.OAuthConfig{
AuthorizeURL: os.Getenv("CDROP_OAUTH_AUTHORIZE_URL"),
TokenURL: os.Getenv("CDROP_OAUTH_TOKEN_URL"),
ClientID: os.Getenv("CDROP_OAUTH_CLIENT_ID"),
Scopes: os.Getenv("CDROP_OAUTH_SCOPES"),
}
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// LoggedIn reports whether a token is currently held; bound so the WebView can
// query auth state on startup.
func (a *App) LoggedIn() bool {
return a.token != nil
}
// Greet returns a greeting for the given name (scaffold demo, kept until the
// shared web UI replaces the default frontend).
func (a *App) Greet(name string) string {
return fmt.Sprintf("Hello %s, It's show time!", name)
}
+35
View File
@@ -0,0 +1,35 @@
# Build Directory
The build directory is used to house all the build files and assets for your application.
The structure is:
* bin - Output directory
* darwin - macOS specific files
* windows - Windows specific files
## Mac
The `darwin` directory holds files specific to Mac builds.
These may be customised and used as part of the build. To return these files to the default state, simply delete them
and
build with `wails build`.
The directory contains the following files:
- `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`.
- `Info.dev.plist` - same as the main plist file but used when building using `wails dev`.
## Windows
The `windows` directory contains the manifest and rc files used when building with `wails build`.
These may be customised for your application. To return these files to the default state, simply delete them and
build with `wails build`.
- `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to
use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file
will be created using the `appicon.png` file in the build directory.
- `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`.
- `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer,
as well as the application itself (right click the exe -> properties -> details)
- `wails.exe.manifest` - The main application manifest file.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+68
View File
@@ -0,0 +1,68 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>net.commilitia.cdrop</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>
+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>net.commilitia.cdrop</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
</dict>
</plist>
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

+15
View File
@@ -0,0 +1,15 @@
{
"fixed": {
"file_version": "{{.Info.ProductVersion}}"
},
"info": {
"0000": {
"ProductVersion": "{{.Info.ProductVersion}}",
"CompanyName": "{{.Info.CompanyName}}",
"FileDescription": "{{.Info.ProductName}}",
"LegalCopyright": "{{.Info.Copyright}}",
"ProductName": "{{.Info.ProductName}}",
"Comments": "{{.Info.Comments}}"
}
}
}
+114
View File
@@ -0,0 +1,114 @@
Unicode true
####
## Please note: Template replacements don't work in this file. They are provided with default defines like
## mentioned underneath.
## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo.
## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually
## from outside of Wails for debugging and development of the installer.
##
## For development first make a wails nsis build to populate the "wails_tools.nsh":
## > wails build --target windows/amd64 --nsis
## Then you can call makensis on this file with specifying the path to your binary:
## For a AMD64 only installer:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
## For a ARM64 only installer:
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
## For a installer with both architectures:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
####
## The following information is taken from the ProjectInfo file, but they can be overwritten here.
####
## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}"
## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}"
## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}"
## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}"
## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}"
###
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
####
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
####
## Include the wails tools
####
!include "wails_tools.nsh"
# The version information for this two must consist of 4 parts
VIProductVersion "${INFO_PRODUCTVERSION}.0"
VIFileVersion "${INFO_PRODUCTVERSION}.0"
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
ManifestDPIAware true
!include "MUI.nsh"
!define MUI_ICON "..\icon.ico"
!define MUI_UNICON "..\icon.ico"
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
!insertmacro MUI_PAGE_INSTFILES # Installing page.
!insertmacro MUI_PAGE_FINISH # Finished installation page.
!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
#!uninstfinalize 'signtool --file "%1"'
#!finalize 'signtool --file "%1"'
Name "${INFO_PRODUCTNAME}"
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
ShowInstDetails show # This will always show the installation details.
Function .onInit
!insertmacro wails.checkArchitecture
FunctionEnd
Section
!insertmacro wails.setShellContext
!insertmacro wails.webview2runtime
SetOutPath $INSTDIR
!insertmacro wails.files
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
!insertmacro wails.associateFiles
!insertmacro wails.associateCustomProtocols
!insertmacro wails.writeUninstaller
SectionEnd
Section "uninstall"
!insertmacro wails.setShellContext
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
RMDir /r $INSTDIR
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
!insertmacro wails.unassociateFiles
!insertmacro wails.unassociateCustomProtocols
!insertmacro wails.deleteUninstaller
SectionEnd
@@ -0,0 +1,249 @@
# DO NOT EDIT - Generated automatically by `wails build`
!include "x64.nsh"
!include "WinVer.nsh"
!include "FileFunc.nsh"
!ifndef INFO_PROJECTNAME
!define INFO_PROJECTNAME "{{.Name}}"
!endif
!ifndef INFO_COMPANYNAME
!define INFO_COMPANYNAME "{{.Info.CompanyName}}"
!endif
!ifndef INFO_PRODUCTNAME
!define INFO_PRODUCTNAME "{{.Info.ProductName}}"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "{{.Info.ProductVersion}}"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "{{.Info.Copyright}}"
!endif
!ifndef PRODUCT_EXECUTABLE
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
!endif
!ifndef UNINST_KEY_NAME
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
!endif
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
!ifndef REQUEST_EXECUTION_LEVEL
!define REQUEST_EXECUTION_LEVEL "admin"
!endif
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
!ifdef ARG_WAILS_AMD64_BINARY
!define SUPPORTS_AMD64
!endif
!ifdef ARG_WAILS_ARM64_BINARY
!define SUPPORTS_ARM64
!endif
!ifdef SUPPORTS_AMD64
!ifdef SUPPORTS_ARM64
!define ARCH "amd64_arm64"
!else
!define ARCH "amd64"
!endif
!else
!ifdef SUPPORTS_ARM64
!define ARCH "arm64"
!else
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
!endif
!endif
!macro wails.checkArchitecture
!ifndef WAILS_WIN10_REQUIRED
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
!endif
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
!endif
${If} ${AtLeastWin10}
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
Goto ok
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
Goto ok
${EndIf}
!endif
IfSilent silentArch notSilentArch
silentArch:
SetErrorLevel 65
Abort
notSilentArch:
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
Quit
${else}
IfSilent silentWin notSilentWin
silentWin:
SetErrorLevel 64
Abort
notSilentWin:
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
Quit
${EndIf}
ok:
!macroend
!macro wails.files
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
${EndIf}
!endif
!macroend
!macro wails.writeUninstaller
WriteUninstaller "$INSTDIR\uninstall.exe"
SetRegView 64
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
!macroend
!macro wails.deleteUninstaller
Delete "$INSTDIR\uninstall.exe"
SetRegView 64
DeleteRegKey HKLM "${UNINST_KEY}"
!macroend
!macro wails.setShellContext
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
SetShellVarContext all
${else}
SetShellVarContext current
${EndIf}
!macroend
# Install webview2 by launching the bootstrapper
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
!macro wails.webview2runtime
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
!endif
SetRegView 64
# If the admin key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${EndIf}
SetDetailsPrint both
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
SetDetailsPrint listonly
InitPluginsDir
CreateDirectory "$pluginsdir\webview2bootstrapper"
SetOutPath "$pluginsdir\webview2bootstrapper"
File "tmp\MicrosoftEdgeWebview2Setup.exe"
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
SetDetailsPrint both
ok:
!macroend
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
!macroend
!macro APP_UNASSOCIATE EXT FILECLASS
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
!macroend
!macro wails.associateFiles
; Create file associations
{{range .Info.FileAssociations}}
!insertmacro APP_ASSOCIATE "{{.Ext}}" "{{.Name}}" "{{.Description}}" "$INSTDIR\{{.IconName}}.ico" "Open with ${INFO_PRODUCTNAME}" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
File "..\{{.IconName}}.ico"
{{end}}
!macroend
!macro wails.unassociateFiles
; Delete app associations
{{range .Info.FileAssociations}}
!insertmacro APP_UNASSOCIATE "{{.Ext}}" "{{.Name}}"
Delete "$INSTDIR\{{.IconName}}.ico"
{{end}}
!macroend
!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
!macroend
!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
!macroend
!macro wails.associateCustomProtocols
; Create custom protocols associations
{{range .Info.Protocols}}
!insertmacro CUSTOM_PROTOCOL_ASSOCIATE "{{.Scheme}}" "{{.Description}}" "$INSTDIR\${PRODUCT_EXECUTABLE},0" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
{{end}}
!macroend
!macro wails.unassociateCustomProtocols
; Delete app custom protocol associations
{{range .Info.Protocols}}
!insertmacro CUSTOM_PROTOCOL_UNASSOCIATE "{{.Scheme}}"
{{end}}
!macroend
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
</asmv3:windowsSettings>
</asmv3:application>
</assembly>
+101
View File
@@ -0,0 +1,101 @@
//go:build darwin
// clipprobe 是一个只读诊断工具:监听本机剪贴板变化,打印每次变化时
// NSPasteboard 上挂的全部类型(UTI)。它不读取也不上传任何剪贴板内容,
// 只看「类型」,用于在真机上经验性地确定各应用(尤其密码管理器)复制时
// 打哪些 UTI,从而让 D4 的敏感内容过滤基于实测数据而非猜测。
//
// 用法:go run ./cmd/clipprobe(或 go build 后运行二进制),然后依次从
// 普通文本编辑器 / 1Password / Bitwarden / 浏览器密码自动填充 等复制,观察
// 各自打印的 UTI。含 org.nspasteboard.ConcealedType / TransientType /
// AutoGeneratedType 或厂商私有 UTI(如 com.agilebits.onepassword)者,即为
// D4 应跳过上传的敏感内容。
//
// 实现用 ebitengine/purego + objc 运行时直调 AppKit,无 cgo、可随 Wails
// 交叉编译;调用模式照搬 golang.design/x/clipboard 的 darwin 实现。
package main
import (
"fmt"
"runtime"
"time"
"unsafe"
"github.com/ebitengine/purego"
"github.com/ebitengine/purego/objc"
)
func main() {
// objc 调用必须钉在同一 OS 线程上(autorelease pool 的语义要求)。
runtime.LockOSThread()
// 加载 AppKit / Foundation,使 NSPasteboard / NSAutoreleasePool 等类可解析。
for _, fw := range []string{
"/System/Library/Frameworks/AppKit.framework/AppKit",
"/System/Library/Frameworks/Foundation.framework/Foundation",
} {
if _, err := purego.Dlopen(fw, purego.RTLD_NOW|purego.RTLD_GLOBAL); err != nil {
panic(fmt.Sprintf("clipprobe: 无法加载 %s: %v", fw, err))
}
}
pbClass := objc.GetClass("NSPasteboard")
poolClass := objc.GetClass("NSAutoreleasePool")
var (
selGeneral = objc.RegisterName("generalPasteboard")
selChangeCount = objc.RegisterName("changeCount")
selTypes = objc.RegisterName("types")
selCount = objc.RegisterName("count")
selObjectAt = objc.RegisterName("objectAtIndex:")
selUTF8 = objc.RegisterName("UTF8String")
selAlloc = objc.RegisterName("alloc")
selInit = objc.RegisterName("init")
selRelease = objc.RegisterName("release")
)
fmt.Println("clipprobe:监听剪贴板,打印每次变化的 pasteboard 类型(UTI)。Ctrl+C 退出。")
fmt.Println("依次从 普通文本 / 1Password / Bitwarden / 浏览器密码自动填充 复制,观察各自的 UTI。")
fmt.Println("含 org.nspasteboard.ConcealedType / TransientType / AutoGeneratedType")
fmt.Println("或厂商私有 UTIcom.agilebits.onepassword 等)者 = 应跳过上传的敏感内容。")
fmt.Println("(本工具只读类型、不读内容、不上传。)")
fmt.Println("--------")
last := -1
for {
pool := objc.ID(poolClass).Send(selAlloc).Send(selInit)
general := objc.ID(pbClass).Send(selGeneral)
cc := int(general.Send(selChangeCount))
if cc != last {
last = cc
types := general.Send(selTypes)
n := int(types.Send(selCount))
fmt.Printf("[changeCount=%d] %d 个类型:\n", cc, n)
for i := 0; i < n; i++ {
item := types.Send(selObjectAt, i)
cstr := item.Send(selUTF8)
fmt.Printf(" - %s\n", cString(uintptr(cstr)))
}
}
objc.ID(pool).Send(selRelease)
time.Sleep(500 * time.Millisecond)
}
}
// cString reads a NUL-terminated C string (from -[NSString UTF8String]) into a
// Go string.
func cString(p uintptr) string {
if p == 0 {
return "(nil)"
}
var b []byte
for i := 0; ; i++ {
c := *(*byte)(unsafe.Pointer(p + uintptr(i)))
if c == 0 {
break
}
b = append(b, c)
}
return string(b)
}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-Hans">
<head>
<meta charset="UTF-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>cdrop-desktop</title>
</head>
<body>
<div id="app"></div>
<script src="./src/main.ts" type="module"></script>
</body>
</html>
+679
View File
@@ -0,0 +1,679 @@
{
"name": "frontend",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "0.0.0",
"devDependencies": {
"typescript": "^4.5.4",
"vite": "^3.0.7"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.18.tgz",
"integrity": "sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.15.18.tgz",
"integrity": "sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.18.tgz",
"integrity": "sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/android-arm": "0.15.18",
"@esbuild/linux-loong64": "0.15.18",
"esbuild-android-64": "0.15.18",
"esbuild-android-arm64": "0.15.18",
"esbuild-darwin-64": "0.15.18",
"esbuild-darwin-arm64": "0.15.18",
"esbuild-freebsd-64": "0.15.18",
"esbuild-freebsd-arm64": "0.15.18",
"esbuild-linux-32": "0.15.18",
"esbuild-linux-64": "0.15.18",
"esbuild-linux-arm": "0.15.18",
"esbuild-linux-arm64": "0.15.18",
"esbuild-linux-mips64le": "0.15.18",
"esbuild-linux-ppc64le": "0.15.18",
"esbuild-linux-riscv64": "0.15.18",
"esbuild-linux-s390x": "0.15.18",
"esbuild-netbsd-64": "0.15.18",
"esbuild-openbsd-64": "0.15.18",
"esbuild-sunos-64": "0.15.18",
"esbuild-windows-32": "0.15.18",
"esbuild-windows-64": "0.15.18",
"esbuild-windows-arm64": "0.15.18"
}
},
"node_modules/esbuild-android-64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.15.18.tgz",
"integrity": "sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-android-arm64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.15.18.tgz",
"integrity": "sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-darwin-64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.15.18.tgz",
"integrity": "sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-darwin-arm64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.18.tgz",
"integrity": "sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-freebsd-64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.18.tgz",
"integrity": "sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-freebsd-arm64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.18.tgz",
"integrity": "sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-linux-32": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.15.18.tgz",
"integrity": "sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-linux-64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.15.18.tgz",
"integrity": "sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-linux-arm": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.15.18.tgz",
"integrity": "sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-linux-arm64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.18.tgz",
"integrity": "sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-linux-mips64le": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.18.tgz",
"integrity": "sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-linux-ppc64le": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.18.tgz",
"integrity": "sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-linux-riscv64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.18.tgz",
"integrity": "sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-linux-s390x": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.18.tgz",
"integrity": "sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-netbsd-64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.18.tgz",
"integrity": "sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-openbsd-64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.18.tgz",
"integrity": "sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-sunos-64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.15.18.tgz",
"integrity": "sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-windows-32": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.15.18.tgz",
"integrity": "sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-windows-64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.15.18.tgz",
"integrity": "sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild-windows-arm64": {
"version": "0.15.18",
"resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.18.tgz",
"integrity": "sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/is-core-module": {
"version": "2.16.2",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
"integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"is-core-module": "^2.16.1",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/rollup": {
"version": "2.80.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz",
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
"dev": true,
"license": "MIT",
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=10.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/typescript": {
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
}
},
"node_modules/vite": {
"version": "3.2.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-3.2.11.tgz",
"integrity": "sha512-K/jGKL/PgbIgKCiJo5QbASQhFiV02X9Jh+Qq0AKCRCRKZtOTVi4t6wh75FDpGf2N9rYOnzH87OEFQNaFy6pdxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.15.9",
"postcss": "^8.4.18",
"resolve": "^1.22.1",
"rollup": "^2.79.1"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
},
"peerDependencies": {
"@types/node": ">= 14",
"less": "*",
"sass": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
}
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "^4.5.4",
"vite": "^3.0.7"
}
}
+1
View File
@@ -0,0 +1 @@
f8412e249716e9530567e4bc92d8331c
+54
View File
@@ -0,0 +1,54 @@
#logo {
display: block;
width: 50%;
height: 50%;
margin: auto;
padding: 10% 0 0;
background-position: center;
background-repeat: no-repeat;
background-size: 100% 100%;
background-origin: content-box;
}
.result {
height: 20px;
line-height: 20px;
margin: 1.5rem auto;
}
.input-box .btn {
width: 60px;
height: 30px;
line-height: 30px;
border-radius: 3px;
border: none;
margin: 0 0 0 20px;
padding: 0 8px;
cursor: pointer;
}
.input-box .btn:hover {
background-image: linear-gradient(to top, #cfd9df 0%, #e2ebf0 100%);
color: #333333;
}
.input-box .input {
border: none;
border-radius: 3px;
outline: none;
height: 30px;
line-height: 30px;
padding: 0 10px;
background-color: rgba(240, 240, 240, 1);
-webkit-font-smoothing: antialiased;
}
.input-box .input:hover {
border: none;
background-color: rgba(255, 255, 255, 1);
}
.input-box .input:focus {
border: none;
background-color: rgba(255, 255, 255, 1);
}
+93
View File
@@ -0,0 +1,93 @@
Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

+50
View File
@@ -0,0 +1,50 @@
import "./style.css";
import "./app.css";
import { StartLogin, LoggedIn } from "../wailsjs/go/main/App";
import { EventsOn } from "../wailsjs/runtime/runtime";
// 临时登录验证 UI:在共享 web 界面接入(A2)前,用它端到端验证 D1
// loopback OAuth 流程。点登录 → Go 起 loopback + 开系统浏览器 → 授权回跳 →
// Go 换 token → oauth:success / oauth:error 事件回到这里。
document.querySelector("#app")!.innerHTML = `
<main class="login-demo">
<h1>cdrop </h1>
<p class="status" id="status"></p>
<button class="btn" id="loginBtn" disabled> cdrop</button>
<p class="hint"></p>
</main>
`;
const statusEl = document.getElementById("status") as HTMLElement;
const loginBtn = document.getElementById("loginBtn") as HTMLButtonElement;
function setStatus(text: string): void {
statusEl.innerText = text;
}
loginBtn.addEventListener("click", () => {
setStatus("已在系统浏览器中打开授权页,请完成登录……");
loginBtn.disabled = true;
StartLogin();
});
EventsOn("oauth:success", (payload: { expiresIn: number }) => {
setStatus(`登录成功,access token 有效期约 ${payload.expiresIn} 秒。`);
loginBtn.innerText = "重新登录";
loginBtn.disabled = false;
});
EventsOn("oauth:error", (payload: { detail: string }) => {
setStatus(`登录失败:${payload.detail}`);
loginBtn.disabled = false;
});
LoggedIn()
.then((yes) => {
setStatus(yes ? "已登录。" : "尚未登录。");
loginBtn.disabled = false;
})
.catch(() => {
setStatus("尚未登录。");
loginBtn.disabled = false;
});
+26
View File
@@ -0,0 +1,26 @@
html {
background-color: rgba(27, 38, 54, 1);
text-align: center;
color: white;
}
body {
margin: 0;
color: white;
font-family: "Nunito", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
}
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 400;
src: local(""),
url("assets/fonts/nunito-v16-latin-regular.woff2") format("woff2");
}
#app {
height: 100vh;
text-align: center;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": [
"ESNext",
"DOM"
],
"moduleResolution": "Node",
"strict": true,
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"noEmit": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"skipLibCheck": true
},
"include": [
"src"
]
}
+49
View File
@@ -0,0 +1,49 @@
module cdrop-desktop
go 1.24
require (
fyne.io/systray v1.12.2
github.com/ebitengine/purego v0.10.1
github.com/wailsapp/wails/v2 v2.12.0
github.com/zalando/go-keyring v0.2.8
golang.design/x/clipboard v0.8.0
golang.org/x/sys v0.33.0
)
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.design/x/x11 v0.2.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 // indirect
golang.org/x/image v0.28.0 // indirect
golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/text v0.26.0 // indirect
)
// replace github.com/wailsapp/wails/v2 v2.12.0 => /Users/commilitia/go/pkg/mod
+103
View File
@@ -0,0 +1,103 @@
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
golang.design/x/clipboard v0.8.0 h1:6VEcH28wwcSgKc+vnxHHDWiRjrakTQIAJnPUrt3aOgg=
golang.design/x/clipboard v0.8.0/go.mod h1:s0pwrtA3Q9fgnVtGDmP5ZK/pp55cQKB23esKsjwWhWM=
golang.design/x/x11 v0.2.0 h1:Uiwu2guGihsJX/ZCzpoDPFz5gR/Qntm08mvoBCmRydo=
golang.design/x/x11 v0.2.0/go.mod h1:/5q1mFkdc1rL8mvB7DsQFi6as4tIkBv4FXjcP07mrkE=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 h1:Wdx0vgH5Wgsw+lF//LJKmWOJBLWX6nprsMqnf99rYDE=
golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f h1:/n+PL2HlfqeSiDCuhdBbRNlGS/g2fM4OHufalHaTVG8=
golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f/go.mod h1:ESkJ836Z6LpG6mTVAhA48LpfW/8fNR0ifStlH2axyfg=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+105
View File
@@ -0,0 +1,105 @@
package main
import (
"embed"
"os"
"runtime"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
"cdrop-desktop/platform"
)
//go:embed all:frontend/dist
var assets embed.FS
func main() {
// Create an instance of the app structure
app := NewApp()
// Reverse-proxy /api/* to the backend so the embedded web UI can keep its
// same-origin relative URLs (see platform.NewAPIProxy). SSE streams through.
apiProxy, err := platform.NewAPIProxy(app.apiBase)
if err != nil {
println("Error:", err.Error())
return
}
// Windows: Wails' custom-scheme AssetServer buffers a handler's response until
// it returns, so the long-lived SSE never reaches the WebView (device list
// never loads, status stuck on "connecting"). Serve /api from a loopback HTTP
// origin instead — WebView2's normal network stack streams it — and point the
// page there via the injected api_base. macOS streams the custom scheme fine,
// so it keeps api_base="" (same-origin relative /api through apiProxy).
webAPIBase := ""
if runtime.GOOS == "windows" {
if base, lerr := platform.StartLocalAPIServer(app.apiBase); lerr == nil {
webAPIBase = base
} else {
println("local api server:", lerr.Error())
}
}
// Tell the backend this is a native desktop client, not a browser, so the
// device list shows the right kind. The page forwards it as X-Device-Type.
deviceType := ""
switch runtime.GOOS {
case "darwin":
deviceType = "macos"
case "windows":
deviceType = "windows"
case "linux":
deviceType = "linux"
}
// Restore the persisted login + device name by injecting them into index.html
// at load — the wails:// scheme has no persistent WebView storage (see
// session.go).
session, _ := platform.LoadSession()
// When the OS starts us at login, the autostart entry appends a --hidden flag
// (see SetLaunchAtLogin). Come up straight to the menu bar / tray with no
// window — the user didn't open the app, so don't steal focus. The frontend
// still loads, so clipboard sync runs in the background. A manual launch has
// no flag and shows the window as usual.
hidden := launchedHidden()
app.startHidden = hidden
// Create application with options
err = wails.Run(&options.App{
Title: "cdrop",
Width: 1024,
Height: 768,
StartHidden: hidden,
AssetServer: &assetserver.Options{
Assets: assets,
Handler: apiProxy,
Middleware: platform.SessionInjectMiddleware(session, platform.ResolveDeviceName(), webAPIBase, deviceType),
},
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
Menu: app.buildMenu(),
OnStartup: app.startup,
OnBeforeClose: app.beforeClose,
Bind: []interface{}{
app,
},
})
if err != nil {
println("Error:", err.Error())
}
}
// launchedHidden reports whether this process was started by the login autostart
// entry, which appends a --hidden flag to the launch command (see
// SetLaunchAtLogin on each platform). Manual launches never carry it.
func launchedHidden() bool {
for _, arg := range os.Args[1:] {
if arg == "--hidden" {
return true
}
}
return false
}
+108
View File
@@ -0,0 +1,108 @@
package platform
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// ClipboardMaxBytes mirrors the backend's CDROP_CLIPBOARD_MAX_BYTES (default
// 64 KiB). Uploads larger than this are rejected client-side, matching the web.
const ClipboardMaxBytes = 64 * 1024
// Client is an authenticated client for the cdrop backend API. The access token
// is held here in Go (never in the WebView); deviceName identifies this device
// as the source of clipboard writes via the X-Device-Name header.
type Client struct {
baseURL string
token string
deviceName string
http *http.Client
}
// NewClient builds a Client. baseURL is the backend origin, e.g.
// https://drop.commilitia.net.
func NewClient(baseURL, accessToken, deviceName string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
token: accessToken,
deviceName: deviceName,
http: &http.Client{Timeout: 30 * time.Second},
}
}
// ClipboardContent is the cloud clipboard state. UpdatedAt is the server's
// authoritative timestamp (also the ETag); 0 means never written.
type ClipboardContent struct {
Content string `json:"content"`
ContentType string `json:"content_type"`
SourceDevice string `json:"source_device"`
UpdatedAt int64 `json:"updated_at"`
}
func (c *Client) authReq(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/json")
if c.deviceName != "" {
req.Header.Set("X-Device-Name", c.deviceName)
}
return req, nil
}
// Clipboard fetches the current cloud clipboard content.
func (c *Client) Clipboard(ctx context.Context) (ClipboardContent, error) {
req, err := c.authReq(ctx, http.MethodGet, "/api/clipboard", nil)
if err != nil {
return ClipboardContent{}, fmt.Errorf("api: build clipboard request: %w", err)
}
resp, err := c.http.Do(req)
if err != nil {
return ClipboardContent{}, fmt.Errorf("api: clipboard request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return ClipboardContent{}, fmt.Errorf("api: GET clipboard status %d", resp.StatusCode)
}
var cc ClipboardContent
if err := json.NewDecoder(resp.Body).Decode(&cc); err != nil {
return ClipboardContent{}, fmt.Errorf("api: decode clipboard: %w", err)
}
return cc, nil
}
// PutClipboard uploads plain text to the cloud clipboard.
func (c *Client) PutClipboard(ctx context.Context, text string) error {
if len(text) > ClipboardMaxBytes {
return fmt.Errorf("api: clipboard content %d bytes exceeds limit %d", len(text), ClipboardMaxBytes)
}
payload, err := json.Marshal(map[string]string{
"content": text,
"content_type": "text/plain",
})
if err != nil {
return fmt.Errorf("api: marshal clipboard: %w", err)
}
req, err := c.authReq(ctx, http.MethodPut, "/api/clipboard", bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("api: build clipboard request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("api: clipboard upload: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return fmt.Errorf("api: PUT clipboard status %d", resp.StatusCode)
}
return nil
}
+90
View File
@@ -0,0 +1,90 @@
package platform
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestPutClipboard(t *testing.T) {
var gotAuth, gotDevice, gotCT string
var gotBody map[string]string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut || r.URL.Path != "/api/clipboard" {
t.Errorf("method/path = %s %s", r.Method, r.URL.Path)
}
gotAuth = r.Header.Get("Authorization")
gotDevice = r.Header.Get("X-Device-Name")
gotCT = r.Header.Get("Content-Type")
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := NewClient(srv.URL, "tok-abc", "my-mac")
if err := c.PutClipboard(context.Background(), "hello world"); err != nil {
t.Fatalf("PutClipboard: %v", err)
}
if gotAuth != "Bearer tok-abc" {
t.Errorf("Authorization = %q, want Bearer tok-abc", gotAuth)
}
if gotDevice != "my-mac" {
t.Errorf("X-Device-Name = %q, want my-mac", gotDevice)
}
if gotCT != "application/json" {
t.Errorf("Content-Type = %q", gotCT)
}
if gotBody["content"] != "hello world" || gotBody["content_type"] != "text/plain" {
t.Errorf("body = %v", gotBody)
}
}
func TestPutClipboard_TooLarge(t *testing.T) {
c := NewClient("https://example", "tok", "dev")
big := strings.Repeat("x", ClipboardMaxBytes+1)
if err := c.PutClipboard(context.Background(), big); err == nil {
t.Fatal("want error for oversized content, got nil")
}
}
func TestClipboard_Get(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(ClipboardContent{
Content: "from cloud",
ContentType: "text/plain",
SourceDevice: "other-device",
UpdatedAt: 1700000000,
})
}))
defer srv.Close()
c := NewClient(srv.URL, "tok-xyz", "my-mac")
cc, err := c.Clipboard(context.Background())
if err != nil {
t.Fatalf("Clipboard: %v", err)
}
if gotAuth != "Bearer tok-xyz" {
t.Errorf("Authorization = %q", gotAuth)
}
if cc.Content != "from cloud" || cc.SourceDevice != "other-device" || cc.UpdatedAt != 1700000000 {
t.Errorf("clipboard = %+v", cc)
}
}
func TestClipboard_Unauthorized(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
c := NewClient(srv.URL, "bad", "dev")
if _, err := c.Clipboard(context.Background()); err == nil {
t.Fatal("want error for 401, got nil")
}
}
+104
View File
@@ -0,0 +1,104 @@
package platform
import (
"encoding/json"
"errors"
"os"
"path/filepath"
)
// DesktopConfig is the desktop-only preference set, persisted as JSON under the
// user config dir. json tags are the contract with the WebView settings page.
type DesktopConfig struct {
ClipboardSyncEnabled bool `json:"clipboard_sync_enabled"`
LaunchAtLogin bool `json:"launch_at_login"`
DeviceName string `json:"device_name"` // empty = use the hostname
DownloadDir string `json:"download_dir"` // empty = system Downloads dir
}
// ResolveDeviceName returns the persisted device name, or the hostname when the
// user hasn't renamed it. Persisting it Go-side is required because the WebView's
// localStorage doesn't survive restart on the wails:// scheme (see session.go).
func ResolveDeviceName() string {
cfg, _ := LoadConfig()
if cfg.DeviceName != "" {
return cfg.DeviceName
}
if h, err := os.Hostname(); err == nil && h != "" {
return h
}
return "cdrop-desktop"
}
// DefaultConfig is what a fresh install gets: clipboard sync on, no autostart.
func DefaultConfig() DesktopConfig {
return DesktopConfig{ClipboardSyncEnabled: true, LaunchAtLogin: false}
}
// ResolveDownloadDir returns the directory received files land in: the user's
// configured override, or the system Downloads dir when unset.
func ResolveDownloadDir() string {
cfg, _ := LoadConfig()
if cfg.DownloadDir != "" {
return cfg.DownloadDir
}
return DefaultDownloadDir()
}
// DefaultDownloadDir is ~/Downloads (the OS default download folder; its path is
// stable across locales on macOS/Windows). Falls back to the cwd if the home
// dir can't be resolved.
func DefaultDownloadDir() string {
if home, err := os.UserHomeDir(); err == nil && home != "" {
return filepath.Join(home, "Downloads")
}
return "."
}
// configPath is ~/Library/Application Support/cdrop/config.json on macOS
// (os.UserConfigDir resolves the per-OS base).
func configPath() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "cdrop", "config.json"), nil
}
// LoadConfig reads the persisted config, falling back to defaults when the file
// is absent or unreadable so the app always has a usable preference set.
func LoadConfig() (DesktopConfig, error) {
p, err := configPath()
if err != nil {
return DefaultConfig(), err
}
data, err := os.ReadFile(p)
if errors.Is(err, os.ErrNotExist) {
return DefaultConfig(), nil
}
if err != nil {
return DefaultConfig(), err
}
cfg := DefaultConfig()
if err := json.Unmarshal(data, &cfg); err != nil {
return DefaultConfig(), err
}
return cfg, nil
}
// SaveConfig writes the config atomically-ish (mkdir + write) under the user
// config dir.
func SaveConfig(cfg DesktopConfig) error {
p, err := configPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(p, data, 0o644)
}
+55
View File
@@ -0,0 +1,55 @@
package platform
import (
"os"
"path/filepath"
"testing"
)
func TestConfigRoundTrip(t *testing.T) {
// Redirect the user config dir into a temp HOME so the test never touches
// the real ~/Library/Application Support.
t.Setenv("HOME", t.TempDir())
// Absent file → defaults.
got, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig (absent): %v", err)
}
if got != DefaultConfig() {
t.Errorf("absent config should be defaults, got %+v", got)
}
want := DesktopConfig{ClipboardSyncEnabled: false, LaunchAtLogin: true}
if err := SaveConfig(want); err != nil {
t.Fatalf("SaveConfig: %v", err)
}
got, err = LoadConfig()
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if got != want {
t.Errorf("round-trip: got %+v, want %+v", got, want)
}
}
func TestLoadConfigToleratesGarbage(t *testing.T) {
t.Setenv("HOME", t.TempDir())
p, err := configPath()
if err != nil {
t.Fatalf("configPath: %v", err)
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("{not json"), 0o644); err != nil {
t.Fatal(err)
}
got, err := LoadConfig()
if err == nil {
t.Error("expected an error for malformed json")
}
if got != DefaultConfig() {
t.Errorf("malformed config should fall back to defaults, got %+v", got)
}
}
+151
View File
@@ -0,0 +1,151 @@
package platform
import (
"context"
"crypto/sha256"
"encoding/hex"
"sync"
)
// ClipboardEvent is one observed change of the local clipboard.
type ClipboardEvent struct {
Text string
// Sensitive is set by the platform Source when the pasteboard carries a
// privacy marker — on macOS org.nspasteboard.ConcealedType / TransientType /
// AutoGeneratedType or a known password-manager UTI; on Windows the
// ExcludeClipboardContentFromMonitorProcessing / CanUploadToCloudClipboard
// formats. Sensitive events are never uploaded.
Sensitive bool
}
// Sink receives clipboard text that the policy decided should be uploaded.
type Sink func(text string)
// Source is the platform-specific clipboard backend: it watches for local
// changes and can write text back. Implementations live in clipboard_darwin.go
// / clipboard_windows.go. The upstream golang.design/x/clipboard library only
// surfaces the changed text (no change-count, no pasteboard types), so the
// Sensitive flag and the upload policy below are this package's responsibility,
// not the library's.
type Source interface {
// Watch emits an event on every observed local clipboard change until ctx
// is cancelled, then closes the channel. The implementation sets Sensitive
// from the pasteboard's privacy markers.
Watch(ctx context.Context) <-chan ClipboardEvent
// Write replaces the local clipboard text.
Write(text string) error
}
// Monitor applies cdrop's upload policy on top of a raw clipboard event stream:
// it drops sensitive content, de-duplicates repeats, and suppresses the echo of
// our own writes (the loop guard, PLAN.md R4). Everything here is pure and
// host-independent, so it is unit-tested without a real clipboard; the
// platform Source produces the raw events and performs the OS writes.
//
// Debounce of rapid successive copies is intentionally left to the upload
// callback (wrap it with github.com/bep/debounce in the app wiring) so the
// policy stays free of timers and trivially testable.
type Monitor struct {
upload Sink
mu sync.Mutex
lastHash string // hash of the last text we accepted (dedup)
selfHash string // hash of the text we most recently wrote ourselves (loop guard)
}
// NewMonitor builds a Monitor that forwards accepted text to upload.
func NewMonitor(upload Sink) *Monitor {
return &Monitor{upload: upload}
}
// Observe runs the policy for one raw event. It returns true when the event was
// accepted and forwarded to the sink, false when it was filtered out. Safe to
// call concurrently with ApplyRemote — the watch goroutine and the download
// path share one Monitor.
func (m *Monitor) Observe(ev ClipboardEvent) bool {
if ev.Sensitive || ev.Text == "" {
return false
}
h := hashText(ev.Text)
m.mu.Lock()
if h == m.selfHash {
// Echo of our own write: consume it once (don't re-upload) and disarm,
// so a genuine later re-copy of the same text still passes.
m.selfHash = ""
m.lastHash = h
m.mu.Unlock()
return false
}
if h == m.lastHash {
m.mu.Unlock()
return false // unchanged content re-observed
}
m.lastHash = h
m.mu.Unlock()
m.upload(ev.Text) // outside the lock: the sink may block (network / IPC)
return true
}
// WillWrite must be called immediately before the Source writes text to the
// local clipboard, so the resulting change notification (carrying the same
// text) is recognised as our own echo and not re-uploaded.
func (m *Monitor) WillWrite(text string) {
m.mu.Lock()
m.selfHash = hashText(text)
m.mu.Unlock()
}
// ApplyRemote writes a remote clipboard change to the local clipboard (the
// download direction), skipping our own uploads echoed back by the server and
// content we already hold. It arms the loop guard before writing, so the
// Source's resulting change event is not re-uploaded. Returns true if it wrote.
func (m *Monitor) ApplyRemote(src Source, cc ClipboardContent, selfDevice string) bool {
if cc.Content == "" {
return false
}
if selfDevice != "" && cc.SourceDevice == selfDevice {
return false // our own upload, echoed back by the server
}
h := hashText(cc.Content)
m.mu.Lock()
if h == m.lastHash {
m.mu.Unlock()
return false // already the current local content
}
m.selfHash = h // arm loop guard inline (avoid re-locking via WillWrite)
m.mu.Unlock()
if err := src.Write(cc.Content); err != nil {
return false
}
m.mu.Lock()
m.lastHash = h
m.mu.Unlock()
return true
}
// Run drives src's events through m until ctx is cancelled or the source closes.
// It is the glue the app uses; all policy decisions live in Monitor.
func Run(ctx context.Context, src Source, m *Monitor) {
ch := src.Watch(ctx)
for {
select {
case <-ctx.Done():
return
case ev, ok := <-ch:
if !ok {
return
}
m.Observe(ev)
}
}
}
func hashText(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
+80
View File
@@ -0,0 +1,80 @@
//go:build darwin
package platform
/*
#cgo darwin CFLAGS: -x objective-c -fobjc-arc
#cgo darwin LDFLAGS: -framework Cocoa
#include <stdlib.h>
long cdropPasteboardChangeCount(void);
char *cdropPasteboardReadText(int *sensitive);
void cdropPasteboardWriteText(const char *text);
*/
import "C"
import (
"context"
"time"
"unsafe"
)
// pasteboardPollInterval is how often the watcher samples the change counter.
// 400ms keeps copies feeling instant without busy-polling.
const pasteboardPollInterval = 400 * time.Millisecond
// darwinClipboard is the macOS Source: it polls NSPasteboard's changeCount for
// local copies and writes plain text back. NSPasteboard read/write off the main
// thread is the same approach the common clipboard libraries take.
type darwinClipboard struct {
poll time.Duration
}
// NewClipboardSource returns the platform clipboard Source (macOS).
func NewClipboardSource() Source {
return &darwinClipboard{poll: pasteboardPollInterval}
}
func (d *darwinClipboard) Watch(ctx context.Context) <-chan ClipboardEvent {
ch := make(chan ClipboardEvent)
go func() {
defer close(ch)
last := C.cdropPasteboardChangeCount()
ticker := time.NewTicker(d.poll)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
cc := C.cdropPasteboardChangeCount()
if cc == last {
continue
}
last = cc
var sensitive C.int
cstr := C.cdropPasteboardReadText(&sensitive)
if cstr == nil {
continue // non-text payload (image / file) — ignore
}
text := C.GoString(cstr)
C.free(unsafe.Pointer(cstr))
select {
case ch <- ClipboardEvent{Text: text, Sensitive: sensitive != 0}:
case <-ctx.Done():
return
}
}
}
}()
return ch
}
func (d *darwinClipboard) Write(text string) error {
c := C.CString(text)
defer C.free(unsafe.Pointer(c))
C.cdropPasteboardWriteText(c)
return nil
}
+42
View File
@@ -0,0 +1,42 @@
#import <Cocoa/Cocoa.h>
#include <stdlib.h>
#include <string.h>
// cdropPasteboardChangeCount returns the general pasteboard's monotonic change
// counter. Polling it is the cheap way to detect a copy without a callback API.
long cdropPasteboardChangeCount(void) {
return (long)[[NSPasteboard generalPasteboard] changeCount];
}
// cdropPasteboardReadText returns a malloc'd UTF-8 copy of the pasteboard's
// plain-text payload (caller frees), or NULL when there is no text (image /
// file only). It sets *sensitive to 1 when a privacy marker type is present
// org.nspasteboard.{Concealed,Transient,AutoGenerated}Type so the upload
// policy can skip it. (Most password managers set no marker; this is a
// best-effort secondary guard behind the server's short TTL.)
char *cdropPasteboardReadText(int *sensitive) {
NSPasteboard *pb = [NSPasteboard generalPasteboard];
*sensitive = 0;
for (NSString *type in pb.types) {
if ([type isEqualToString:@"org.nspasteboard.ConcealedType"] ||
[type isEqualToString:@"org.nspasteboard.TransientType"] ||
[type isEqualToString:@"org.nspasteboard.AutoGeneratedType"]) {
*sensitive = 1;
break;
}
}
NSString *s = [pb stringForType:NSPasteboardTypeString];
if (s == nil) { return NULL; }
const char *utf8 = [s UTF8String];
if (utf8 == NULL) { return NULL; }
return strdup(utf8);
}
// cdropPasteboardWriteText replaces the pasteboard's contents with plain text.
void cdropPasteboardWriteText(const char *text) {
NSString *s = [NSString stringWithUTF8String:text];
if (s == nil) { return; }
NSPasteboard *pb = [NSPasteboard generalPasteboard];
[pb clearContents];
[pb setString:s forType:NSPasteboardTypeString];
}
@@ -0,0 +1,24 @@
//go:build !darwin && !windows
package platform
import "context"
// noopClipboard is the non-darwin Source: it never emits and silently drops
// writes. Windows/Linux native clipboard backends land when those platforms are
// targeted; until then the rest of the sync wiring compiles and stays inert.
type noopClipboard struct{}
// NewClipboardSource returns an inert Source outside macOS.
func NewClipboardSource() Source { return noopClipboard{} }
func (noopClipboard) Watch(ctx context.Context) <-chan ClipboardEvent {
ch := make(chan ClipboardEvent)
go func() {
<-ctx.Done()
close(ch)
}()
return ch
}
func (noopClipboard) Write(string) error { return nil }
+199
View File
@@ -0,0 +1,199 @@
package platform
import (
"context"
"testing"
"time"
)
// collect records everything the monitor decides to upload.
func newCollector() (*[]string, Sink) {
var got []string
return &got, func(text string) { got = append(got, text) }
}
func TestMonitor_UploadsNewText(t *testing.T) {
got, sink := newCollector()
m := NewMonitor(sink)
if !m.Observe(ClipboardEvent{Text: "hello"}) {
t.Error("first new text should be accepted")
}
if len(*got) != 1 || (*got)[0] != "hello" {
t.Errorf("uploads = %v, want [hello]", *got)
}
}
func TestMonitor_DropsSensitive(t *testing.T) {
got, sink := newCollector()
m := NewMonitor(sink)
if m.Observe(ClipboardEvent{Text: "hunter2", Sensitive: true}) {
t.Error("sensitive content must not be accepted")
}
if len(*got) != 0 {
t.Errorf("uploads = %v, want none", *got)
}
}
func TestMonitor_DropsEmpty(t *testing.T) {
got, sink := newCollector()
m := NewMonitor(sink)
if m.Observe(ClipboardEvent{Text: ""}) {
t.Error("empty text must not be accepted")
}
if len(*got) != 0 {
t.Errorf("uploads = %v, want none", *got)
}
}
func TestMonitor_DedupsRepeats(t *testing.T) {
got, sink := newCollector()
m := NewMonitor(sink)
m.Observe(ClipboardEvent{Text: "same"})
if m.Observe(ClipboardEvent{Text: "same"}) {
t.Error("identical repeat should be dropped")
}
if len(*got) != 1 {
t.Errorf("uploads = %v, want one", *got)
}
// a different value re-arms uploads
if !m.Observe(ClipboardEvent{Text: "other"}) {
t.Error("changed text should be accepted")
}
}
func TestMonitor_LoopGuardSuppressesOwnEcho(t *testing.T) {
got, sink := newCollector()
m := NewMonitor(sink)
// We write "from-cloud" locally, then the OS reports that same change.
m.WillWrite("from-cloud")
if m.Observe(ClipboardEvent{Text: "from-cloud"}) {
t.Error("our own write echo must not be re-uploaded")
}
if len(*got) != 0 {
t.Errorf("uploads = %v, want none (echo suppressed)", *got)
}
// A genuinely new copy afterwards still uploads.
if !m.Observe(ClipboardEvent{Text: "typed-by-user"}) {
t.Error("subsequent real change should be accepted")
}
if len(*got) != 1 || (*got)[0] != "typed-by-user" {
t.Errorf("uploads = %v, want [typed-by-user]", *got)
}
}
// fakeSource feeds a fixed list of events into Watch then closes.
type fakeSource struct {
events []ClipboardEvent
}
func (f *fakeSource) Watch(ctx context.Context) <-chan ClipboardEvent {
ch := make(chan ClipboardEvent)
go func() {
defer close(ch)
for _, ev := range f.events {
select {
case <-ctx.Done():
return
case ch <- ev:
}
}
}()
return ch
}
func (f *fakeSource) Write(string) error { return nil }
func TestRun_DrivesSourceThroughMonitor(t *testing.T) {
got, sink := newCollector()
m := NewMonitor(sink)
src := &fakeSource{events: []ClipboardEvent{
{Text: "a"},
{Text: "secret", Sensitive: true},
{Text: "a"}, // dedup
{Text: "b"},
}}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
Run(ctx, src, m)
want := []string{"a", "b"}
if len(*got) != len(want) || (*got)[0] != "a" || (*got)[1] != "b" {
t.Errorf("uploads = %v, want %v", *got, want)
}
}
// recordingSource records Write calls; Watch is unused for the download tests.
type recordingSource struct {
writes []string
err error
}
func (r *recordingSource) Watch(context.Context) <-chan ClipboardEvent {
ch := make(chan ClipboardEvent)
close(ch)
return ch
}
func (r *recordingSource) Write(text string) error {
if r.err != nil {
return r.err
}
r.writes = append(r.writes, text)
return nil
}
func TestApplyRemote_WritesNewContent(t *testing.T) {
src := &recordingSource{}
m := NewMonitor(func(string) {})
if !m.ApplyRemote(src, ClipboardContent{Content: "hi", SourceDevice: "other"}, "me") {
t.Error("new remote content should be written locally")
}
if len(src.writes) != 1 || src.writes[0] != "hi" {
t.Errorf("writes = %v, want [hi]", src.writes)
}
}
func TestApplyRemote_SkipsSelfSource(t *testing.T) {
src := &recordingSource{}
m := NewMonitor(func(string) {})
if m.ApplyRemote(src, ClipboardContent{Content: "hi", SourceDevice: "me"}, "me") {
t.Error("our own echoed upload must be skipped")
}
if len(src.writes) != 0 {
t.Errorf("writes = %v, want none", src.writes)
}
}
func TestApplyRemote_SkipsEmptyAndCurrent(t *testing.T) {
src := &recordingSource{}
m := NewMonitor(func(string) {})
if m.ApplyRemote(src, ClipboardContent{Content: "", SourceDevice: "other"}, "me") {
t.Error("empty remote content must be skipped")
}
m.Observe(ClipboardEvent{Text: "X"}) // lastHash now = hash(X)
if m.ApplyRemote(src, ClipboardContent{Content: "X", SourceDevice: "other"}, "me") {
t.Error("content equal to current must be skipped")
}
}
// The full loop guard across both directions: apply a remote change, then the
// Source's resulting local change event must not be re-uploaded.
func TestApplyRemote_LoopGuardAcrossObserve(t *testing.T) {
src := &recordingSource{}
var uploads []string
m := NewMonitor(func(text string) { uploads = append(uploads, text) })
m.ApplyRemote(src, ClipboardContent{Content: "cloud", SourceDevice: "other"}, "me")
if m.Observe(ClipboardEvent{Text: "cloud"}) {
t.Error("echo of an applied-remote write must be suppressed")
}
if len(uploads) != 0 {
t.Errorf("uploads = %v, want none", uploads)
}
}
+67
View File
@@ -0,0 +1,67 @@
//go:build windows
package platform
import (
"context"
"golang.design/x/clipboard"
)
// windowsClipboard is the Windows Source backed by golang.design/x/clipboard
// (pure syscall, no cgo): it watches the clipboard for local copies via
// GetClipboardSequenceNumber polling and writes plain text back.
type windowsClipboard struct {
ready bool
}
// NewClipboardSource returns the platform clipboard Source (Windows). If the
// clipboard can't be initialised it degrades to an inert source so the rest of
// the sync wiring still runs.
func NewClipboardSource() Source {
return &windowsClipboard{ready: clipboard.Init() == nil}
}
func (c *windowsClipboard) Watch(ctx context.Context) <-chan ClipboardEvent {
out := make(chan ClipboardEvent)
go func() {
defer close(out)
if !c.ready {
<-ctx.Done()
return
}
changes := clipboard.Watch(ctx, clipboard.FmtText)
for {
select {
case <-ctx.Done():
return
case data, ok := <-changes:
if !ok {
return
}
if data.Format != clipboard.FmtText || len(data.Bytes) == 0 {
continue
}
// golang.design/x/clipboard surfaces only the text — the Windows
// privacy formats (ExcludeClipboardContentFromMonitorProcessing /
// CanUploadToCloudClipboard) aren't exposed, so Sensitive stays
// false and the server's 5-min TTL is the backstop. A raw
// EnumClipboardFormats check is a possible follow-up.
select {
case out <- ClipboardEvent{Text: string(data.Bytes)}:
case <-ctx.Done():
return
}
}
}
}()
return out
}
func (c *windowsClipboard) Write(text string) error {
if !c.ready {
return nil
}
clipboard.Write(clipboard.FmtText, []byte(text))
return nil
}
+56
View File
@@ -0,0 +1,56 @@
package platform
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
)
// FetchOAuthConfig pulls the provider coordinates from the cdrop backend's
// /api/auth/config. The desktop reuses the same OAuth client as the web app
// (the client_id published there) and runs its own loopback PKCE flow against
// the provider, instead of the browser's in-page redirect + /api/auth/exchange
// proxy. apiBase is the backend origin, e.g. https://drop.commilitia.net.
func FetchOAuthConfig(ctx context.Context, apiBase string) (OAuthConfig, error) {
endpoint := strings.TrimRight(apiBase, "/") + "/api/auth/config"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return OAuthConfig{}, fmt.Errorf("oauth: build config request: %w", err)
}
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return OAuthConfig{}, fmt.Errorf("oauth: fetch config: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return OAuthConfig{}, fmt.Errorf("oauth: config endpoint status %d", resp.StatusCode)
}
var c struct {
AuthorizeURL string `json:"authorize_url"`
TokenURL string `json:"token_url"`
ClientID string `json:"client_id"`
Scopes string `json:"scopes"`
}
if err := json.NewDecoder(resp.Body).Decode(&c); err != nil {
return OAuthConfig{}, fmt.Errorf("oauth: decode config: %w", err)
}
cfg := OAuthConfig{
AuthorizeURL: c.AuthorizeURL,
TokenURL: c.TokenURL,
ClientID: c.ClientID,
Scopes: c.Scopes,
}
if cfg.AuthorizeURL == "" || cfg.TokenURL == "" || cfg.ClientID == "" {
return OAuthConfig{}, errors.New("oauth: backend config missing authorize_url / token_url / client_id")
}
return cfg, nil
}
+69
View File
@@ -0,0 +1,69 @@
package platform
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestFetchOAuthConfig_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/auth/config" {
t.Errorf("path = %s, want /api/auth/config", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"auth_mode": "prod",
"authorize_url": "https://casdoor.example/login/oauth/authorize",
"token_url": "https://casdoor.example/api/login/oauth/access_token",
"client_id": "web-client-id",
"redirect_uri": "https://drop.example/oauth/callback",
"scopes": "openid profile groups",
})
}))
defer srv.Close()
// trailing slash on apiBase must be tolerated
cfg, err := FetchOAuthConfig(context.Background(), srv.URL+"/")
if err != nil {
t.Fatalf("FetchOAuthConfig: %v", err)
}
if cfg.ClientID != "web-client-id" {
t.Errorf("client_id = %q, want web-client-id (reused web client)", cfg.ClientID)
}
if cfg.TokenURL != "https://casdoor.example/api/login/oauth/access_token" {
t.Errorf("token_url = %q", cfg.TokenURL)
}
if cfg.Scopes != "openid profile groups" {
t.Errorf("scopes = %q", cfg.Scopes)
}
}
func TestFetchOAuthConfig_Incomplete(t *testing.T) {
// backend missing token_url / client_id (e.g. dev mode) must be rejected.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"auth_mode": "dev",
"authorize_url": "https://casdoor.example/login/oauth/authorize",
})
}))
defer srv.Close()
if _, err := FetchOAuthConfig(context.Background(), srv.URL); err == nil {
t.Fatal("want error for incomplete backend config, got nil")
}
}
func TestFetchOAuthConfig_BadStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
if _, err := FetchOAuthConfig(context.Background(), srv.URL); err == nil {
t.Fatal("want error for 500 status, got nil")
}
}
+112
View File
@@ -0,0 +1,112 @@
package platform
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)
// SaveDownload writes a received file's bytes into dir (empty → system Downloads),
// returning the absolute path written. The name comes from a transfer peer (U2,
// untrusted), so it is sanitized to a bare filename before use — no path
// components survive, so a crafted name like "../../x" cannot escape dir. On a
// name collision a " (n)" suffix is appended rather than overwriting. After a
// successful write the file is best-effort revealed in the OS file manager.
//
// If the configured dir is unwritable (deleted, no permission, read-only), the
// write is retried into the system Downloads dir before giving up — the WebView
// has no usable download fallback (WKWebView ignores blob a[download]), so a
// received file must never be silently lost to a bad setting. The returned path
// reflects where the file actually landed, so the UI can show it.
func SaveDownload(dir, name string, data []byte) (string, error) {
if dir == "" {
dir = DefaultDownloadDir()
}
path, err := writeInto(dir, name, data)
if err == nil {
return path, nil
}
if def := DefaultDownloadDir(); def != dir {
if p, ferr := writeInto(def, name, data); ferr == nil {
return p, nil
}
}
return "", err // report the original (configured-dir) failure
}
// writeInto sanitizes the name, resolves a non-colliding path under dir, writes
// the bytes, and reveals the result. Returns the absolute path written.
func writeInto(dir, name string, data []byte) (string, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
safe := sanitizeFileName(name)
if safe == "" {
safe = "cdrop-download"
}
target := uniquePath(dir, safe)
if err := os.WriteFile(target, data, 0o644); err != nil {
return "", err
}
revealInFileManager(target)
return target, nil
}
// sanitizeFileName reduces a peer-supplied name to a single safe filename:
// directory components are dropped (path-traversal defense), control chars are
// removed, and characters reserved by common filesystems are replaced with "_".
func sanitizeFileName(name string) string {
name = filepath.Base(filepath.FromSlash(name)) // strip any directory parts
name = strings.TrimSpace(name)
if name == "." || name == ".." {
return ""
}
var b strings.Builder
for _, r := range name {
switch {
case r < 0x20:
continue // control characters
case strings.ContainsRune(`/\:*?"<>|`, r):
b.WriteRune('_') // filesystem-reserved
default:
b.WriteRune(r)
}
}
out := strings.TrimSpace(b.String())
if len(out) > 200 {
out = strings.TrimSpace(out[:200])
}
return out
}
// uniquePath returns dir/name, or dir/"name (n).ext" with the lowest n that does
// not yet exist, so a repeated transfer never silently overwrites a prior file.
func uniquePath(dir, name string) string {
target := filepath.Join(dir, name)
if _, err := os.Stat(target); os.IsNotExist(err) {
return target
}
ext := filepath.Ext(name)
stem := strings.TrimSuffix(name, ext)
for i := 1; ; i += 1 {
cand := filepath.Join(dir, fmt.Sprintf("%s (%d)%s", stem, i, ext))
if _, err := os.Stat(cand); os.IsNotExist(err) {
return cand
}
}
}
// revealInFileManager opens the OS file manager with the saved file selected.
// Best-effort: the save already succeeded, so any error here is ignored.
func revealInFileManager(path string) {
switch runtime.GOOS {
case "darwin":
_ = exec.Command("open", "-R", path).Start()
case "windows":
_ = exec.Command("explorer", "/select,"+path).Start()
}
}
+104
View File
@@ -0,0 +1,104 @@
package platform
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
)
// UserInfo is the display identity the WebView store needs (mirrors the web
// app's User shape: id / name / avatar).
type UserInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Avatar string `json:"avatar"`
}
// LoginResult is the full result Go keeps internally: tokens + identity. The
// refresh_token never crosses into the WebView — see SessionView and the
// credential policy in session.go.
type LoginResult struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
User UserInfo `json:"user"`
}
// SessionView is the refresh-token-free projection handed to the WebView (login
// emit, refresh return, boot injection). The refresh_token — the long-lived
// secret — is deliberately omitted: it lives only in the Go process and the
// on-disk session, never in JS or the injected HTML.
type SessionView struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
User UserInfo `json:"user"`
}
// View projects a LoginResult to the refresh-free SessionView.
func (r LoginResult) View() SessionView {
return SessionView{AccessToken: r.AccessToken, ExpiresIn: r.ExpiresIn, User: r.User}
}
// UserFromToken extracts the display identity from OIDC tokens WITHOUT verifying
// the signature. This mirrors the backend's extractUser (internal/httpapi/auth.go)
// exactly: prefer the id_token's richer claims, fall back to the access_token;
// name priority preferred_username → name → email → sub; avatar avatar → picture.
//
// Skipping verification is safe for the same reason it is on the backend: the
// auth middleware verifies the access_token on every API call via JWKS, so any
// tamper surfaces there. These claims only drive local display.
func UserFromToken(idToken, accessToken string) (UserInfo, error) {
pick := idToken
if pick == "" {
pick = accessToken
}
claims, err := decodeJWTClaims(pick)
if err != nil {
return UserInfo{}, err
}
u := UserInfo{ID: claimString(claims, "sub")}
for _, k := range []string{"preferred_username", "name", "email"} {
if v := claimString(claims, k); v != "" {
u.Name = v
break
}
}
if u.Name == "" {
u.Name = u.ID
}
for _, k := range []string{"avatar", "picture"} {
if v := claimString(claims, k); v != "" {
u.Avatar = v
break
}
}
return u, nil
}
// decodeJWTClaims base64url-decodes the JWT payload segment and unmarshals it.
// No signature check (see UserFromToken).
func decodeJWTClaims(token string) (map[string]any, error) {
parts := strings.Split(token, ".")
if len(parts) < 2 {
return nil, errors.New("oauth: token is not a JWT")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("oauth: decode jwt payload: %w", err)
}
var claims map[string]any
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, fmt.Errorf("oauth: parse jwt claims: %w", err)
}
return claims, nil
}
func claimString(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}
+94
View File
@@ -0,0 +1,94 @@
package platform
import (
"encoding/base64"
"encoding/json"
"testing"
)
// makeJWT builds a syntactically valid (unsigned) JWT carrying the given claims.
// UserFromToken never verifies the signature, so a dummy header + sig suffice.
func makeJWT(claims map[string]any) string {
payload, _ := json.Marshal(claims)
seg := base64.RawURLEncoding.EncodeToString(payload)
return "eyJhbGciOiJSUzI1NiJ9." + seg + ".sig"
}
func TestUserFromTokenPrefersIDToken(t *testing.T) {
idTok := makeJWT(map[string]any{"sub": "u-1", "preferred_username": "alice"})
accessTok := makeJWT(map[string]any{"sub": "u-1", "name": "bob"})
u, err := UserFromToken(idTok, accessTok)
if err != nil {
t.Fatalf("UserFromToken: %v", err)
}
if u.ID != "u-1" {
t.Errorf("id: got %q, want u-1", u.ID)
}
if u.Name != "alice" {
t.Errorf("name should come from id_token preferred_username, got %q", u.Name)
}
}
func TestUserFromTokenNamePriority(t *testing.T) {
cases := []struct {
name string
claims map[string]any
want string
}{
{"preferred_username wins", map[string]any{"sub": "s", "preferred_username": "p", "name": "n", "email": "e"}, "p"},
{"name when no preferred", map[string]any{"sub": "s", "name": "n", "email": "e"}, "n"},
{"email when no name", map[string]any{"sub": "s", "email": "e@x"}, "e@x"},
{"sub as last resort", map[string]any{"sub": "s"}, "s"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
u, err := UserFromToken(makeJWT(c.claims), "")
if err != nil {
t.Fatalf("UserFromToken: %v", err)
}
if u.Name != c.want {
t.Errorf("name: got %q, want %q", u.Name, c.want)
}
})
}
}
func TestUserFromTokenAvatarPriority(t *testing.T) {
u, _ := UserFromToken(makeJWT(map[string]any{
"sub": "s", "avatar": "a.png", "picture": "p.png",
}), "")
if u.Avatar != "a.png" {
t.Errorf("avatar should prefer 'avatar' claim, got %q", u.Avatar)
}
u2, _ := UserFromToken(makeJWT(map[string]any{"sub": "s", "picture": "p.png"}), "")
if u2.Avatar != "p.png" {
t.Errorf("avatar should fall back to 'picture', got %q", u2.Avatar)
}
u3, _ := UserFromToken(makeJWT(map[string]any{"sub": "s"}), "")
if u3.Avatar != "" {
t.Errorf("avatar should be empty when no claim, got %q", u3.Avatar)
}
}
func TestUserFromTokenFallsBackToAccessToken(t *testing.T) {
accessTok := makeJWT(map[string]any{"sub": "u-9", "name": "carol"})
u, err := UserFromToken("", accessTok)
if err != nil {
t.Fatalf("UserFromToken: %v", err)
}
if u.ID != "u-9" || u.Name != "carol" {
t.Errorf("fallback to access_token failed: %+v", u)
}
}
func TestUserFromTokenRejectsGarbage(t *testing.T) {
if _, err := UserFromToken("not-a-jwt", ""); err == nil {
t.Error("expected error for non-JWT token")
}
if _, err := UserFromToken("a.!!!notbase64!!!.c", ""); err == nil {
t.Error("expected error for undecodable payload")
}
}
+117
View File
@@ -0,0 +1,117 @@
//go:build darwin
package platform
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// launchAgentLabel is the reverse-DNS label + plist filename for the per-user
// LaunchAgent that starts cdrop at login.
const launchAgentLabel = "net.commilitia.cdrop"
func launchAgentPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, "Library", "LaunchAgents", launchAgentLabel+".plist"), nil
}
// launchTarget returns the program the LaunchAgent should run. For a packaged
// .app it launches the bundle via `open` (proper LaunchServices activation);
// in a bare dev binary it runs the executable directly. Either way it appends
// --hidden so a login launch comes up to the menu bar with no window (see
// launchedHidden in main); `open --args` forwards the rest as the app's argv.
func launchTarget() ([]string, error) {
exe, err := os.Executable()
if err != nil {
return nil, err
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
if bundle := appBundlePath(exe); bundle != "" {
return []string{"/usr/bin/open", bundle, "--args", "--hidden"}, nil
}
return []string{exe, "--hidden"}, nil
}
// appBundlePath returns the enclosing .app path if exe lives inside one
// (…/Foo.app/Contents/MacOS/binary), else "".
func appBundlePath(exe string) string {
const marker = ".app/Contents/MacOS/"
if i := strings.LastIndex(exe, marker); i != -1 {
return exe[:i+len(".app")]
}
return ""
}
// SetLaunchAtLogin installs or removes the LaunchAgent plist. Removing a missing
// agent is a no-op success.
func SetLaunchAtLogin(enabled bool) error {
p, err := launchAgentPath()
if err != nil {
return err
}
if !enabled {
if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
args, err := launchTarget()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err
}
return os.WriteFile(p, []byte(buildLaunchAgentPlist(launchAgentLabel, args)), 0o644)
}
// IsLaunchAtLoginEnabled reports whether the LaunchAgent plist is present.
func IsLaunchAtLoginEnabled() bool {
p, err := launchAgentPath()
if err != nil {
return false
}
_, err = os.Stat(p)
return err == nil
}
func buildLaunchAgentPlist(label string, programArgs []string) string {
var args strings.Builder
for _, a := range programArgs {
fmt.Fprintf(&args, "\n <string>%s</string>", xmlEscape(a))
}
return fmt.Sprintf(`<?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>Label</key>
<string>%s</string>
<key>ProgramArguments</key>
<array>%s
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
`, xmlEscape(label), args.String())
}
func xmlEscape(s string) string {
r := strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
"'", "&apos;",
)
return r.Replace(s)
}
@@ -0,0 +1,66 @@
//go:build darwin
package platform
import (
"os"
"strings"
"testing"
)
func TestSetLaunchAtLoginInstallsAndRemoves(t *testing.T) {
t.Setenv("HOME", t.TempDir())
if IsLaunchAtLoginEnabled() {
t.Fatal("should start disabled")
}
if err := SetLaunchAtLogin(true); err != nil {
t.Fatalf("enable: %v", err)
}
if !IsLaunchAtLoginEnabled() {
t.Error("should be enabled after install")
}
p, _ := launchAgentPath()
data, err := os.ReadFile(p)
if err != nil {
t.Fatalf("read plist: %v", err)
}
plist := string(data)
if !strings.Contains(plist, "<key>Label</key>") ||
!strings.Contains(plist, launchAgentLabel) ||
!strings.Contains(plist, "<key>RunAtLoad</key>") {
t.Errorf("plist missing expected keys:\n%s", plist)
}
// Disabling removes the file; removing again is a no-op success.
if err := SetLaunchAtLogin(false); err != nil {
t.Fatalf("disable: %v", err)
}
if IsLaunchAtLoginEnabled() {
t.Error("should be disabled after removal")
}
if err := SetLaunchAtLogin(false); err != nil {
t.Errorf("removing an absent agent should be a no-op, got %v", err)
}
}
func TestAppBundlePath(t *testing.T) {
cases := map[string]string{
"/Apps/cdrop.app/Contents/MacOS/desktop": "/Apps/cdrop.app",
"/usr/local/bin/desktop": "",
}
for in, want := range cases {
if got := appBundlePath(in); got != want {
t.Errorf("appBundlePath(%q) = %q, want %q", in, got, want)
}
}
}
func TestXMLEscape(t *testing.T) {
got := xmlEscape(`a&b<c>"d"`)
if !strings.Contains(got, "&amp;") || !strings.Contains(got, "&lt;") || !strings.Contains(got, "&quot;") {
t.Errorf("xmlEscape did not escape specials: %q", got)
}
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !darwin && !windows
package platform
// Launch-at-login on Windows/Linux uses different mechanisms (registry Run key /
// XDG autostart .desktop) and lands with those platform targets. Stubbed so the
// settings wiring compiles everywhere.
// SetLaunchAtLogin is a no-op outside macOS.
func SetLaunchAtLogin(bool) error { return nil }
// IsLaunchAtLoginEnabled always reports false outside macOS.
func IsLaunchAtLoginEnabled() bool { return false }
+53
View File
@@ -0,0 +1,53 @@
//go:build windows
package platform
import (
"errors"
"os"
"golang.org/x/sys/windows/registry"
)
const (
// runKeyPath is the per-user autostart key — entries here launch at sign-in
// without elevation (HKLM would need admin).
runKeyPath = `Software\Microsoft\Windows\CurrentVersion\Run`
runValueName = "Commilitia Drop"
)
// SetLaunchAtLogin adds or removes the per-user Run registry entry that starts
// the app at sign-in. The value is the quoted executable path (so a Program
// Files path with spaces stays one argument) followed by --hidden, so a login
// launch comes up to the tray with no window (see launchedHidden in main).
func SetLaunchAtLogin(enabled bool) error {
k, _, err := registry.CreateKey(registry.CURRENT_USER, runKeyPath, registry.SET_VALUE)
if err != nil {
return err
}
defer k.Close()
if !enabled {
if err := k.DeleteValue(runValueName); err != nil && !errors.Is(err, registry.ErrNotExist) {
return err
}
return nil
}
exe, err := os.Executable()
if err != nil {
return err
}
return k.SetStringValue(runValueName, `"`+exe+`" --hidden`)
}
// IsLaunchAtLoginEnabled reports whether the Run entry is present.
func IsLaunchAtLoginEnabled() bool {
k, err := registry.OpenKey(registry.CURRENT_USER, runKeyPath, registry.QUERY_VALUE)
if err != nil {
return false
}
defer k.Close()
_, _, err = k.GetStringValue(runValueName)
return err == nil
}
+68
View File
@@ -0,0 +1,68 @@
package platform
import (
"fmt"
"net"
"net/http"
)
// StartLocalAPIServer runs the /api reverse proxy on a loopback HTTP listener and
// returns its base URL (e.g. http://127.0.0.1:54321).
//
// Why: on Windows, Wails' custom-scheme AssetServer buffers a handler's response
// until the handler RETURNS, so a long-lived SSE (/api/hub/events, whose handler
// never returns) never reaches the WebView — the device list never loads and the
// status sticks on "connecting". A real loopback HTTP origin is served by
// WebView2's normal network stack, which streams response bodies incrementally.
//
// The page reaches this via the injected api_base; since its origin differs from
// the listener, CORS is added. No credentials/cookies are used (auth is a Bearer
// header), so a permissive policy is safe. macOS (WKWebView) streams the custom
// scheme fine and doesn't use this.
func StartLocalAPIServer(apiBase string) (string, error) {
proxy, err := NewAPIProxy(apiBase)
if err != nil {
return "", err
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", fmt.Errorf("local api: listen: %w", err)
}
srv := &http.Server{Handler: corsWrap(proxy)}
go func() { _ = srv.Serve(ln) }()
return "http://" + ln.Addr().String(), nil
}
// corsWrap adds permissive CORS so the WebView page (a different origin than this
// loopback listener) can fetch /api, and answers the preflight. Includes the
// Private Network Access ack since the page origin is also loopback.
func corsWrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin == "" {
origin = "*"
}
h := w.Header()
h.Set("Access-Control-Allow-Origin", origin)
h.Add("Vary", "Origin")
h.Add("Vary", "Access-Control-Request-Headers")
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
// Echo the requested headers so every client header is allowed without
// maintaining a list — the app sends Authorization, X-Device-Name,
// X-Device-Type, and conditional headers like If-None-Match (clipboard
// caching). Missing one fails the preflight as a "Failed to fetch".
if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" {
h.Set("Access-Control-Allow-Headers", reqHeaders)
} else {
h.Set("Access-Control-Allow-Headers",
"Authorization, Content-Type, X-Device-Name, X-Device-Type, X-Dev-User, If-None-Match")
}
h.Set("Access-Control-Allow-Private-Network", "true")
h.Set("Access-Control-Max-Age", "600")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

+264
View File
@@ -0,0 +1,264 @@
// Package platform implements cdrop desktop's native capabilities — the Go
// shell that the shared web UI drives over Wails bindings.
//
// This file owns the OAuth login flow: an RFC 8252 loopback redirect against
// Casdoor with PKCE as a public client (no secret). The authorization code
// exchange happens here in Go on purpose, so the PKCE verifier and the
// resulting tokens never enter the WebView / JS context.
package platform
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// OAuthConfig carries the provider coordinates. For Casdoor the token endpoint
// is /api/login/oauth/access_token (non-standard) and authorize is
// /login/oauth/authorize; the desktop registers as its own public client.
type OAuthConfig struct {
AuthorizeURL string
TokenURL string
ClientID string
Scopes string // space-separated; empty defaults to "openid profile groups"
}
// TokenResult is the subset of the token response the shell keeps. IDToken is
// captured so the identity can be decoded from the richer id_token claims (it's
// present when the openid scope is granted — our default scopes include it).
type TokenResult struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}
// Flow runs one interactive login. openURL is injected so production uses
// Wails' runtime.BrowserOpenURL while tests substitute a fake user-agent.
type Flow struct {
cfg OAuthConfig
openURL func(string)
client *http.Client
timeout time.Duration
}
// NewFlow builds a Flow with sane defaults: a 30s HTTP client for the token
// exchange and a 3-minute ceiling on the whole interactive login.
func NewFlow(cfg OAuthConfig, openURL func(string)) *Flow {
return &Flow{
cfg: cfg,
openURL: openURL,
client: &http.Client{Timeout: 30 * time.Second},
timeout: 3 * time.Minute,
}
}
// Login performs the loopback PKCE flow and returns the exchanged tokens.
//
// Steps: generate verifier/challenge/state → bind an ephemeral 127.0.0.1
// listener → open the system browser at the authorize URL → wait for the
// browser to hit the loopback /callback with the code → validate state →
// exchange code+verifier at the token endpoint. The verifier never leaves Go.
func (f *Flow) Login(ctx context.Context) (*TokenResult, error) {
if f.cfg.AuthorizeURL == "" || f.cfg.TokenURL == "" || f.cfg.ClientID == "" {
return nil, errors.New("oauth: incomplete config (authorize_url / token_url / client_id)")
}
verifier, challenge, err := newPKCE()
if err != nil {
return nil, fmt.Errorf("oauth: pkce: %w", err)
}
state, err := randString(24)
if err != nil {
return nil, fmt.Errorf("oauth: state: %w", err)
}
// RFC 8252 §7.3: bind the IPv4 loopback literal (not "localhost") on an
// OS-assigned ephemeral port.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, fmt.Errorf("oauth: loopback listen: %w", err)
}
defer ln.Close()
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", ln.Addr().(*net.TCPAddr).Port)
type cbResult struct {
code string
err error
}
resCh := make(chan cbResult, 1)
send := func(r cbResult) {
// non-blocking: only the first callback wins, later ones are dropped.
select {
case resCh <- r:
default:
}
}
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)
send(cbResult{err: fmt.Errorf("oauth: authorization denied: %s", e)})
return
}
if q.Get("state") != state {
writeClosePage(w, false)
send(cbResult{err: errors.New("oauth: state mismatch (possible CSRF)")})
return
}
code := q.Get("code")
if code == "" {
writeClosePage(w, false)
send(cbResult{err: errors.New("oauth: callback missing code")})
return
}
writeClosePage(w, true)
send(cbResult{code: code})
})
srv := &http.Server{Handler: mux}
go func() { _ = srv.Serve(ln) }()
defer srv.Close()
authURL := f.cfg.AuthorizeURL + "?" + url.Values{
"response_type": {"code"},
"client_id": {f.cfg.ClientID},
"redirect_uri": {redirectURI},
"scope": {f.scopes()},
"state": {state},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}.Encode()
f.openURL(authURL)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(f.timeout):
return nil, errors.New("oauth: login timed out")
case res := <-resCh:
if res.err != nil {
return nil, res.err
}
return f.exchange(ctx, res.code, verifier, redirectURI)
}
}
func (f *Flow) scopes() string {
if s := strings.TrimSpace(f.cfg.Scopes); s != "" {
return s
}
return "openid profile groups"
}
// exchange trades the authorization code + PKCE verifier for tokens. Casdoor
// accepts form-encoded public-client requests (no client_secret). authorize and
// token must carry the identical redirect_uri, so we pass the same value.
func (f *Flow) exchange(ctx context.Context, code, verifier, redirectURI string) (*TokenResult, error) {
return f.postToken(ctx, url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"code_verifier": {verifier},
"client_id": {f.cfg.ClientID},
"redirect_uri": {redirectURI},
})
}
// Refresh exchanges a refresh_token for a fresh token (public client, no
// secret). Call it when the access token nears expiry; the access token never
// leaves Go, so refresh is driven from the shell, not the WebView.
func (f *Flow) Refresh(ctx context.Context, refreshToken string) (*TokenResult, error) {
if f.cfg.TokenURL == "" || f.cfg.ClientID == "" {
return nil, errors.New("oauth: incomplete config (token_url / client_id)")
}
if refreshToken == "" {
return nil, errors.New("oauth: empty refresh token")
}
return f.postToken(ctx, url.Values{
"grant_type": {"refresh_token"},
"refresh_token": {refreshToken},
"client_id": {f.cfg.ClientID},
"scope": {f.scopes()},
})
}
// postToken POSTs a form-encoded request to the token endpoint and decodes the
// response. Shared by the authorization-code exchange and refresh.
func (f *Flow) postToken(ctx context.Context, form url.Values) (*TokenResult, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, f.cfg.TokenURL, strings.NewReader(form.Encode()))
if err != nil {
return nil, fmt.Errorf("oauth: build token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := f.client.Do(req)
if err != nil {
return nil, fmt.Errorf("oauth: token request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("oauth: token endpoint status %d", resp.StatusCode)
}
var tok TokenResult
if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil {
return nil, fmt.Errorf("oauth: decode token: %w", err)
}
if tok.AccessToken == "" {
return nil, errors.New("oauth: token response missing access_token")
}
return &tok, nil
}
// --- PKCE (RFC 7636) ---
func newPKCE() (verifier, challenge string, err error) {
verifier, err = randString(64) // 43128 chars required; 64 is comfortable
if err != nil {
return "", "", err
}
sum := sha256.Sum256([]byte(verifier))
challenge = base64.RawURLEncoding.EncodeToString(sum[:])
return verifier, challenge, nil
}
// randString returns n characters of base64url-encoded crypto-random data. The
// base64url alphabet (AZ az 09 - _) is a subset of the PKCE unreserved set,
// so the output is a valid code_verifier / state.
func randString(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
s := base64.RawURLEncoding.EncodeToString(b)
if len(s) > n {
s = s[:n]
}
return s, nil
}
// writeClosePage is what the browser tab lands on after the redirect. The tab
// usually can't be closed by script (it wasn't script-opened), so the text is
// the real affordance; the close attempt is best-effort.
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:system-ui,sans-serif;text-align:center;margin-top:20vh">`+
`<p>%s</p><script>setTimeout(function(){window.close();},800);</script></body></html>`, msg)
}
+187
View File
@@ -0,0 +1,187 @@
package platform
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestNewPKCE(t *testing.T) {
v, c, err := newPKCE()
if err != nil {
t.Fatalf("newPKCE: %v", err)
}
if len(v) != 64 {
t.Errorf("verifier length = %d, want 64", len(v))
}
sum := sha256.Sum256([]byte(v))
want := base64.RawURLEncoding.EncodeToString(sum[:])
if c != want {
t.Errorf("challenge = %q, want S256(verifier) = %q", c, want)
}
// two calls must differ
v2, _, _ := newPKCE()
if v == v2 {
t.Error("two verifiers collided")
}
}
// TestLogin_Success drives the whole loopback flow with a fake Casdoor token
// endpoint and a fake browser, with no real network or prod dependency.
func TestLogin_Success(t *testing.T) {
var gotForm url.Values
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
t.Errorf("token endpoint ParseForm: %v", err)
}
gotForm = r.PostForm
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "at-123",
"refresh_token": "rt-456",
"expires_in": 3600,
"token_type": "Bearer",
})
}))
defer tokenSrv.Close()
cfg := OAuthConfig{
AuthorizeURL: "https://casdoor.example/login/oauth/authorize",
TokenURL: tokenSrv.URL,
ClientID: "cdrop-desktop",
}
// Fake browser: parse the authorize URL, assert it carries PKCE, then GET
// the loopback redirect with a code + the same state (authorized).
openURL := func(authURL string) {
u, err := url.Parse(authURL)
if err != nil {
t.Errorf("parse authURL: %v", err)
return
}
q := u.Query()
if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" {
t.Errorf("authorize request missing PKCE challenge: %v", q)
}
if q.Get("client_id") != "cdrop-desktop" {
t.Errorf("authorize client_id = %q", q.Get("client_id"))
}
cb := q.Get("redirect_uri") + "?code=auth-code-xyz&state=" + url.QueryEscape(q.Get("state"))
resp, err := http.Get(cb)
if err != nil {
t.Errorf("callback GET: %v", err)
return
}
_ = resp.Body.Close()
}
tok, err := NewFlow(cfg, openURL).Login(context.Background())
if err != nil {
t.Fatalf("Login: %v", err)
}
if tok.AccessToken != "at-123" {
t.Errorf("access_token = %q, want at-123", tok.AccessToken)
}
if tok.RefreshToken != "rt-456" {
t.Errorf("refresh_token = %q, want rt-456", tok.RefreshToken)
}
if tok.ExpiresIn != 3600 {
t.Errorf("expires_in = %d, want 3600", tok.ExpiresIn)
}
// The exchange must carry the code + PKCE verifier and, as a public client,
// no client_secret.
if gotForm.Get("grant_type") != "authorization_code" {
t.Errorf("grant_type = %q", gotForm.Get("grant_type"))
}
if gotForm.Get("code") != "auth-code-xyz" {
t.Errorf("code = %q", gotForm.Get("code"))
}
if gotForm.Get("code_verifier") == "" {
t.Error("token exchange missing code_verifier")
}
if gotForm.Get("client_secret") != "" {
t.Error("public client must not send client_secret")
}
}
func TestLogin_StateMismatch(t *testing.T) {
cfg := OAuthConfig{
AuthorizeURL: "https://casdoor.example/login/oauth/authorize",
TokenURL: "https://casdoor.example/token",
ClientID: "cdrop-desktop",
}
openURL := func(authURL string) {
u, _ := url.Parse(authURL)
redirect := u.Query().Get("redirect_uri")
resp, err := http.Get(redirect + "?code=c&state=WRONG-STATE")
if err == nil {
_ = resp.Body.Close()
}
}
_, err := NewFlow(cfg, openURL).Login(context.Background())
if err == nil || !strings.Contains(err.Error(), "state mismatch") {
t.Fatalf("want state mismatch error, got %v", err)
}
}
func TestLogin_IncompleteConfig(t *testing.T) {
_, err := NewFlow(OAuthConfig{ClientID: "only-id"}, func(string) {}).Login(context.Background())
if err == nil {
t.Fatal("want error for incomplete config, got nil")
}
}
func TestRefresh_Success(t *testing.T) {
var gotForm url.Values
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
gotForm = r.PostForm
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "new-at",
"refresh_token": "new-rt",
"expires_in": 3600,
})
}))
defer tokenSrv.Close()
cfg := OAuthConfig{
AuthorizeURL: "https://casdoor.example/login/oauth/authorize",
TokenURL: tokenSrv.URL,
ClientID: "cdrop-desktop",
}
tok, err := NewFlow(cfg, func(string) {}).Refresh(context.Background(), "old-rt")
if err != nil {
t.Fatalf("Refresh: %v", err)
}
if tok.AccessToken != "new-at" {
t.Errorf("access_token = %q, want new-at", tok.AccessToken)
}
if gotForm.Get("grant_type") != "refresh_token" {
t.Errorf("grant_type = %q", gotForm.Get("grant_type"))
}
if gotForm.Get("refresh_token") != "old-rt" {
t.Errorf("refresh_token = %q", gotForm.Get("refresh_token"))
}
if gotForm.Get("client_secret") != "" {
t.Error("public client must not send client_secret on refresh")
}
}
func TestRefresh_EmptyToken(t *testing.T) {
cfg := OAuthConfig{
AuthorizeURL: "https://x/authorize",
TokenURL: "https://x/token",
ClientID: "c",
}
if _, err := NewFlow(cfg, func(string) {}).Refresh(context.Background(), ""); err == nil {
t.Fatal("want error for empty refresh token")
}
}
+55
View File
@@ -0,0 +1,55 @@
package platform
import (
"fmt"
"net/http"
"net/http/httputil"
"net/url"
"strings"
)
// NewAPIProxy returns an http.Handler suitable for Wails' AssetServer.Handler.
// It reverse-proxies every /api/* request to the remote cdrop backend so the
// embedded web UI keeps using same-origin relative URLs — no CORS, no API-base
// rewrite, no backend change.
//
// Why a proxy instead of cross-origin fetch: the WebView serves the bundle from
// a custom scheme, so a relative /api request never reaches the backend, and a
// cross-origin fetch to https://drop.commilitia.net would need server-side CORS
// the backend doesn't send. Proxying keeps the request same-origin end to end.
//
// SSE (/api/hub/events) streams correctly: FlushInterval=-1 flushes each chunk,
// and the darwin WebView ResponseWriter forwards every Write to the
// WKURLSchemeTask immediately (didReceiveData per write), so frames arrive live.
//
// apiBase is the backend origin, e.g. https://drop.commilitia.net. Requests
// that are not under /api/ get a 404 — desktop navigation is client-side, so the
// asset FS already answers "/" and deep-link misses don't happen in normal use.
func NewAPIProxy(apiBase string) (http.Handler, error) {
target, err := url.Parse(apiBase)
if err != nil {
return nil, fmt.Errorf("proxy: parse api base %q: %w", apiBase, err)
}
if target.Scheme == "" || target.Host == "" {
return nil, fmt.Errorf("proxy: api base %q missing scheme or host", apiBase)
}
proxy := httputil.NewSingleHostReverseProxy(target)
base := proxy.Director
proxy.Director = func(r *http.Request) {
base(r)
// The backend is vhosted behind Caddy; the Host header drives TLS SNI
// and request routing, so it must be the backend host, not the WebView's
// synthetic origin.
r.Host = target.Host
}
proxy.FlushInterval = -1 // immediate flush — SSE frames / streamed relay bodies
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
proxy.ServeHTTP(w, r)
return
}
http.NotFound(w, r)
}), nil
}
+145
View File
@@ -0,0 +1,145 @@
package platform
import (
"bufio"
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestProxyForwardsAPIWithBackendHost(t *testing.T) {
var gotPath, gotHost, gotAuth string
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotHost = r.Host
gotAuth = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "clip-body")
}))
defer backend.Close()
proxy, err := NewAPIProxy(backend.URL)
if err != nil {
t.Fatalf("NewAPIProxy: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/clipboard", nil)
req.Header.Set("Authorization", "Bearer tok-123")
rec := httptest.NewRecorder()
proxy.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200", rec.Code)
}
if gotPath != "/api/clipboard" {
t.Errorf("backend path: got %q", gotPath)
}
// Host must be rewritten to the backend (TLS SNI + vhost routing), not the
// WebView's synthetic origin.
if gotHost != backendHost(backend.URL) {
t.Errorf("backend Host header: got %q, want %q", gotHost, backendHost(backend.URL))
}
if gotAuth != "Bearer tok-123" {
t.Errorf("Authorization not forwarded: got %q", gotAuth)
}
if body := rec.Body.String(); body != "clip-body" {
t.Errorf("body: got %q", body)
}
}
func TestProxyNonAPIReturns404(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Error("backend should not be hit for non-/api path")
w.WriteHeader(http.StatusOK)
}))
defer backend.Close()
proxy, err := NewAPIProxy(backend.URL)
if err != nil {
t.Fatalf("NewAPIProxy: %v", err)
}
rec := httptest.NewRecorder()
proxy.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/settings", nil))
if rec.Code != http.StatusNotFound {
t.Errorf("non-/api status: got %d, want 404", rec.Code)
}
}
func TestProxyForwardsPUT(t *testing.T) {
var gotMethod, gotBody string
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
buf := make([]byte, r.ContentLength)
_, _ = r.Body.Read(buf)
gotBody = string(buf)
w.WriteHeader(http.StatusNoContent)
}))
defer backend.Close()
proxy, _ := NewAPIProxy(backend.URL)
req := httptest.NewRequest(http.MethodPut, "/api/clipboard", strings.NewReader("hello"))
rec := httptest.NewRecorder()
proxy.ServeHTTP(rec, req)
if gotMethod != http.MethodPut {
t.Errorf("method: got %q", gotMethod)
}
if gotBody != "hello" {
t.Errorf("body: got %q", gotBody)
}
}
// TestProxyStreamsSSE checks that the proxy relays an event-stream incrementally
// rather than buffering until the backend closes the connection. The backend
// holds the connection open after the first frame; the client must see that
// frame before the deadline.
func TestProxyStreamsSSE(t *testing.T) {
release := make(chan struct{})
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "event: clipboard:update\ndata: {}\n\n")
w.(http.Flusher).Flush()
<-release // keep the stream open like a real SSE channel
}))
defer backend.Close()
defer close(release)
proxy, _ := NewAPIProxy(backend.URL)
srv := httptest.NewServer(proxy)
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/api/hub/events", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET sse: %v", err)
}
defer resp.Body.Close()
done := make(chan string, 1)
go func() {
line, _ := bufio.NewReader(resp.Body).ReadString('\n')
done <- line
}()
select {
case line := <-done:
if !strings.Contains(line, "clipboard:update") {
t.Errorf("first streamed frame: got %q", line)
}
case <-time.After(1500 * time.Millisecond):
t.Fatal("SSE frame did not arrive incrementally (proxy buffered the stream)")
}
}
func backendHost(rawURL string) string {
// httptest URLs are http://host:port — strip the scheme.
return strings.TrimPrefix(rawURL, "http://")
}
+258
View File
@@ -0,0 +1,258 @@
package platform
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"io"
"log/slog"
"os"
"path/filepath"
keyring "github.com/zalando/go-keyring"
)
// Session persistence lives in a Go-owned file, NOT WebView storage: Wails serves
// the UI from the wails:// custom scheme, and WKWebView does not persist
// localStorage / sessionStorage for custom-scheme origins, so any in-page store
// is wiped on every restart. The persisted session is injected back into the
// page at load time (see SessionInjectMiddleware) so it's available synchronously
// without a binding round-trip.
//
// Credential policy (consistent with the browser): the refresh_token is the
// long-lived secret and is never exposed to the WebView/JS context. It lives only
// in the Go process and at rest; the WebView only ever receives the short-lived
// access_token (via SessionView). Refresh is Go-internal — the WebView calls
// Refresh() with no argument and Go uses its own copy.
//
// At rest the refresh_token is encrypted, not plaintext (R2). It is the one
// credential that lets a holder mint fresh access tokens indefinitely, so
// same-user malware must not be able to read it off disk. We can't store it in
// the OS secret store directly: Casdoor issues JWT refresh_tokens that run to
// ~15 KB, far past go-keyring's per-item limits (macOS ~3 KB command, Windows
// 2560 B). Instead we keep a 32-byte AES key in the secret store (tiny, no size
// issue on any platform) and write the AES-256-GCM ciphertext to the 0600 file.
// Reading the file yields only ciphertext; decrypting it needs the key, which is
// protected exactly as a directly-stored token would be — same posture, no size
// cap. The key goes through go-keyring's system tooling rather than this app's
// code signature, so an ad-hoc / per-build re-sign doesn't invalidate it.
//
// The short-lived access_token and display identity stay as plaintext in the
// file: the access_token is already handed to the WebView and expires fast, so
// it is not the asset R2 protects.
const (
keyringService = "cdrop"
// keyringKeyAccount holds the 32-byte AES key (base64) that encrypts the
// refresh_token at rest — NOT the token itself.
keyringKeyAccount = "session_key"
)
// persistedSession is the on-disk shape. RefreshTokenEnc holds
// base64(nonce||ciphertext) under AES-256-GCM. RefreshToken is only ever set on
// a legacy file (written before R2) or as a degraded fallback when the secret
// store is unavailable; the happy path leaves it empty.
type persistedSession struct {
AccessToken string `json:"access_token"`
RefreshTokenEnc string `json:"refresh_token_enc,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresIn int `json:"expires_in"`
User UserInfo `json:"user"`
}
// sessionPath is ~/Library/Application Support/cdrop/session.json on macOS.
func sessionPath() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "cdrop", "session.json"), nil
}
// loadOrCreateKey returns the AES-256 key from the OS secret store, minting and
// storing a fresh random one on first use.
func loadOrCreateKey() ([]byte, error) {
if enc, err := keyring.Get(keyringService, keyringKeyAccount); err == nil {
if key, derr := base64.StdEncoding.DecodeString(enc); derr == nil && len(key) == 32 {
return key, nil
}
// A corrupt / wrong-sized value is unusable; fall through and regenerate.
// (Any ciphertext encrypted under the old value becomes undecryptable —
// the user re-logs in, which is acceptable for a corrupt store.)
} else if !errors.Is(err, keyring.ErrNotFound) {
return nil, err
}
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, err
}
if err := keyring.Set(keyringService, keyringKeyAccount, base64.StdEncoding.EncodeToString(key)); err != nil {
return nil, err
}
return key, nil
}
func newGCM(key []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
// encryptToken seals the refresh_token under the secret-store key, returning
// base64(nonce||ciphertext).
func encryptToken(plaintext string) (string, error) {
key, err := loadOrCreateKey()
if err != nil {
return "", err
}
gcm, err := newGCM(key)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(sealed), nil
}
// decryptToken opens a refresh_token ciphertext using the secret-store key. It
// never mints a key: a missing key means the ciphertext is unrecoverable (the
// caller falls back to requiring a fresh login).
func decryptToken(enc string) (string, error) {
keyB64, err := keyring.Get(keyringService, keyringKeyAccount)
if err != nil {
return "", err
}
key, err := base64.StdEncoding.DecodeString(keyB64)
if err != nil || len(key) != 32 {
return "", errors.New("session: malformed key in secret store")
}
raw, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
return "", err
}
gcm, err := newGCM(key)
if err != nil {
return "", err
}
if len(raw) < gcm.NonceSize() {
return "", errors.New("session: ciphertext too short")
}
nonce, ct := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ct, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
// SaveSession persists the login session. The refresh_token is encrypted at rest
// under a key held in the OS secret store; everything else (short-lived
// access_token + identity) is plaintext in the 0600 file. If the secret store is
// unavailable (e.g. a headless Linux box with no Secret Service), we fall back to
// writing the refresh_token in plaintext so login still works — logging the
// downgrade rather than failing auth outright.
func SaveSession(res LoginResult) error {
p, err := sessionPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
return err
}
rec := persistedSession{
AccessToken: res.AccessToken,
ExpiresIn: res.ExpiresIn,
User: res.User,
}
if res.RefreshToken != "" {
if enc, err := encryptToken(res.RefreshToken); err == nil {
rec.RefreshTokenEnc = enc
} else {
slog.Warn("cdrop: refresh_token kept in session file; OS secret store unavailable", "err", err)
rec.RefreshToken = res.RefreshToken
}
}
data, err := json.Marshal(rec)
if err != nil {
return err
}
return os.WriteFile(p, data, 0o600)
}
// LoadSession returns the persisted session, or nil when none is stored / the
// file is unreadable or empty. The refresh_token is decrypted from the file
// using the secret-store key. A legacy file that still embeds the plaintext
// refresh_token (written before R2, or by the fallback above) is re-encrypted on
// first load and stripped of its plaintext.
func LoadSession() (*LoginResult, error) {
p, err := sessionPath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(p)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var rec persistedSession
if err := json.Unmarshal(data, &rec); err != nil {
return nil, err
}
if rec.AccessToken == "" {
return nil, nil
}
res := &LoginResult{
AccessToken: rec.AccessToken,
ExpiresIn: rec.ExpiresIn,
User: rec.User,
}
switch {
case rec.RefreshToken != "":
// Plaintext on disk (legacy or fallback): keep it in memory and re-save,
// which encrypts it and strips the plaintext (no-ops if the store is
// still unavailable, leaving the file as-is).
res.RefreshToken = rec.RefreshToken
if err := SaveSession(*res); err != nil {
slog.Warn("cdrop: refresh_token migration to secret store failed", "err", err)
}
case rec.RefreshTokenEnc != "":
if rt, err := decryptToken(rec.RefreshTokenEnc); err == nil {
res.RefreshToken = rt
} else {
// Key gone / ciphertext corrupt: drop to an access-token-only session;
// the app will require a fresh login once the access_token lapses.
slog.Warn("cdrop: decrypt refresh_token failed; re-login will be required", "err", err)
}
}
return res, nil
}
// ClearSession removes the persisted session (logout): both the file and the
// encryption key held in the OS secret store. Absent entries are a no-op.
func ClearSession() error {
p, err := sessionPath()
if err != nil {
return err
}
if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if err := keyring.Delete(keyringService, keyringKeyAccount); err != nil &&
!errors.Is(err, keyring.ErrNotFound) {
slog.Warn("cdrop: clear session key from secret store failed", "err", err)
}
return nil
}
+197
View File
@@ -0,0 +1,197 @@
package platform
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
keyring "github.com/zalando/go-keyring"
)
// withTempSession isolates session storage: an in-memory keyring mock (so tests
// never touch the real Keychain / Credential Manager) plus a temp HOME so
// sessionPath resolves under t.TempDir().
func withTempSession(t *testing.T) {
t.Helper()
keyring.MockInit()
dir := t.TempDir()
// os.UserConfigDir honours XDG_CONFIG_HOME on Linux and HOME on macOS.
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv("HOME", dir)
}
// readSessionFile returns the parsed on-disk record plus its raw bytes.
func readSessionFile(t *testing.T) (persistedSession, []byte) {
t.Helper()
p, err := sessionPath()
if err != nil {
t.Fatalf("sessionPath: %v", err)
}
data, err := os.ReadFile(p)
if err != nil {
if os.IsNotExist(err) {
return persistedSession{}, nil
}
t.Fatalf("read session file: %v", err)
}
var rec persistedSession
if err := json.Unmarshal(data, &rec); err != nil {
t.Fatalf("unmarshal session file: %v", err)
}
return rec, data
}
// The refresh_token must be encrypted at rest (R2): the plaintext never appears
// in the file, the keyring holds only the small AES key, and the round-trip
// returns the token in memory.
func TestSaveSession_RefreshTokenEncryptedAtRest(t *testing.T) {
withTempSession(t)
res := LoginResult{
AccessToken: "access-abc",
RefreshToken: "refresh-xyz-secret",
ExpiresIn: 3600,
User: UserInfo{ID: "u1", Name: "Tester"},
}
if err := SaveSession(res); err != nil {
t.Fatalf("save: %v", err)
}
rec, raw := readSessionFile(t)
if rec.RefreshToken != "" {
t.Errorf("plaintext refresh_token must not be in the file, got %q", rec.RefreshToken)
}
if rec.RefreshTokenEnc == "" {
t.Error("refresh_token_enc must be present")
}
if strings.Contains(string(raw), "refresh-xyz-secret") {
t.Error("the plaintext token must not appear anywhere in the file bytes")
}
// The keyring holds the AES key, not the token.
if k, err := keyring.Get(keyringService, keyringKeyAccount); err != nil || k == "" {
t.Errorf("keyring must hold the session key: %q err %v", k, err)
}
loaded, err := LoadSession()
if err != nil || loaded == nil {
t.Fatalf("load: %v (nil=%v)", err, loaded == nil)
}
if loaded.RefreshToken != "refresh-xyz-secret" || loaded.AccessToken != "access-abc" {
t.Errorf("loaded tokens: rt=%q at=%q", loaded.RefreshToken, loaded.AccessToken)
}
}
// A large JWT refresh_token (Casdoor issues ~15 KB) must round-trip — this is
// exactly the case go-keyring can't store directly (its per-item limit is a few
// KB), and the reason the token is encrypted into the file instead.
func TestSaveSession_LargeTokenRoundTrips(t *testing.T) {
withTempSession(t)
big := strings.Repeat("a", 16000)
res := LoginResult{AccessToken: "acc", RefreshToken: big, ExpiresIn: 3600, User: UserInfo{ID: "u"}}
if err := SaveSession(res); err != nil {
t.Fatalf("save large token: %v", err)
}
rec, raw := readSessionFile(t)
if rec.RefreshToken != "" {
t.Error("large token must not be stored in plaintext")
}
if strings.Contains(string(raw), big) {
t.Error("large plaintext token must not appear in the file bytes")
}
loaded, err := LoadSession()
if err != nil || loaded == nil || loaded.RefreshToken != big {
t.Fatalf("large token did not round-trip: err %v", err)
}
}
// A legacy file with a plaintext refresh_token (written before R2) must be
// re-encrypted on first load and stripped of its plaintext.
func TestLoadSession_MigratesLegacyPlaintext(t *testing.T) {
withTempSession(t)
p, _ := sessionPath()
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
// Seed a legacy file: plaintext refresh_token, no ciphertext field.
data, _ := json.Marshal(persistedSession{
AccessToken: "access-abc",
RefreshToken: "legacy-plain",
ExpiresIn: 3600,
User: UserInfo{ID: "u1"},
})
if err := os.WriteFile(p, data, 0o600); err != nil {
t.Fatalf("seed legacy file: %v", err)
}
loaded, err := LoadSession()
if err != nil || loaded == nil {
t.Fatalf("load: %v", err)
}
if loaded.RefreshToken != "legacy-plain" {
t.Errorf("migrated session must carry the token in memory, got %q", loaded.RefreshToken)
}
rec, raw := readSessionFile(t)
if rec.RefreshToken != "" {
t.Error("plaintext must be stripped from disk after migration")
}
if rec.RefreshTokenEnc == "" || strings.Contains(string(raw), "legacy-plain") {
t.Error("migrated token must be encrypted in the file")
}
}
func TestSessionRoundTripAndClear(t *testing.T) {
withTempSession(t)
if got, err := LoadSession(); err != nil || got != nil {
t.Fatalf("absent session should be nil; got %+v err %v", got, err)
}
want := LoginResult{
AccessToken: "acc",
RefreshToken: "ref",
ExpiresIn: 3600,
User: UserInfo{ID: "u-1", Name: "alice", Avatar: "a.png"},
}
if err := SaveSession(want); err != nil {
t.Fatalf("SaveSession: %v", err)
}
got, err := LoadSession()
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
if got == nil || *got != want {
t.Errorf("round-trip: got %+v, want %+v", got, want)
}
if err := ClearSession(); err != nil {
t.Fatalf("ClearSession: %v", err)
}
if got, _ := LoadSession(); got != nil {
t.Error("session should be gone after ClearSession")
}
// The key is dropped too — no orphaned secret-store entry.
if _, err := keyring.Get(keyringService, keyringKeyAccount); err != keyring.ErrNotFound {
t.Errorf("session key should be gone after clear, got err %v", err)
}
if err := ClearSession(); err != nil {
t.Errorf("ClearSession on absent should be no-op, got %v", err)
}
}
func TestLoadSessionIgnoresEmptyToken(t *testing.T) {
withTempSession(t)
if err := SaveSession(LoginResult{RefreshToken: "ref"}); err != nil {
t.Fatalf("SaveSession: %v", err)
}
got, err := LoadSession()
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
if got != nil {
t.Errorf("empty-access-token session should load as nil, got %+v", got)
}
}
+116
View File
@@ -0,0 +1,116 @@
package platform
import (
"bytes"
"encoding/json"
"net/http"
"strconv"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
)
// bootPayload is the state injected into the page at load so the WebView store
// can hydrate synchronously: the restored session (refresh-free — see
// SessionView), the device name (both of which the wails:// scheme can't persist
// in WebView storage), and the API base the page should fetch from.
type bootPayload struct {
Session *SessionView `json:"session"`
DeviceName string `json:"device_name"`
// APIBase points the page at a loopback HTTP origin for /api on Windows,
// where the custom-scheme AssetServer buffers streaming responses (so SSE
// never arrives). Empty on macOS — the page then uses same-origin relative
// /api through the custom scheme, which streams fine there.
APIBase string `json:"api_base,omitempty"`
// DeviceType is the native client kind (macos / windows / linux); the page
// forwards it as X-Device-Type so the backend lists it correctly instead of
// defaulting every device to "browser".
DeviceType string `json:"device_type,omitempty"`
}
// SessionInjectMiddleware injects the boot state into the served index.html as
// `window.__CDROP_BOOT__`, so the WebView store hydrates synchronously at startup
// — no WebView storage (which doesn't survive restart for the wails:// scheme)
// and no binding round-trip (window.go isn't ready that early). Read once at app
// launch. device_name + api_base are injected always (needed even logged out);
// the session is included only when one was restored.
//
// Only the top-level HTML document is rewritten; assets and the /api proxy
// (including the SSE stream) pass straight through untouched. The JSON is
// produced by encoding/json with default HTML escaping, so a display name
// containing "</script>" can't break out of the inline script.
func SessionInjectMiddleware(session *LoginResult, deviceName, apiBase, deviceType string) assetserver.Middleware {
boot := bootPayload{DeviceName: deviceName, APIBase: apiBase, DeviceType: deviceType}
if session != nil {
view := session.View() // strip the refresh_token before it ever reaches the page
boot.Session = &view
}
payload, _ := json.Marshal(boot)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || !isDocumentRequest(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
// Force a full 200 (never a 304): strip conditional headers so
// http.ServeContent always returns the whole document for us to
// inject. Without this, a WebView that cached the first (logged-out)
// page could send If-Modified-Since on the next launch, get a 304 with
// an empty body, reuse its stale page, and the session wouldn't restore.
r.Header.Del("If-Modified-Since")
r.Header.Del("If-None-Match")
r.Header.Del("If-Range")
rec := &captureWriter{header: http.Header{}}
next.ServeHTTP(rec, r)
body := rec.buf.Bytes()
if idx := bytes.Index(body, []byte("</head>")); idx != -1 {
script := []byte("<script>window.__CDROP_BOOT__=" + string(payload) + ";</script>")
out := make([]byte, 0, len(body)+len(script))
out = append(out, body[:idx]...)
out = append(out, script...)
out = append(out, body[idx:]...)
body = out
}
h := w.Header()
for k, vs := range rec.header {
for _, v := range vs {
h.Add(k, v)
}
}
// Never let the WebView cache the document — the injected session
// changes between launches, so a cached copy must not be reused.
h.Del("Last-Modified")
h.Del("ETag")
h.Set("Cache-Control", "no-store, no-cache, must-revalidate")
h.Set("Pragma", "no-cache")
h.Set("Content-Length", strconv.Itoa(len(body)))
code := rec.code
if code == 0 {
code = http.StatusOK
}
w.WriteHeader(code)
_, _ = w.Write(body)
})
}
}
// isDocumentRequest matches the top-level HTML document the WebView loads.
func isDocumentRequest(path string) bool {
return path == "" || path == "/" || path == "/index.html"
}
// captureWriter buffers a handler's response so the middleware can rewrite the
// HTML body before forwarding it. Only used for the small index.html document.
type captureWriter struct {
header http.Header
buf bytes.Buffer
code int
}
func (c *captureWriter) Header() http.Header { return c.header }
func (c *captureWriter) WriteHeader(code int) { c.code = code }
func (c *captureWriter) Write(b []byte) (int, error) { return c.buf.Write(b) }
+109
View File
@@ -0,0 +1,109 @@
package platform
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
const htmlDoc = "<html><head><title>cdrop</title></head><body></body></html>"
func htmlHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(htmlDoc))
})
}
func TestSessionInjectIntoDocument(t *testing.T) {
session := &LoginResult{
AccessToken: "acc-token",
RefreshToken: "ref-token",
User: UserInfo{ID: "u-1", Name: "alice"},
}
h := SessionInjectMiddleware(session, "my-host", "", "macos")(htmlHandler())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
body := rec.Body.String()
if !strings.Contains(body, "window.__CDROP_BOOT__") {
t.Fatalf("boot global not injected:\n%s", body)
}
if !strings.Contains(body, `"access_token":"acc-token"`) {
t.Errorf("access token not in injected payload:\n%s", body)
}
// Credential policy: the refresh_token must NEVER reach the page.
if strings.Contains(body, "ref-token") || strings.Contains(body, "refresh_token") {
t.Errorf("refresh_token must not be injected into the page:\n%s", body)
}
if !strings.Contains(body, `"device_name":"my-host"`) {
t.Errorf("device name not in injected payload:\n%s", body)
}
if !strings.Contains(body, `"device_type":"macos"`) {
t.Errorf("device type not in injected payload:\n%s", body)
}
// Injected before </head> so it runs before the deferred module bundle.
if strings.Index(body, "__CDROP_BOOT__") > strings.Index(body, "</head>") {
t.Error("injection must precede </head>")
}
}
func TestSessionInjectPassesThroughNonDocument(t *testing.T) {
apiBody := "streamed-api-body"
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(apiBody))
})
h := SessionInjectMiddleware(&LoginResult{AccessToken: "x"}, "h", "", "")(next)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/hub/events", nil))
if rec.Body.String() != apiBody {
t.Errorf("non-document request must pass through unchanged, got %q", rec.Body.String())
}
}
func TestSessionInjectNilSessionStillInjectsDeviceBoot(t *testing.T) {
// Logged out, the boot state is still injected — the page needs the device
// name / api_base / device_type before login — but with a null session, and
// no token of any kind leaks.
h := SessionInjectMiddleware(nil, "my-host", "http://127.0.0.1:5000", "windows")(htmlHandler())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
body := rec.Body.String()
if !strings.Contains(body, "window.__CDROP_BOOT__") {
t.Fatalf("boot global must be injected even when logged out:\n%s", body)
}
if !strings.Contains(body, `"session":null`) {
t.Errorf("logged-out boot must carry a null session:\n%s", body)
}
if !strings.Contains(body, `"device_name":"my-host"`) ||
!strings.Contains(body, `"device_type":"windows"`) ||
!strings.Contains(body, `"api_base":"http://127.0.0.1:5000"`) {
t.Errorf("device boot fields not injected:\n%s", body)
}
if strings.Contains(body, "access_token") {
t.Errorf("no token must appear when logged out:\n%s", body)
}
}
func TestSessionInjectEscapesScriptBreakout(t *testing.T) {
// A display name containing </script> must not break out of the inline tag.
session := &LoginResult{AccessToken: "acc", User: UserInfo{Name: "</script><b>x"}}
h := SessionInjectMiddleware(session, "h", "", "")(htmlHandler())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
body := rec.Body.String()
// The literal closing tag from the name must NOT appear verbatim; encoding/json
// HTML-escapes < / > to < / > by default.
if strings.Contains(body, "</script><b>x") {
t.Errorf("script breakout not escaped:\n%s", body)
}
if !strings.Contains(body, `</script>`) {
t.Errorf("expected HTML-escaped name in payload:\n%s", body)
}
}
+23
View File
@@ -0,0 +1,23 @@
package platform
// StatusBarHandler carries the three menu-bar actions as closures so the App can
// supply them without exposing extra Wails-bound methods on its struct.
type StatusBarHandler struct {
OnShowWindow func()
OnOpenSettings func()
OnQuit func()
}
// StatusBarMenu is the set of localized labels for the menu bar item + its menu.
// The icon itself is the embedded brand mascot (see statusbar_darwin); Title is
// only the accessibility label + text fallback when the image can't be loaded.
type StatusBarMenu struct {
Title string // accessibility label + text fallback if the icon fails to load
Show string
Settings string
Quit string
}
// statusBarHandler holds the active closures; set by InstallStatusBar and read
// by the native menu-action callbacks (darwin) — a no-op elsewhere.
var statusBarHandler StatusBarHandler
+80
View File
@@ -0,0 +1,80 @@
//go:build darwin
package platform
/*
#cgo darwin CFLAGS: -x objective-c -fobjc-arc
#cgo darwin LDFLAGS: -framework Cocoa
#include <stdlib.h>
void cdropStatusBarInstall(const void *iconPNG, int iconLen, const char *title,
const char *showLabel, const char *settingsLabel,
const char *quitLabel);
void cdropSetActivationPolicy(int policy);
*/
import "C"
import (
_ "embed"
"unsafe"
)
// menuBarIconPNG 是嵌入二进制的品牌吉祥物头像(avatar-head,256px)。菜单栏图标
// 用彩色图而非 SF Symbol 模板图——彩色更有品牌辨识度(代价是不随明暗自适应)。
// 256px 提供高清 rep,运行时按菜单栏 thickness(逻辑点)显示,retina 下采样保持清晰。
//
//go:embed menubar-icon.png
var menuBarIconPNG []byte
// InstallStatusBar creates the menu bar (NSStatusBar) item with a Show /
// Settings / Quit menu. Safe to call from any goroutine — the native work is
// dispatched to the main thread. Idempotent on the native side.
func InstallStatusBar(m StatusBarMenu, h StatusBarHandler) {
statusBarHandler = h
ct := C.CString(m.Title)
cs := C.CString(m.Show)
cg := C.CString(m.Settings)
cq := C.CString(m.Quit)
defer C.free(unsafe.Pointer(ct))
defer C.free(unsafe.Pointer(cs))
defer C.free(unsafe.Pointer(cg))
defer C.free(unsafe.Pointer(cq))
// 把 PNG 字节复制进 C 缓冲;ObjC 侧在 dispatch 前同步拷进 NSData,故调用返回后即可释放。
cIcon := C.CBytes(menuBarIconPNG)
defer C.free(cIcon)
C.cdropStatusBarInstall(cIcon, C.int(len(menuBarIconPNG)), ct, cs, cg, cq)
}
// SetDockVisible toggles the Dock icon: true → regular (icon shown), false →
// accessory (Dock hidden, only the menu bar item remains). Main-thread safe.
func SetDockVisible(visible bool) {
p := C.int(1) // accessory
if visible {
p = C.int(0) // regular
}
C.cdropSetActivationPolicy(p)
}
//export cdropOnShowWindow
func cdropOnShowWindow() {
if statusBarHandler.OnShowWindow != nil {
statusBarHandler.OnShowWindow()
}
}
//export cdropOnOpenSettings
func cdropOnOpenSettings() {
if statusBarHandler.OnOpenSettings != nil {
statusBarHandler.OnOpenSettings()
}
}
//export cdropOnQuit
func cdropOnQuit() {
if statusBarHandler.OnQuit != nil {
statusBarHandler.OnQuit()
}
}
+133
View File
@@ -0,0 +1,133 @@
//go:build darwin
#import <Cocoa/Cocoa.h>
#include "_cgo_export.h"
// CdropStatusBarController owns the menu bar item and routes the three menu
// actions back into Go. It is deliberately NOT the NSApplication delegate
// Wails owns that, and stealing it would break Wails' window / runtime / custom
// scheme handling. A status item can be created independently after launch, so
// we never touch the delegate.
@interface CdropStatusBarController : NSObject
@property (strong) NSStatusItem *item;
@property (strong) NSMenu *menu;
- (void)showWindow:(id)sender;
- (void)openSettings:(id)sender;
- (void)quit:(id)sender;
- (void)statusItemClicked:(id)sender;
@end
@implementation CdropStatusBarController
- (void)showWindow:(id)sender { cdropOnShowWindow(); }
- (void)openSettings:(id)sender { cdropOnOpenSettings(); }
- (void)quit:(id)sender { cdropOnQuit(); }
// showMenu pops the menu now. Temporarily attaching item.menu and performClick
// shows it and BLOCKS until dismissed (menu tracking is modal); detaching after
// keeps left-clicks flowing through statusItemClicked: (so left-click opens the
// window directly instead of popping the menu).
- (void)showMenu {
self.item.menu = self.menu;
[self.item.button performClick:nil];
self.item.menu = nil;
}
// statusItemClicked fires on left/right mouse-up. Click routing (the Windows tray
// should mirror this once implemented):
// - left-click open the main window
// - right-click / control-click menu (Show / Settings / Quit)
// We route clicks manually instead of setting item.menu item.menu would pop the
// menu on left-click too, leaving no way for left-click to open the window.
- (void)statusItemClicked:(id)sender {
NSEvent *e = [NSApp currentEvent];
BOOL secondary = (e.type == NSEventTypeRightMouseUp) ||
((e.modifierFlags & NSEventModifierFlagControl) != 0);
if (secondary) {
[self showMenu]; // right-click / control-click menu
} else {
cdropOnShowWindow(); // left-click open the main window
}
}
@end
// File-scope strong reference (ARC) keeps the controller and its status item
// alive for the process lifetime.
static CdropStatusBarController *gController = nil;
// cdropStatusBarInstall creates the menu bar item + its menu on the main thread.
// The icon is the embedded brand mascot (a colored, NON-template image keeps
// brand color instead of tinting to the menu bar). It's displayed at the menu
// bar thickness (logical points); the high-res (256px) source rep keeps it crisp
// on retina. If decoding fails it falls back to the text title. The PNG bytes are
// copied into NSData synchronously here (before the async block) so Go can free
// its C buffer right after this call. Labels arrive as UTF-8 from Go so this
// source stays ASCII-only. Idempotent.
void cdropStatusBarInstall(const void *iconPNG, int iconLen, const char *title,
const char *showLabel, const char *settingsLabel,
const char *quitLabel) {
NSData *iconData = (iconPNG != NULL && iconLen > 0)
? [NSData dataWithBytes:iconPNG length:(NSUInteger)iconLen]
: nil;
NSString *t = [NSString stringWithUTF8String:title];
NSString *sl = [NSString stringWithUTF8String:showLabel];
NSString *gl = [NSString stringWithUTF8String:settingsLabel];
NSString *ql = [NSString stringWithUTF8String:quitLabel];
dispatch_async(dispatch_get_main_queue(), ^{
if (gController != nil) { return; }
gController = [[CdropStatusBarController alloc] init];
NSStatusItem *item = [[NSStatusBar systemStatusBar]
statusItemWithLength:NSVariableStatusItemLength];
NSImage *icon = (iconData != nil) ? [[NSImage alloc] initWithData:iconData] : nil;
if (icon != nil) {
CGFloat th = [[NSStatusBar systemStatusBar] thickness];
[icon setSize:NSMakeSize(th, th)]; // fill bar height; retina uses 256px rep
icon.template = NO; // keep brand color, don't tint
item.button.image = icon;
item.button.accessibilityLabel = t; // image has no text a11y label
} else {
item.button.title = t;
}
NSMenu *menu = [[NSMenu alloc] init];
NSMenuItem *show = [[NSMenuItem alloc] initWithTitle:sl
action:@selector(showWindow:) keyEquivalent:@""];
show.target = gController;
[menu addItem:show];
NSMenuItem *settings = [[NSMenuItem alloc] initWithTitle:gl
action:@selector(openSettings:) keyEquivalent:@""];
settings.target = gController;
[menu addItem:settings];
[menu addItem:[NSMenuItem separatorItem]];
NSMenuItem *quit = [[NSMenuItem alloc] initWithTitle:ql
action:@selector(quit:) keyEquivalent:@""];
quit.target = gController;
[menu addItem:quit];
// Don't set item.menu (that auto-pops on mouse-down, killing double-click
// detection). Store the menu and route clicks through statusItemClicked:.
gController.item = item;
gController.menu = menu;
item.button.target = gController;
item.button.action = @selector(statusItemClicked:);
[item.button sendActionOn:(NSEventMaskLeftMouseUp | NSEventMaskRightMouseUp)];
});
}
// cdropSetActivationPolicy flips the Dock presence on the main thread.
// policy: 0 = regular (Dock icon shown), 1 = accessory (Dock hidden, menu bar
// item only).
void cdropSetActivationPolicy(int policy) {
dispatch_async(dispatch_get_main_queue(), ^{
NSApplicationActivationPolicy p =
(policy == 1) ? NSApplicationActivationPolicyAccessory
: NSApplicationActivationPolicyRegular;
[NSApp setActivationPolicy:p];
});
}
+19
View File
@@ -0,0 +1,19 @@
//go:build !darwin && !windows
package platform
// Non-darwin stubs. The menu bar item + Dock activation policy are macOS
// concepts; Windows/Linux equivalents (tray + window-to-tray) land when those
// platforms are targeted. Kept as no-ops so cross-platform builds compile.
//
// 当实现 Windows 托盘时,交互须与 macOS 对齐(见 statusbar_darwin.m):
// 左键单击 → 打开主窗口;右键 → 菜单(显示主窗 / 设置 / 退出)。托盘图标用同一
// 彩色品牌图(menubar-icon.png)。
// InstallStatusBar is a no-op outside macOS.
func InstallStatusBar(_ StatusBarMenu, h StatusBarHandler) {
statusBarHandler = h
}
// SetDockVisible is a no-op outside macOS.
func SetDockVisible(_ bool) {}
+74
View File
@@ -0,0 +1,74 @@
//go:build windows
package platform
import (
_ "embed"
"runtime"
"fyne.io/systray"
)
// trayIconICO is the brand mascot as a Windows .ico, embedded in the binary and
// shown in the notification area.
//
//go:embed tray-icon.ico
var trayIconICO []byte
// InstallStatusBar installs the Windows system-tray icon, mirroring the macOS
// menu bar: left-click (primary tap) opens the main window, right-click shows
// the Show / Settings / Quit menu. systray owns a Win32 message loop, so it runs
// on its own OS-locked goroutine; the menu-item click channels are drained in a
// second goroutine. The handler closures already hand work to a goroutine before
// touching the Wails runtime, so invoking them from here is safe.
func InstallStatusBar(m StatusBarMenu, h StatusBarHandler) {
statusBarHandler = h
go func() {
// systray creates a window + GetMessage loop on this thread; the window
// is thread-affine, so the goroutine must not migrate.
runtime.LockOSThread()
systray.Run(func() { trayOnReady(m) }, nil)
}()
}
func trayOnReady(m StatusBarMenu) {
systray.SetIcon(trayIconICO)
systray.SetTitle(m.Title)
systray.SetTooltip(m.Title)
// Left-click → open the main window (matches the macOS convention). Right-
// click falls through to the menu below (Windows default).
systray.SetOnTapped(func() {
if statusBarHandler.OnShowWindow != nil {
statusBarHandler.OnShowWindow()
}
})
show := systray.AddMenuItem(m.Show, "")
settings := systray.AddMenuItem(m.Settings, "")
quit := systray.AddMenuItem(m.Quit, "")
go func() {
for {
select {
case <-show.ClickedCh:
if statusBarHandler.OnShowWindow != nil {
statusBarHandler.OnShowWindow()
}
case <-settings.ClickedCh:
if statusBarHandler.OnOpenSettings != nil {
statusBarHandler.OnOpenSettings()
}
case <-quit.ClickedCh:
if statusBarHandler.OnQuit != nil {
statusBarHandler.OnQuit()
}
}
}
}()
}
// SetDockVisible is a no-op on Windows: the app lifecycle already hides/shows the
// window (Wails WindowHide/WindowShow), which removes/adds the taskbar button, so
// there is no separate Dock-style activation policy to toggle.
func SetDockVisible(_ bool) {}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "Commilitia Drop Desktop",
"outputfilename": "Commilitia Drop Desktop",
"frontend:install": "cd ../../web && npm install",
"frontend:build": "cd ../../web && npm run build -- --outDir ../desktop/frontend/dist --emptyOutDir",
"frontend:dev:watcher": "cd ../../web && npm run dev",
"frontend:dev:serverUrl": "auto",
"info": {
"productName": "Commilitia Drop Desktop"
},
"author": {
"name": "commilitia",
"email": "git.commilitia@commilitia.net"
}
}
+12
View File
@@ -0,0 +1,12 @@
# 并入你的主 Caddyfile(站点 block)。换上你的域名与服务名。
# 关键开关:
# - flush_interval -1 :禁用 reverse_proxy SSE 长响应的缓冲,让探测帧
# ping 立即送达浏览器;任何反代器忽略此项都会让 /api/hub/events 看似无事件
# - h3 建议在 global block 启用(站点级不必重复声明)
# - NN-cdrop 中的 NN compose 服务条目保持一致
your-domain.example {
reverse_proxy NN-cdrop:8080 {
flush_interval -1
}
}
+43
View File
@@ -0,0 +1,43 @@
# syntax=docker/dockerfile:1.7
#
# cdrop:latest —— 应用镜像。基础环境(Go 工具链 + node_modules + go modules
# 已被 cdrop-base:latest 预热(见 docker/Dockerfile.base),这里只做源码层与
# 跨编译。
#
# 构建(部署平台为 linux/amd64):
# docker buildx build --platform linux/amd64 \
# -f docker/Dockerfile -t cdrop:latest --load .
#
# BUILDPLATFORM 让 build 阶段跑在宿主原生 archmacOS arm64),跨编译产出
# linux/amd64 的 Go 二进制;最终 runtime FROM distroless 是 linux/amd64
# 但只有 COPY 一层,不经过 qemu 执行任何 amd64 程序。
# ---- build (uses cached base) ----
FROM --platform=$BUILDPLATFORM cdrop-base:latest AS build
ARG TARGETOS=linux TARGETARCH=amd64
# Vite 编译期 envdev 模式 token 必须随 image 烤进 bundleprod 部署留空即可
ARG VITE_CDROP_DEV_TOKEN=""
ARG VITE_CDROP_STUN_URLS=""
ENV VITE_CDROP_DEV_TOKEN=$VITE_CDROP_DEV_TOKEN \
VITE_CDROP_STUN_URLS=$VITE_CDROP_STUN_URLS
WORKDIR /src
# 复制源码(依赖在 base 中已就位)
COPY . .
# 前端构建(VITE_* 环境变量已 export
RUN cd web && npm run build
# 嵌入到 internal/webui/dist 让 //go:embed 拿到
RUN rm -rf ./internal/webui/dist && cp -a web/dist ./internal/webui/dist
# 跨编译 Go 到目标平台
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 \
go build -trimpath -ldflags="-s -w" -o /out/cdropd ./cmd/cdropd
# ---- runtime (linux/amd64 distroless) ----
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/cdropd /cdropd
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/cdropd"]
+29
View File
@@ -0,0 +1,29 @@
# syntax=docker/dockerfile:1.7
#
# cdrop-base — 重型依赖的预热镜像。
# 只有 go.mod/go.sum 或 web/package*.json 变动时才需要重建;改源码后构建
# cdrop:latest 就只需要复制源 + 编译,省掉 npm ci 与 go mod download 的耗时。
#
# 同时安装 Go 工具链与 Node 运行时,让最终的应用 Dockerfile 不再需要 multi-stage
# 切换基础镜像。
#
# 构建(一次或随依赖更新;走宿主原生 arch 即可):
# docker buildx build -f docker/Dockerfile.base -t cdrop-base:latest --load .
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS base
# Node.js + npm,覆盖 web 端依赖;ca-certificates 给 go mod download 用 HTTPS
RUN apk add --no-cache nodejs npm ca-certificates git
WORKDIR /src
# Go modules 缓存层
COPY go.mod go.sum ./
RUN go mod download
# npm 缓存层
WORKDIR /src/web
COPY web/package.json web/package-lock.json ./
RUN npm ci --no-audit --no-fund
WORKDIR /src
+38
View File
@@ -0,0 +1,38 @@
# cdrop 在你的 docker-compose.yaml 中的服务条目片段。
# NN 由你决定(与既有服务区分的编号 / 名称);your-proxy-network 换成你的反代所在网络。
#
# 不显式 ports —— 走内部网络,反代(如 Caddy)通过 NN-cdrop:8080 访问。
# 持久化挂一个目录到 /data,里面只有 cdrop.dbSQLite WAL)。
NN-cdrop:
image: cdrop:latest
container_name: NN-cdrop
restart: unless-stopped
networks: [your-proxy-network]
ports: []
volumes:
- ./NN-cdrop/data:/data
environment:
# M13 首轮 dev 模式(验证 Caddy / H3 管道),稳定后切 prod 见下方注释
CDROP_AUTH_MODE: dev
CDROP_DEV_TOKEN: REPLACE_WITH_RANDOM_HEX64
CDROP_DB_PATH: /data/cdrop.db
CDROP_LISTEN: ":8080"
CDROP_DEVICE_TTL_HOURS: "196"
# ---- prod 切换时启用以下,并把 CDROP_AUTH_MODE 改成 prod、删掉 DEV_TOKEN ----
# CDROP_OIDC_AUTHORIZE_URL: https://your-idp.example/login/oauth/authorize
# CDROP_OIDC_TOKEN_URL: https://your-idp.example/api/login/oauth/access_token
# JWKS 可走 IdP 内网地址省一跳;也可用公网 https。
# CDROP_OIDC_JWKS_URL: https://your-idp.example/.well-known/jwks
# CDROP_OIDC_ISSUER: https://your-idp.example/
# prod 强制非空(空则跳过 audience 校验 = 同 JWKS 其他应用可冒充)
# CDROP_OIDC_AUDIENCE: <你的 OAuth client_id>
# CDROP_OIDC_CLIENT_ID: <你的 OAuth client_id>
# CDROP_OIDC_REDIRECT_URI: https://your-domain.example/oauth/callback
# CDROP_OIDC_SCOPES: "openid profile email"
# CDROP_HS256_SECRET: REPLACE_WITH_RANDOM_HEX64
# ---- 可选:Cloudflare Realtime TURN over TLS(不配则前端 STUN-only 兜底)----
# CDROP_CF_TURN_KEY_ID: <dash.cloudflare.com → Realtime → TURN App>
# CDROP_CF_TURN_API_TOKEN: <同 App 派发的 API Token>
+148
View File
@@ -0,0 +1,148 @@
# iOS 快捷指令剪贴板同步
> **状态(2026-06-14):暂停。** 网页端「签发 token」界面已移除(组件 `web/src/features/shortcut/ShortcutTokens.tsx` 保留休眠、后端端点/令牌系统保留,便于将来重启)。本文余下内容为暂停前的设计与搭建规格,存档参考。
>
> **暂停原因(两条硬伤,源自轮询模型)**
> 1. **频繁鉴权**——自动指令每 INTERVAL 轮询一次,每次都走一遍完整鉴权(HS256 验签 + 设备 upsert + 异步 touch)。
> 2. **设备列表被「Unknown Device」刷屏**——轮询打的 `GET /api/clipboard/version` **没带 `X-Device-Name`**,鉴权中间件遂用 UA 兜底名(含随机十六进制)登记设备,每次轮询生成一条新设备行。
>
> 重启此路径前必须先解决:给所有快捷指令请求统一带 `X-Device-Name`(或服务端对 scoped 令牌固定设备名/跳过 upsert),并评估轮询频率对鉴权的压力(如令牌侧加轻量校验、或换长连/推送方案)。
借鉴 [SyncClipboard](https://github.com/Jeric-X/SyncClipboard) 的形态:两个 iCloud 快捷指令——**手动**与**自动**。本阶段先落地**手动**路径并真机验证,自动轮询指令在此基础上追加(见文末)。
## 鉴权模型(长期方案)
为快捷指令签发**长效、仅限剪贴板、可随时吊销**的专用 HS256 token**不**采用 SyncClipboard 的账号密码 Basic Auth。
理由出自信任模型(不信任本机其他软件):token 会明文存在快捷指令配置里、同机其他 App 可读。
- Basic Auth 一旦泄漏的是整账号、无法单独吊销、改密即全端失效——正是要避开的反模式。
- 本方案 token 只携带 `scope=clipboard`,经 `requireScope` 中间件**只能命中 `/api/clipboard`**;可单独**吊销**(服务端查 `revoked`,下次使用即失效)、带 **TTL**(默认 365 天)、记 `last_used_at` 审计。泄漏面仅限剪贴板、可定点吊销、不碰账号密码。
token 用 `CDROP_HS256_SECRET` 派生的 32 字节 HMAC 密钥签发(SHA-256 派生,任意长度密钥都可用)。快捷指令登记的设备名取自请求的 `X-Device-Name`——**设备名全局限制为 ASCII**:名字要塞进 HTTP 头,非 ASCII 无法可靠传输(浏览器 fetch 还会直接抛错),故输入处校验 + 服务端 `sanitizeDeviceName` 兜底剥离。`ios` 设备类型保留给未来真正的原生客户端,快捷指令不发该类型。
## 签发 token
设置页 →「iOS 快捷指令」→ 填备注名 →「签发」。弹窗**一次性**展示 token 与服务器地址,立即复制保存;关闭后无法再次查看。每用户活跃 token 上限默认 10(`CDROP_SHORTCUT_MAX_PER_USER`)。
需服务端配置 `CDROP_HS256_SECRET` 才启用,否则签发返回 503。
## 同步模型(version + origin_ts
服务端权威,每条剪贴板带:
- **`version`**:每用户严格单调递增的版本号。判断「变没变」的唯一依据,**不比内容**。
- **`origin_ts`**:内容在源设备被复制的时刻(ms)。冲突收敛的 LWW 键——写入仅在 `origin_ts` 严格大于现存时被接受,故延迟/乱序到达的旧复制不会盖掉新的(异步容忍)。
- `source_device`:来源,抑制回声 + 展示。
客户端:记 `lastVersion` + 自己的设备名。轮询**轻量 `/version`** 探测变更(不取 content),版本变了且 `source ≠ 自己` 才取全量;推送带 `origin_ts`
## 端点
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/clipboard/version` | **廉价探测**:只返 `{version, source_device, origin_ts, content_type, updated_at}`**不含 content** |
| GET | `/api/clipboard` | 取全量;返回上面字段 + `content` |
| PUT | `/api/clipboard` | 写入;体 `{"content": "...", "content_type": "text/plain", "origin_ts": <复制时刻 ms>}`,上限 64 KB。被 LWW 拒(有更晚的复制)回 `409` + 当前胜出态;省略 `origin_ts` 时服务端用 now |
三者都需请求头 `Authorization: Bearer <token>`
## 手动快捷指令搭建
在 iPhone「快捷指令」App 中手工新建两个指令,首次运行填入服务器地址与 token(用「文本」+「设定变量」持久化,后续动作引用变量)。两个指令都加请求头 `X-Device-Name: <你的 ASCII 设备名>`(仅 ASCII,如 `iPhone`),让它在设备列表里统一显示为一台设备。
### 拉取(云端 → 本机剪贴板)
1. 「获取 URL 内容」:地址 `<服务器地址>/api/clipboard`,方法 GET,请求头加 `Authorization: Bearer <token>`
2. 「获取词典值」:取键 `content`
3. 内容非空 →「拷贝到剪贴板」;可加「显示通知」提示已拉取。
### 上传(本机剪贴板 → 云端)
1. 「获取剪贴板」;若为空则「停止并输出」。
2. 「获取 URL 内容」:地址同上,方法 PUT,请求头加 `Authorization: Bearer <token>``Content-Type: application/json`,请求体选 JSON`{"content": 剪贴板, "content_type": "text/plain"}`
3. 可加「显示通知」提示已上传。
将两个指令加到主屏 / 共享表单 / 轻点背面即可手动触发。
## 网络容错
单次运行遇网络波动可能失败,重试即可——长效 token 不会因短暂离线失效。Web / 桌面端的实时通道(SSE)已改为**永不放弃的指数退避重连**(1s→3s→9s→18s,封顶 30s),短期断网自动恢复、不报错不退出。
## 吊销
设置页「iOS 快捷指令」列表 →「吊销」。服务端在每次请求时校验 `revoked`,吊销后该 token 下次使用立即失效。
## iCloud 分发:合并指令搭建规格(手动同步)
做**一个合并指令**(运行时菜单选 拉取 / 上传),配 **Import Questions** 在安装时问参数——服务器地址、设备名给默认值,安装者只需粘贴一次令牌。在 iPhone「快捷指令」App 里照下面搭,再「拷贝 iCloud 链接」分发;分享出的 iCloud 链接由 Apple 签名,安装无需开启「不受信任的快捷指令」。
### 顶部:三个配置变量
1. 「文本」→ 内容 `https://drop.commilitia.net` →「设定变量」`BASE`
2. 「文本」→ 内容占位(如 `token`)→「设定变量」`TOKEN`
3. 「文本」→ 内容 `iPhone` →「设定变量」`NAME`
(这三条「文本」的内容随后设为 Import Questions,安装者填 / 确认。)
### 菜单
4. 「从菜单选取」→ 两项:`从云端拉取``上传到云端`
### 「从云端拉取」分支
5. 「获取 URL 内容」:
- 网址:插入变量 `BASE`,紧跟文本 `/api/clipboard`
- 方法:`GET`
- 标头:`Authorization` = 文本 `Bearer ` 后插变量 `TOKEN``X-Device-Name` = 变量 `NAME`
6. 「获取词典值」:键 `content`,输入为上一步结果。
7. 「如果」上一步「有任何值」:
- 「拷贝到剪贴板」= 上一步值;(可选)「显示通知」`已从 cdrop 拉取`
- 否则:(可选)「显示通知」`云端剪贴板为空`
### 「上传到云端」分支
8. 「获取剪贴板」。
9. 「如果」剪贴板「有任何值」:
- 「获取 URL 内容」:
- 网址:变量 `BASE` 紧跟文本 `/api/clipboard`
- 方法:`PUT`
- 请求体:`JSON`,两字段——`content`(文本)= 变量「剪贴板」;`content_type`(文本)= `text/plain`
- 标头:`Authorization` = `Bearer ` + 变量 `TOKEN``X-Device-Name` = 变量 `NAME`。(请求体选 JSON 时通常自动带 `Content-Type: application/json`,没有就手动加。)
- (可选)「显示通知」`已上传到 cdrop`
- 否则:(可选)「显示通知」`本机剪贴板为空`
### 配置 Import Questions(分享前)
打开指令详情(设置)→「导入问题」区,添加三个,对应顶部三条「文本」:
- 服务器地址 → 问题「服务器地址」,默认答案 `https://drop.commilitia.net`
- 令牌 → 问题「粘贴 cdrop 令牌」,无默认。
- 设备名 → 问题「设备名(仅 ASCII)」,默认 `iPhone`
### 分发
指令详情 →「分享」→「拷贝 iCloud 链接」→ 把链接发来,接进网站签发面板(安装链接 + 二维码 + 复制令牌)。
要点:设备名仅 ASCII(与全局策略一致);不发 `X-Device-Type`——快捷指令在设备列表显示为浏览器类型 + 你填的名字,`ios` 类型保留给未来原生客户端;两个分支都带 `X-Device-Name`,统一折成一台设备。
## 自动同步:双向轮询指令
一个无限循环指令,按 version + origin_ts 模型做**双向**同步:本机被复制就推、远端更新就拉,单一 `LAST_VERSION` 防回环。每轮只读一次本机剪贴板 + 一次轻量 `/version`,仅在真有新内容时才取全量。
配置同合并指令(BASE_URL / TOKEN / NAME + Import Questions / INTERVAL)。持久变量:`LAST_VERSION``BASELINE`(本机当前内容)。初始化:`BASELINE`=获取剪贴板、`LAST_VERSION`=0。「重复」次数 `99999`(无真无限循环,停靠手动结束运行)。
循环体:
1. `L` = 「获取剪贴板」。
2. **如果 `L` ≠ `BASELINE`(本机被复制)→ 推送**
- 「获取 URL 内容」PUT `BASE_URL/api/clipboard`,体 JSON `{content: L, content_type: "text/plain", origin_ts: <当前时间 Unix 毫秒>}`,标头 `Authorization`/`X-Device-Name`
- 取响应 `version``LAST_VERSION`;取响应 `content`**等于 `L`** → `BASELINE`=L(被接受);**不等** → 「拷贝到剪贴板」=响应 content、`BASELINE`=响应 content(被拒,采纳胜出内容)。
- (iOS 读不了 409 状态码,故靠「响应 content 是否等于我发的」判接受/落败。)
3. **否则 → 廉价探测上游**
- 「获取 URL 内容」GET `BASE_URL/api/clipboard/version`(无 content)→ `{version, source_device}`
- 如果 `version``LAST_VERSION` **且** `source_device``NAME`GET 全量 `/api/clipboard` → 「拷贝到剪贴板」=content、`BASELINE`=content、`LAST_VERSION`=version。
- 否则如果 `version``LAST_VERSION`(自己的回声/同值):只 `LAST_VERSION`=version,不回写。
4. 「等待」`INTERVAL` 秒。
`<当前时间 Unix 毫秒>` 用「当前日期」→ 格式化为 Unix 毫秒时间戳。
**局限(如实交代)**:iOS 无法监听剪贴板变化,「自动」本质是前台/驻留轮询、不保证真后台、需手动停止;完全没网时「获取 URL 内容」报错会中断循环(iOS 无 try/catch),需重新运行或配「连上 Wi-Fi 时」自动化重启。真正无感同步只能靠原生 App。
+34
View File
@@ -0,0 +1,34 @@
module commilitia.net/cdrop
go 1.26.2
require (
github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/httprate v0.15.0
github.com/go-jose/go-jose/v4 v4.1.4
github.com/knadh/koanf/parsers/yaml v1.1.0
github.com/knadh/koanf/providers/env/v2 v2.0.0
github.com/knadh/koanf/providers/file v1.2.1
github.com/knadh/koanf/v2 v2.3.4
modernc.org/sqlite v1.50.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.yaml.in/yaml/v3 v3.0.3 // indirect
golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+97
View File
@@ -0,0 +1,97 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4=
github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg=
github.com/knadh/koanf/providers/env/v2 v2.0.0 h1:Ad5H3eun722u+FvchiIcEIJZsZ2M6oxCkgZfWN5B5KY=
github.com/knadh/koanf/providers/env/v2 v2.0.0/go.mod h1:1g01PE+Ve1gBfWNNw2wmULRP0tc8RJrjn5p2N/jNCIc=
github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM=
github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
github.com/knadh/koanf/v2 v2.3.4 h1:fnynNSDlujWE+v83hAp8wKr/cdoxHLO0629SN+U8Urc=
github.com/knadh/koanf/v2 v2.3.4/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+161
View File
@@ -0,0 +1,161 @@
// Package calls integrates Cloudflare Realtime TURN.
//
// Cloudflare Realtime issues short-lived ICE-server credentials via:
//
// POST https://rtc.live.cloudflare.com/v1/turn/keys/{KEY_ID}/credentials/generate-ice-servers
// Authorization: Bearer {API_TOKEN}
// {"ttl": 86400}
//
// Response 201:
//
// { "iceServers": [
// { "urls": ["stun:stun.cloudflare.com:3478", ...] },
// { "urls": ["turn:turn.cloudflare.com:3478?transport=udp",
// "turn:turn.cloudflare.com:3478?transport=tcp",
// "turns:turn.cloudflare.com:5349?transport=tcp"],
// "username": "...", "credential": "..." }
// ]}
//
// Free of charge for cdrop's egress volume (1 TB/mo soft cap is enough).
package calls
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"sync"
"time"
)
const (
endpointTmpl = "https://rtc.live.cloudflare.com/v1/turn/keys/%s/credentials/generate-ice-servers"
// DefaultTTL caps how long an issued TURN credential stays valid (R3). The
// same bundle is shared by every client of this instance, so a user who
// extracts it could relay traffic on cdrop's Cloudflare quota until it
// expires; a short TTL keeps that window small. 1h is ample to set up a
// transfer (creds authenticate the Allocate; the allocation outlives them).
DefaultTTL = 1 * time.Hour
requestTimeout = 10 * time.Second
)
type ICEServer struct {
URLs []string `json:"urls"`
Username string `json:"username,omitempty"`
Credential string `json:"credential,omitempty"`
}
type ICEResponse struct {
ICEServers []ICEServer `json:"iceServers"`
}
// Provider is concurrency-safe and caches generated credentials so multiple
// concurrent web clients share one upstream call to Cloudflare.
type Provider struct {
keyID string
apiToken string
ttl time.Duration
refreshMargin time.Duration
client *http.Client
mu sync.Mutex
cached *ICEResponse
expiresAt time.Time
}
// NewProvider returns a Provider configured to mint creds with the given TTL.
// ttl ≤ 0 falls back to DefaultTTL. The cache refreshes once a served credential
// drops below refreshMargin (a quarter of the TTL, floored at 5m) of remaining
// life, so every credential handed out still has a comfortable validity window
// even with a short TTL.
func NewProvider(keyID, apiToken string, ttl time.Duration) *Provider {
if ttl <= 0 {
ttl = DefaultTTL
}
margin := ttl / 4
if margin < 5*time.Minute {
margin = 5 * time.Minute
}
if margin >= ttl {
// Pathologically short TTLs: keep margin under the TTL so the cache can
// still serve at least briefly rather than refetching on every call.
margin = ttl / 2
}
return &Provider{
keyID: keyID,
apiToken: apiToken,
ttl: ttl,
refreshMargin: margin,
client: &http.Client{Timeout: requestTimeout},
}
}
// Get returns a fresh ICE-server bundle. Repeated calls within the TTL window
// (minus refreshMargin) return the cached value; otherwise a new fetch happens.
// On upstream failure, falls back to the most recent cached value if any.
func (p *Provider) Get(ctx context.Context) (*ICEResponse, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.cached != nil && time.Until(p.expiresAt) > p.refreshMargin {
return p.cached, nil
}
creds, err := p.fetch(ctx)
if err != nil {
if p.cached != nil {
slog.Warn("cf turn refresh failed; using cached creds",
"err", err, "expires_in", time.Until(p.expiresAt))
return p.cached, nil
}
return nil, err
}
p.cached = creds
p.expiresAt = time.Now().Add(p.ttl)
slog.Info("cf turn creds refreshed",
"servers", len(creds.ICEServers), "ttl", p.ttl)
return creds, nil
}
func (p *Provider) fetch(ctx context.Context) (*ICEResponse, error) {
body := bytes.NewReader([]byte(fmt.Sprintf(`{"ttl":%d}`, int(p.ttl.Seconds()))))
url := fmt.Sprintf(endpointTmpl, p.keyID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+p.apiToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("cloudflare unreachable: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("cloudflare turn status %d: %s",
resp.StatusCode, truncate(string(raw), 200))
}
var r ICEResponse
if err := json.Unmarshal(raw, &r); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
if len(r.ICEServers) == 0 {
return nil, errors.New("cloudflare returned empty iceServers")
}
return &r, nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
+33
View File
@@ -0,0 +1,33 @@
package calls
import (
"testing"
"time"
)
// The refresh margin scales with the TTL (a quarter, floored at 5m) so a short
// R3 TTL still leaves every served credential a comfortable validity window
// without refetching on every call.
func TestNewProvider_RefreshMargin(t *testing.T) {
cases := []struct {
name string
ttl time.Duration
want time.Duration
}{
{"default when zero", 0, DefaultTTL / 4}, // ttl<=0 → DefaultTTL (1h) → 15m
{"one hour", time.Hour, 15 * time.Minute}, // quarter
{"quarter floored at 5m", 10 * time.Minute, 5 * time.Minute}, // 2.5m → floor 5m, 5m<10m
{"pathologically short", 4 * time.Minute, 2 * time.Minute}, // floor 5m >= 4m ttl → ttl/2
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
p := NewProvider("k", "tok", c.ttl)
if p.refreshMargin != c.want {
t.Errorf("refreshMargin: got %v, want %v", p.refreshMargin, c.want)
}
if p.refreshMargin >= p.ttl {
t.Errorf("margin %v must stay under ttl %v so the cache can serve", p.refreshMargin, p.ttl)
}
})
}
}
+261
View File
@@ -0,0 +1,261 @@
// Package clipboard 实现单条覆写式的剪贴板同步:DB 立即写入(GET 永远反映
// 最新真相),SSE 广播按 generation 计数节流——同窗口内的连续 PUT 只让最后
// 一次广播出去,避免下游设备被中间值抖动。
package clipboard
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"sync"
"time"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/hub"
)
// originFutureToleranceMs caps how far ahead of server time a client's origin_ts
// may be. Beyond it we assume a runaway clock and clamp to server-now, so one
// mis-set device can't set an origin_ts no honest device could beat for a while.
const originFutureToleranceMs = 5 * 60 * 1000
const (
// ContentTypeText 是 MVP 阶段唯一允许写入的 content_type。前端 HTML→text
// fallback 在客户端完成,后端只接 text/plain。
ContentTypeText = "text/plain"
// EventType 是 SSE 推送给在线设备的事件名(brief §5 风格的 namespace:verb)。
EventType = "clipboard:update"
)
// Errors that the HTTP layer needs to distinguish.
var (
ErrUnsupportedType = errors.New("unsupported content_type")
ErrTooLarge = errors.New("content exceeds limit")
)
// Hub is the subset of *hub.Hub that the service needs. Declared as interface
// so unit tests can swap in a fake.
type Hub interface {
Broadcast(userID string, ev hub.Event)
}
// Querier 是 sqlc 生成的 *db.Queries 的子集,便于测试桩替换。
type Querier interface {
UpsertClipboard(ctx context.Context, arg db.UpsertClipboardParams) (int64, error)
GetClipboard(ctx context.Context, userID string) (db.ClipboardState, error)
ClearExpiredClipboards(ctx context.Context, updatedAt int64) (int64, error)
}
// Service 持有内存里的 pending 表,每个 userID 对应一个最新待广播的 generation。
type Service struct {
q Querier
hub Hub
max int
window time.Duration
ttl time.Duration // 0 = 内容不过期
mu sync.Mutex
pending map[string]uint64 // userID → 当前最新 generation
nextGen uint64
}
// New constructs a Service. window 是稳定窗口(PUT 收到后等待无新写入的时长,
// 到期才广播);maxBytes 是单次写入上限(UTF-8 字节);ttl 是云端内容存活时长
// (0 = 不过期)——见 Get 的惰性过期与 RunSweeper 的主动清除。
func New(q Querier, h Hub, maxBytes int, window, ttl time.Duration) *Service {
if maxBytes <= 0 {
maxBytes = 65536
}
if window <= 0 {
window = 3 * time.Second
}
return &Service{
q: q,
hub: h,
max: maxBytes,
window: window,
ttl: ttl,
pending: map[string]uint64{},
}
}
// MaxBytes 暴露上限给 HTTP 层做请求体校验。
func (s *Service) MaxBytes() int { return s.max }
// State 是 GET 返回 / SSE 广播 payload 的统一形状。Version 是变更探测的核心——
// 每用户严格单调递增;OriginTs 是冲突收敛的 LWW 键(源设备复制时刻 ms);客户端
// 据 Version 判变更、据 OriginTs 决定回写与否。
type State struct {
Content string `json:"content"`
ContentType string `json:"content_type"`
SourceDevice string `json:"source_device"`
UpdatedAt int64 `json:"updated_at"`
Version int64 `json:"version"`
OriginTs int64 `json:"origin_ts"`
}
// Get 读取当前持久化的剪贴板状态;err == sql.ErrNoRows 时表示用户尚未写入。
// 内容超过 TTL 即视为已过期,返回空状态(与「尚未写入」等价、ETag 0),绝不
// 回吐过期内容;实际的 DB 清除交给 RunSweeper。
func (s *Service) Get(ctx context.Context, userID string) (*State, error) {
row, err := s.q.GetClipboard(ctx, userID)
if err != nil {
return nil, err
}
st := rowToState(row)
if s.expired(st.Content, st.UpdatedAt) {
return &State{ContentType: ContentTypeText, UpdatedAt: 0}, nil
}
return st, nil
}
// expired 判断一条内容是否超过 TTL。ttl<=0 或内容为空时永不过期。
func (s *Service) expired(content string, updatedAt int64) bool {
if s.ttl <= 0 || content == "" {
return false
}
return time.Now().Unix()-updatedAt >= int64(s.ttl.Seconds())
}
// SweepExpired 清除所有超过 TTL 的剪贴板内容(content / source_device 置空),
// 返回清除的行数。ttl<=0 时为 no-op。
func (s *Service) SweepExpired(ctx context.Context) (int64, error) {
if s.ttl <= 0 {
return 0, nil
}
cutoff := time.Now().Unix() - int64(s.ttl.Seconds())
return s.q.ClearExpiredClipboards(ctx, cutoff)
}
// SweepInterval 是 sweeper 唤醒周期。
const SweepInterval = 30 * time.Second
// RunSweeper 阻塞直到 ctx 取消,周期性清除过期剪贴板内容。ttl<=0 时
// SweepExpired 为 no-op;调用方应据 cfg.ClipboardTTLSec>0 决定是否启动。
func RunSweeper(ctx context.Context, svc *Service) {
t := time.NewTicker(SweepInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
n, err := svc.SweepExpired(ctx)
if err != nil {
slog.Error("clipboard sweeper: clear expired failed", "err", err)
continue
}
if n > 0 {
slog.Info("clipboard sweeper: cleared expired content", "count", n)
}
}
}
}
// Put 接收一次写入:校验 → 立即 UPSERT → bump generation → 定时器到期才广播。
// 同 userID 在窗口期内的多次 Put 只让最后一次广播。
// Put writes a clipboard update tagged with originTs (the source device's copy
// time, ms). It is accepted only when originTs strictly exceeds the stored one
// (LWW); a delayed / out-of-order older copy is rejected with accepted=false and
// the current winning State returned (so the caller can reconcile). Only an
// accepted write bumps the version and schedules a broadcast.
func (s *Service) Put(
ctx context.Context,
userID, contentType, content, sourceDevice string,
originTs int64,
) (st *State, accepted bool, err error) {
if contentType != ContentTypeText {
return nil, false, fmt.Errorf("%w: %q", ErrUnsupportedType, contentType)
}
if len(content) > s.max {
return nil, false, fmt.Errorf("%w: %d > %d", ErrTooLarge, len(content), s.max)
}
now := time.Now()
if nowMs := now.UnixMilli(); originTs > nowMs+originFutureToleranceMs {
originTs = nowMs // clamp a runaway-future clock to server-now
}
version, err := s.q.UpsertClipboard(ctx, db.UpsertClipboardParams{
UserID: userID,
ContentType: contentType,
Content: &content,
SourceDevice: &sourceDevice,
UpdatedAt: now.Unix(),
OriginTs: originTs,
})
if errors.Is(err, sql.ErrNoRows) {
// LWW rejected: a newer origin_ts already won. Return the current winner
// (no broadcast) so the caller pulls / reconciles instead of clobbering.
cur, gerr := s.Get(ctx, userID)
if gerr != nil {
return nil, false, gerr
}
return cur, false, nil
}
if err != nil {
return nil, false, fmt.Errorf("upsert clipboard: %w", err)
}
st = &State{
Content: content,
ContentType: contentType,
SourceDevice: sourceDevice,
UpdatedAt: now.Unix(),
Version: version,
OriginTs: originTs,
}
s.mu.Lock()
s.nextGen++
gen := s.nextGen
s.pending[userID] = gen
s.mu.Unlock()
time.AfterFunc(s.window, func() { s.maybeCommit(userID, gen) })
return st, true, nil
}
// maybeCommit 由定时器回调进入。只有当 pending[userID] 仍然是入参 gen 时(即
// 期间没有更新写入)才真正广播;否则视为被新写入覆盖,直接丢弃。
func (s *Service) maybeCommit(userID string, gen uint64) {
s.mu.Lock()
cur, ok := s.pending[userID]
if !ok || cur != gen {
s.mu.Unlock()
return
}
delete(s.pending, userID)
s.mu.Unlock()
// 重新读 DB 拿权威 state——窗口结束时 DB 已是 gen 对应的内容(中间被
// 后续覆盖的可能性已被前面的 generation 检查排除)。
row, err := s.q.GetClipboard(context.Background(), userID)
if err != nil {
slog.Error("clipboard commit: get state failed", "err", err, "user", userID)
return
}
s.hub.Broadcast(userID, hub.Event{
Type: EventType,
Data: rowToState(row),
})
}
func rowToState(row db.ClipboardState) *State {
st := &State{
ContentType: row.ContentType,
UpdatedAt: row.UpdatedAt,
Version: row.Version,
OriginTs: row.OriginTs,
}
if row.Content != nil {
st.Content = *row.Content
}
if row.SourceDevice != nil {
st.SourceDevice = *row.SourceDevice
}
return st
}
+378
View File
@@ -0,0 +1,378 @@
package clipboard
import (
"context"
"database/sql"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/hub"
)
// fakeQuerier 在内存里模拟 clipboard_state 表,按 user_id 单条覆写。
type fakeQuerier struct {
mu sync.Mutex
rows map[string]db.ClipboardState
gets atomic.Int32
puts atomic.Int32
}
func newFakeQuerier() *fakeQuerier {
return &fakeQuerier{rows: map[string]db.ClipboardState{}}
}
func (f *fakeQuerier) UpsertClipboard(_ context.Context, arg db.UpsertClipboardParams) (int64, error) {
f.puts.Add(1)
f.mu.Lock()
defer f.mu.Unlock()
prev, exists := f.rows[arg.UserID]
if exists && arg.OriginTs <= prev.OriginTs {
return 0, sql.ErrNoRows // conditional WHERE failed → stale, no change (RETURNING no row)
}
version := prev.Version + 1 // absent → 0 → new version 1; else prev+1
f.rows[arg.UserID] = db.ClipboardState{
UserID: arg.UserID,
ContentType: arg.ContentType,
Content: arg.Content,
SourceDevice: arg.SourceDevice,
UpdatedAt: arg.UpdatedAt,
Version: version,
OriginTs: arg.OriginTs,
}
return version, nil
}
func (f *fakeQuerier) GetClipboard(_ context.Context, userID string) (db.ClipboardState, error) {
f.gets.Add(1)
f.mu.Lock()
defer f.mu.Unlock()
row, ok := f.rows[userID]
if !ok {
return db.ClipboardState{}, errNoRow
}
return row, nil
}
// ClearExpiredClipboards 模拟 SQL:把 content 非空且 updated_at < cutoff 的行
// 内容置 nil,返回清除行数。
func (f *fakeQuerier) ClearExpiredClipboards(_ context.Context, cutoff int64) (int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
var n int64
for k, row := range f.rows {
if row.Content != nil && row.UpdatedAt < cutoff {
row.Content = nil
row.SourceDevice = nil
f.rows[k] = row
n++
}
}
return n, nil
}
var errNoRow = errors.New("no row")
// fakeHub 计数 Broadcast 调用并把 events 排队让测试断言。
type fakeHub struct {
mu sync.Mutex
events []hub.Event
}
func (h *fakeHub) Broadcast(_ string, ev hub.Event) {
h.mu.Lock()
h.events = append(h.events, ev)
h.mu.Unlock()
}
func (h *fakeHub) snapshot() []hub.Event {
h.mu.Lock()
defer h.mu.Unlock()
out := make([]hub.Event, len(h.events))
copy(out, h.events)
return out
}
func TestPutWritesDBImmediately(t *testing.T) {
q := newFakeQuerier()
h := &fakeHub{}
s := New(q, h, 1024, 50*time.Millisecond, 0)
if _, _, err := s.Put(context.Background(), "alice", "text/plain", "hello", "tab-1", 1); err != nil {
t.Fatalf("Put: %v", err)
}
if got := q.puts.Load(); got != 1 {
t.Errorf("expected 1 DB upsert, got %d", got)
}
got, err := s.Get(context.Background(), "alice")
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.Content != "hello" {
t.Errorf("content: got %q, want %q", got.Content, "hello")
}
}
func TestPutTrailingPutOnlyBroadcastsLast(t *testing.T) {
q := newFakeQuerier()
h := &fakeHub{}
s := New(q, h, 1024, 60*time.Millisecond, 0)
ctx := context.Background()
for i, text := range []string{"a", "b", "c"} {
if _, _, err := s.Put(ctx, "alice", "text/plain", text, "tab-1", int64(i+1)); err != nil {
t.Fatalf("Put %q: %v", text, err)
}
time.Sleep(10 * time.Millisecond) // 每次写都在同窗口内
}
// 等待最后一次 Put 之后的窗口结束 + 余量
time.Sleep(150 * time.Millisecond)
evs := h.snapshot()
if len(evs) != 1 {
t.Fatalf("expected 1 broadcast, got %d", len(evs))
}
st, ok := evs[0].Data.(*State)
if !ok {
t.Fatalf("event data type: %T", evs[0].Data)
}
if st.Content != "c" {
t.Errorf("broadcast content: got %q, want %q", st.Content, "c")
}
if evs[0].Type != EventType {
t.Errorf("event type: got %q, want %q", evs[0].Type, EventType)
}
}
func TestPutDifferentUsersDontInterfere(t *testing.T) {
q := newFakeQuerier()
h := &fakeHub{}
s := New(q, h, 1024, 40*time.Millisecond, 0)
ctx := context.Background()
if _, _, err := s.Put(ctx, "alice", "text/plain", "alice-data", "tab-1", 1); err != nil {
t.Fatalf("alice: %v", err)
}
if _, _, err := s.Put(ctx, "bob", "text/plain", "bob-data", "tab-1", 1); err != nil {
t.Fatalf("bob: %v", err)
}
time.Sleep(120 * time.Millisecond)
evs := h.snapshot()
if len(evs) != 2 {
t.Fatalf("expected 2 broadcasts (one per user), got %d", len(evs))
}
}
func TestPutRejectsUnsupportedType(t *testing.T) {
s := New(newFakeQuerier(), &fakeHub{}, 1024, 10*time.Millisecond, 0)
_, _, err := s.Put(context.Background(), "alice", "text/html", "<b>hi</b>", "tab-1", 1)
if !errors.Is(err, ErrUnsupportedType) {
t.Errorf("expected ErrUnsupportedType, got %v", err)
}
}
func TestPutRejectsTooLarge(t *testing.T) {
s := New(newFakeQuerier(), &fakeHub{}, 8, 10*time.Millisecond, 0)
_, _, err := s.Put(context.Background(), "alice", "text/plain", "123456789", "tab-1", 1)
if !errors.Is(err, ErrTooLarge) {
t.Errorf("expected ErrTooLarge, got %v", err)
}
}
func TestPutEmptyContentAccepted(t *testing.T) {
q := newFakeQuerier()
h := &fakeHub{}
s := New(q, h, 1024, 30*time.Millisecond, 0)
if _, _, err := s.Put(context.Background(), "alice", "text/plain", "", "tab-1", 1); err != nil {
t.Errorf("empty content should be accepted (clear semantics): %v", err)
}
time.Sleep(80 * time.Millisecond)
evs := h.snapshot()
if len(evs) != 1 {
t.Fatalf("expected 1 broadcast, got %d", len(evs))
}
st := evs[0].Data.(*State)
if st.Content != "" {
t.Errorf("expected empty content, got %q", st.Content)
}
}
func TestPutWindowExtendsOnNewWrite(t *testing.T) {
q := newFakeQuerier()
h := &fakeHub{}
window := 80 * time.Millisecond
s := New(q, h, 1024, window, 0)
ctx := context.Background()
t0 := time.Now()
_, _, _ = s.Put(ctx, "alice", "text/plain", "first", "tab-1", 1)
time.Sleep(window / 2)
_, _, _ = s.Put(ctx, "alice", "text/plain", "second", "tab-1", 2)
// 第一窗到期点:任何广播都不该出现(被第二个 Put 重置)
time.Sleep(window/2 + 10*time.Millisecond)
if got := len(h.snapshot()); got != 0 {
t.Errorf("at t=%v, expected 0 broadcasts (window not yet elapsed for second), got %d",
time.Since(t0), got)
}
// 第二窗到期之后 broadcast 应该出现,且内容是 "second"
time.Sleep(window/2 + 30*time.Millisecond)
evs := h.snapshot()
if len(evs) != 1 {
t.Fatalf("expected 1 broadcast after second window, got %d", len(evs))
}
if got := evs[0].Data.(*State).Content; got != "second" {
t.Errorf("broadcast content: got %q, want %q", got, "second")
}
}
// --- TTL(短存活)---
func seedRow(q *fakeQuerier, userID, content string, ageSec int64) {
c := content
src := "tab-1"
q.rows[userID] = db.ClipboardState{
UserID: userID,
ContentType: "text/plain",
Content: &c,
SourceDevice: &src,
UpdatedAt: time.Now().Unix() - ageSec,
}
}
func TestGetExpiredReturnsEmpty(t *testing.T) {
q := newFakeQuerier()
seedRow(q, "alice", "secret", 100) // 100s 前写入
s := New(q, &fakeHub{}, 1024, 10*time.Millisecond, 60*time.Second)
got, err := s.Get(context.Background(), "alice")
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.Content != "" {
t.Errorf("过期内容不应回吐,got %q", got.Content)
}
if got.UpdatedAt != 0 {
t.Errorf("过期状态 UpdatedAt 应为 0(等同未写入),got %d", got.UpdatedAt)
}
}
func TestGetFreshWithinTTL(t *testing.T) {
q := newFakeQuerier()
seedRow(q, "alice", "recent", 5) // 5s 前
s := New(q, &fakeHub{}, 1024, 10*time.Millisecond, 60*time.Second)
got, err := s.Get(context.Background(), "alice")
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.Content != "recent" {
t.Errorf("TTL 内的新内容应返回,got %q", got.Content)
}
}
func TestGetTTLDisabledNeverExpires(t *testing.T) {
q := newFakeQuerier()
seedRow(q, "alice", "old", 100000)
s := New(q, &fakeHub{}, 1024, 10*time.Millisecond, 0) // ttl=0 → 禁用
got, _ := s.Get(context.Background(), "alice")
if got.Content != "old" {
t.Errorf("ttl=0 永不过期,got %q", got.Content)
}
}
func TestSweepExpiredClearsOldOnly(t *testing.T) {
q := newFakeQuerier()
seedRow(q, "alice", "old-secret", 100) // 过期
seedRow(q, "bob", "recent", 5) // 未过期
s := New(q, &fakeHub{}, 1024, 10*time.Millisecond, 60*time.Second)
n, err := s.SweepExpired(context.Background())
if err != nil {
t.Fatalf("SweepExpired: %v", err)
}
if n != 1 {
t.Errorf("应清除 1 行(alice),got %d", n)
}
if c := q.rows["alice"].Content; c != nil {
t.Errorf("alice 的内容应被清空(nil),got %v", *c)
}
if c := q.rows["bob"].Content; c == nil || *c != "recent" {
t.Errorf("bob 的新内容应存活,got %v", c)
}
}
func TestSweepExpiredNoopWhenDisabled(t *testing.T) {
q := newFakeQuerier()
seedRow(q, "alice", "x", 100000)
s := New(q, &fakeHub{}, 1024, 10*time.Millisecond, 0)
n, err := s.SweepExpired(context.Background())
if err != nil || n != 0 {
t.Errorf("ttl=0 时 SweepExpired 应为 noopgot n=%d err=%v", n, err)
}
if q.rows["alice"].Content == nil {
t.Error("ttl=0 不应清除任何内容")
}
}
func TestPutBumpsVersionMonotonically(t *testing.T) {
svc := New(newFakeQuerier(), &fakeHub{}, 1024, time.Hour, 0)
st1, ok, err := svc.Put(context.Background(), "u", ContentTypeText, "a", "devA", 1)
if err != nil || !ok {
t.Fatalf("put 1: ok=%v err=%v", ok, err)
}
if st1.Version != 1 {
t.Errorf("first put version: got %d, want 1", st1.Version)
}
st2, ok, err := svc.Put(context.Background(), "u", ContentTypeText, "b", "devA", 2)
if err != nil || !ok {
t.Fatalf("put 2: ok=%v err=%v", ok, err)
}
if st2.Version != 2 {
t.Errorf("second put version: got %d, want 2", st2.Version)
}
got, err := svc.Get(context.Background(), "u")
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Version != 2 || got.SourceDevice != "devA" {
t.Errorf("get: version=%d source=%q, want 2/devA", got.Version, got.SourceDevice)
}
}
// LWW: a delayed/out-of-order write with an older-or-equal origin_ts must be
// rejected (accepted=false) and leave the current winner intact.
func TestPutRejectsStaleOrigin(t *testing.T) {
svc := New(newFakeQuerier(), &fakeHub{}, 1024, time.Hour, 0)
ctx := context.Background()
st, ok, err := svc.Put(ctx, "u", ContentTypeText, "new", "devA", 200)
if err != nil || !ok || st.Version != 1 {
t.Fatalf("first put: ok=%v ver=%v err=%v", ok, st.Version, err)
}
// Older copy arriving late → rejected, current winner returned.
cur, ok, err := svc.Put(ctx, "u", ContentTypeText, "stale", "devB", 100)
if err != nil {
t.Fatalf("stale put err: %v", err)
}
if ok {
t.Error("older origin_ts must be rejected")
}
if cur.Content != "new" || cur.Version != 1 {
t.Errorf("rejected put must return current winner; got content=%q ver=%d", cur.Content, cur.Version)
}
// Equal origin_ts is not strictly greater → rejected (idempotent duplicate).
if _, ok, _ := svc.Put(ctx, "u", ContentTypeText, "dup", "devA", 200); ok {
t.Error("equal origin_ts must be rejected (idempotent)")
}
}
+134
View File
@@ -0,0 +1,134 @@
package config
import (
"errors"
"fmt"
"os"
"strings"
"github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/providers/env/v2"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2"
)
const envPrefix = "CDROP_"
type Config struct {
AuthMode string `koanf:"auth_mode"`
DevToken string `koanf:"dev_token"`
DBPath string `koanf:"db_path"`
Listen string `koanf:"listen"`
// DeviceTTLHours is the maximum age of a row in `devices` before the
// background sweeper deletes it. Brief §2 sets the refresh_token sliding
// window at 196h (≈8 days) — devices must not outlive that. Default 196h.
DeviceTTLHours int `koanf:"device_ttl_hours"`
// OIDC settings. Generic OAuth/OIDC provider compatible (Casdoor included).
OIDCAuthorizeURL string `koanf:"oidc_authorize_url"`
OIDCTokenURL string `koanf:"oidc_token_url"`
OIDCJWKSURL string `koanf:"oidc_jwks_url"`
OIDCIssuer string `koanf:"oidc_issuer"`
// OIDCAudience accepts a comma-separated list. A token passes when its `aud`
// matches any one entry — this lets one backend serve several OAuth clients
// that carry different audiences (e.g. the web app and the desktop client).
// Empty disables audience checking; a single value behaves as before.
OIDCAudience string `koanf:"oidc_audience"`
OIDCClientID string `koanf:"oidc_client_id"`
OIDCClientSecret string `koanf:"oidc_client_secret"`
OIDCRedirectURI string `koanf:"oidc_redirect_uri"`
OIDCScopes string `koanf:"oidc_scopes"`
HS256Secret string `koanf:"hs256_secret"`
// Shortcut tokensiOS 快捷指令用的长效 HS256 token。TTLDays 是签发有效期
// (默认 365 天),MaxPerUser 是每用户未吊销且未过期的 token 上限(默认 10)。
// 需配 HS256Secret 才启用,否则签发端点返回 503。
ShortcutTokenTTLDays int `koanf:"shortcut_token_ttl_days"`
ShortcutMaxPerUser int `koanf:"shortcut_max_per_user"`
// Cloudflare Realtime TURN (https://developers.cloudflare.com/realtime/turn/).
// When both fields are set, /api/calls/credentials returns short-lived
// TLS-capable TURN creds (turns:turn.cloudflare.com:5349); otherwise the
// endpoint serves a STUN-only fallback so dev / no-CF deploys still work.
CFTurnKeyID string `koanf:"cf_turn_key_id"`
CFTurnAPIToken string `koanf:"cf_turn_api_token"`
// Clipboard:单条覆写式同步。MaxBytes 限制单次写入字节数;DebounceSec 是
// 收到 PUT 后等待"无新写入稳定窗口"的秒数,到期才广播 SSE。同窗口内的
// 后续 PUT 直接刷新 generation 让旧广播取消。
ClipboardMaxBytes int `koanf:"clipboard_max_bytes"`
ClipboardDebounceSec int `koanf:"clipboard_debounce_sec"`
// ClipboardTTLSec 是云剪贴板内容的存活秒数。因为无法可靠区分密码与普通
// 文本(密码.app / 浏览器扩展复制都不打敏感标记),改以短 TTL 限制暴露面:
// 内容超过 TTL 后 GET 返回空,后台 sweeper 亦清除其 content。0 = 不过期。
ClipboardTTLSec int `koanf:"clipboard_ttl_sec"`
}
// Load reads config from optional ./config.yaml then overrides with CDROP_* env.
// Validates dev/prod invariants; returns error if invariants are violated.
func Load() (*Config, error) {
k := koanf.New(".")
k.Set("auth_mode", "prod")
k.Set("db_path", "./cdrop.db")
k.Set("listen", ":8080")
k.Set("device_ttl_hours", 196)
k.Set("oidc_scopes", "openid profile email")
k.Set("clipboard_max_bytes", 65536)
k.Set("clipboard_debounce_sec", 3)
k.Set("shortcut_token_ttl_days", 365)
k.Set("shortcut_max_per_user", 10)
if _, err := os.Stat("config.yaml"); err == nil {
if err := k.Load(file.Provider("config.yaml"), yaml.Parser()); err != nil {
return nil, fmt.Errorf("load config.yaml: %w", err)
}
}
envProvider := env.Provider(".", env.Opt{
Prefix: envPrefix,
TransformFunc: func(key, value string) (string, any) {
return strings.ToLower(strings.TrimPrefix(key, envPrefix)), value
},
})
if err := k.Load(envProvider, nil); err != nil {
return nil, fmt.Errorf("load env: %w", err)
}
cfg := &Config{}
if err := k.Unmarshal("", cfg); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err)
}
if err := cfg.validate(); err != nil {
return nil, err
}
return cfg, nil
}
func (c *Config) validate() error {
switch c.AuthMode {
case "dev":
if c.DevToken == "" {
return errors.New("CDROP_AUTH_MODE=dev requires CDROP_DEV_TOKEN; refusing to start")
}
case "prod":
// Audience MUST be set in prod (R6). With it empty, RS256 audience
// checking is skipped entirely, so ANY token the IdP mints for ANY
// application sharing this JWKS would validate here — a user of a
// sibling Casdoor app could impersonate a cdrop user. The web + desktop
// client_ids go in CDROP_OIDC_AUDIENCE (comma-separated); refuse to boot
// without it rather than run wide open.
if c.OIDCAudience == "" {
return errors.New(
"CDROP_AUTH_MODE=prod requires CDROP_OIDC_AUDIENCE " +
"(comma-separated OAuth client_ids); refusing to start")
}
default:
return fmt.Errorf("CDROP_AUTH_MODE must be \"dev\" or \"prod\", got %q", c.AuthMode)
}
return nil
}
+76
View File
@@ -0,0 +1,76 @@
package config
import (
"strings"
"testing"
)
func TestValidate_DevRequiresDevToken(t *testing.T) {
c := &Config{AuthMode: "dev", DevToken: ""}
err := c.validate()
if err == nil {
t.Fatal("dev mode without DevToken must reject; got nil error")
}
if !strings.Contains(err.Error(), "CDROP_DEV_TOKEN") {
t.Errorf("error must mention CDROP_DEV_TOKEN; got %q", err.Error())
}
}
func TestValidate_DevWithDevTokenOK(t *testing.T) {
c := &Config{AuthMode: "dev", DevToken: "x"}
if err := c.validate(); err != nil {
t.Fatalf("dev with token should pass; got %v", err)
}
}
func TestValidate_ProdOK(t *testing.T) {
c := &Config{AuthMode: "prod", OIDCAudience: "cdrop-web,cdrop-desktop"}
if err := c.validate(); err != nil {
t.Fatalf("prod mode with audience should pass; got %v", err)
}
}
func TestValidate_ProdRequiresAudience(t *testing.T) {
// R6: prod with an empty audience leaves RS256 audience checking off, so a
// token minted for any sibling app sharing the JWKS would validate. Refuse.
c := &Config{AuthMode: "prod", OIDCAudience: ""}
err := c.validate()
if err == nil {
t.Fatal("prod without CDROP_OIDC_AUDIENCE must reject; got nil error")
}
if !strings.Contains(err.Error(), "CDROP_OIDC_AUDIENCE") {
t.Errorf("error must mention CDROP_OIDC_AUDIENCE; got %q", err.Error())
}
}
func TestValidate_UnknownMode(t *testing.T) {
c := &Config{AuthMode: "rogue"}
err := c.validate()
if err == nil {
t.Fatal("unknown auth mode must reject")
}
}
func TestLoad_EnvOverridesDefaults(t *testing.T) {
t.Setenv("CDROP_AUTH_MODE", "dev")
t.Setenv("CDROP_DEV_TOKEN", "abc")
t.Setenv("CDROP_LISTEN", ":9090")
t.Setenv("CDROP_DB_PATH", "/tmp/x.db")
cfg, err := Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.AuthMode != "dev" {
t.Errorf("AuthMode: got %q, want %q", cfg.AuthMode, "dev")
}
if cfg.DevToken != "abc" {
t.Errorf("DevToken: got %q, want %q", cfg.DevToken, "abc")
}
if cfg.Listen != ":9090" {
t.Errorf("Listen: got %q, want %q", cfg.Listen, ":9090")
}
if cfg.DBPath != "/tmp/x.db" {
t.Errorf("DBPath: got %q, want %q", cfg.DBPath, "/tmp/x.db")
}
}
+103
View File
@@ -0,0 +1,103 @@
package db
import (
"context"
"database/sql"
_ "embed"
"fmt"
"net/url"
_ "modernc.org/sqlite"
)
//go:embed migrations/0001_init.sql
var initSchema string
// Open dials a sqlite db with WAL + busy timeout pragmas wired in.
// dbPath is the on-disk file path; passing ":memory:" works for tests.
func Open(dbPath string) (*sql.DB, error) {
dsn := buildDSN(dbPath)
d, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
if err := d.PingContext(context.Background()); err != nil {
_ = d.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
return d, nil
}
func buildDSN(dbPath string) string {
q := url.Values{}
q.Add("_pragma", "journal_mode(WAL)")
q.Add("_pragma", "busy_timeout(5000)")
q.Add("_pragma", "foreign_keys(1)")
return dbPath + "?" + q.Encode()
}
// Bootstrap runs the embedded init schema in a single transaction.
// Idempotent: every CREATE TABLE uses IF NOT EXISTS.
func Bootstrap(ctx context.Context, d *sql.DB) error {
tx, err := d.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin bootstrap tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, initSchema); err != nil {
return fmt.Errorf("exec init schema: %w", err)
}
// Additive migration for DBs created before clipboard_state.version existed:
// CREATE TABLE IF NOT EXISTS won't add the column to an existing table, and
// SQLite has no ADD COLUMN IF NOT EXISTS, so guard it with a pragma check.
if err := ensureColumn(ctx, tx, "clipboard_state", "version",
"ALTER TABLE clipboard_state ADD COLUMN version INTEGER NOT NULL DEFAULT 0"); err != nil {
return fmt.Errorf("migrate clipboard_state.version: %w", err)
}
if err := ensureColumn(ctx, tx, "clipboard_state", "origin_ts",
"ALTER TABLE clipboard_state ADD COLUMN origin_ts INTEGER NOT NULL DEFAULT 0"); err != nil {
return fmt.Errorf("migrate clipboard_state.origin_ts: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit bootstrap: %w", err)
}
return nil
}
// ensureColumn runs addSQL only when table lacks the named column — an idempotent
// additive migration for already-created tables.
func ensureColumn(ctx context.Context, tx *sql.Tx, table, column, addSQL string) error {
has, err := columnExists(ctx, tx, table, column)
if err != nil {
return err
}
if has {
return nil
}
_, err = tx.ExecContext(ctx, addSQL)
return err
}
func columnExists(ctx context.Context, tx *sql.Tx, table, column string) (bool, error) {
// table is a trusted compile-time constant, not user input.
rows, err := tx.QueryContext(ctx, "PRAGMA table_info("+table+")")
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var (
cid, notnull, pk int
name, ctype string
dflt sql.NullString
)
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
return false, err
}
if name == column {
return true, nil
}
}
return false, rows.Err()
}
+116
View File
@@ -0,0 +1,116 @@
package db
import (
"context"
"path/filepath"
"sort"
"testing"
)
func TestBootstrapCreatesAllTables(t *testing.T) {
tmp := filepath.Join(t.TempDir(), "test.db")
d, err := Open(tmp)
if err != nil {
t.Fatalf("open: %v", err)
}
defer d.Close()
if err := Bootstrap(context.Background(), d); err != nil {
t.Fatalf("bootstrap: %v", err)
}
want := []string{"clipboard_state", "devices", "shortcut_tokens", "transfer_sessions"}
rows, err := d.Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
if err != nil {
t.Fatalf("query tables: %v", err)
}
defer rows.Close()
var got []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatalf("scan: %v", err)
}
got = append(got, name)
}
sort.Strings(got)
if len(got) != len(want) {
t.Fatalf("table count: got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("table[%d]: got %q, want %q", i, got[i], want[i])
}
}
}
func TestBootstrapIsIdempotent(t *testing.T) {
tmp := filepath.Join(t.TempDir(), "test.db")
d, err := Open(tmp)
if err != nil {
t.Fatalf("open: %v", err)
}
defer d.Close()
for i := 0; i < 3; i++ {
if err := Bootstrap(context.Background(), d); err != nil {
t.Fatalf("bootstrap iter %d: %v", i, err)
}
}
}
func TestOpenEnablesWALMode(t *testing.T) {
tmp := filepath.Join(t.TempDir(), "test.db")
d, err := Open(tmp)
if err != nil {
t.Fatalf("open: %v", err)
}
defer d.Close()
var mode string
if err := d.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil {
t.Fatalf("pragma: %v", err)
}
if mode != "wal" {
t.Errorf("journal_mode: got %q, want %q", mode, "wal")
}
}
// Simulates the prod upgrade: a clipboard_state created before the version
// column existed must gain it (defaulting to 0) on bootstrap, idempotently.
func TestBootstrapAddsClipboardVersionToLegacyDB(t *testing.T) {
tmp := filepath.Join(t.TempDir(), "legacy.db")
d, err := Open(tmp)
if err != nil {
t.Fatalf("open: %v", err)
}
defer d.Close()
_, err = d.Exec(`CREATE TABLE clipboard_state (
user_id TEXT PRIMARY KEY, content_type TEXT NOT NULL, content TEXT,
source_device TEXT, updated_at INTEGER NOT NULL)`)
if err != nil {
t.Fatalf("legacy schema: %v", err)
}
if _, err := d.Exec(`INSERT INTO clipboard_state (user_id, content_type, content, updated_at)
VALUES ('u', 'text/plain', 'hi', 100)`); err != nil {
t.Fatalf("seed row: %v", err)
}
if err := Bootstrap(context.Background(), d); err != nil {
t.Fatalf("bootstrap: %v", err)
}
var version int64
if err := d.QueryRow(`SELECT version FROM clipboard_state WHERE user_id='u'`).Scan(&version); err != nil {
t.Fatalf("select version (column missing?): %v", err)
}
if version != 0 {
t.Errorf("legacy row version: got %d, want 0", version)
}
// Second bootstrap must be a no-op (column already present).
if err := Bootstrap(context.Background(), d); err != nil {
t.Fatalf("second bootstrap: %v", err)
}
}
+87
View File
@@ -0,0 +1,87 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: clipboard.sql
package db
import (
"context"
)
const clearExpiredClipboards = `-- name: ClearExpiredClipboards :execrows
UPDATE clipboard_state
SET content =
`
// 清除 updated_at 早于 cutoff 的剪贴板内容(content / source_device 置空),
// 由 sweeper 周期调用实现短 TTL;保留行本身、仅抹掉内容。
func (q *Queries) ClearExpiredClipboards(ctx context.Context, updatedAt int64) (int64, error) {
result, err := q.db.ExecContext(ctx, clearExpiredClipboards, updatedAt)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getClipboard = `-- name: GetClipboard :one
SELECT user_id, content_type, content, source_device, updated_at, version, origin_ts
FROM clipboard_state
WHERE user_id = ?
`
func (q *Queries) GetClipboard(ctx context.Context, userID string) (ClipboardState, error) {
row := q.db.QueryRowContext(ctx, getClipboard, userID)
var i ClipboardState
err := row.Scan(
&i.UserID,
&i.ContentType,
&i.Content,
&i.SourceDevice,
&i.UpdatedAt,
&i.Version,
&i.OriginTs,
)
return i, err
}
const upsertClipboard = `-- name: UpsertClipboard :one
INSERT INTO clipboard_state (user_id, content_type, content, source_device, updated_at, version, origin_ts)
VALUES (?, ?, ?, ?, ?, 1, ?)
ON CONFLICT (user_id) DO UPDATE SET
content_type = excluded.content_type,
content = excluded.content,
source_device = excluded.source_device,
updated_at = excluded.updated_at,
version = clipboard_state.version + 1,
origin_ts = excluded.origin_ts
WHERE excluded.origin_ts > clipboard_state.origin_ts
RETURNING version
`
type UpsertClipboardParams struct {
UserID string `json:"user_id"`
ContentType string `json:"content_type"`
Content *string `json:"content"`
SourceDevice *string `json:"source_device"`
UpdatedAt int64 `json:"updated_at"`
OriginTs int64 `json:"origin_ts"`
}
// New rows start version 1; conflicts bump version by 1 (per-user monotonic).
// LWW: the DO UPDATE only runs when the new origin_ts is strictly greater than
// the stored one, so a delayed / out-of-order older copy changes nothing and
// RETURNING yields no row (the service reads sql.ErrNoRows as "stale, rejected").
func (q *Queries) UpsertClipboard(ctx context.Context, arg UpsertClipboardParams) (int64, error) {
row := q.db.QueryRowContext(ctx, upsertClipboard,
arg.UserID,
arg.ContentType,
arg.Content,
arg.SourceDevice,
arg.UpdatedAt,
arg.OriginTs,
)
var version int64
err := row.Scan(&version)
return version, err
}
+31
View File
@@ -0,0 +1,31 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package db
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}
+98
View File
@@ -0,0 +1,98 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: devices.sql
package db
import (
"context"
)
const deleteDevice = `-- name: DeleteDevice :execrows
DELETE FROM devices
WHERE user_id = ? AND name = ?
`
type DeleteDeviceParams struct {
UserID string `json:"user_id"`
Name string `json:"name"`
}
func (q *Queries) DeleteDevice(ctx context.Context, arg DeleteDeviceParams) (int64, error) {
result, err := q.db.ExecContext(ctx, deleteDevice, arg.UserID, arg.Name)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const deleteStaleDevices = `-- name: DeleteStaleDevices :exec
DELETE FROM devices
WHERE last_seen < ?
`
func (q *Queries) DeleteStaleDevices(ctx context.Context, lastSeen int64) error {
_, err := q.db.ExecContext(ctx, deleteStaleDevices, lastSeen)
return err
}
const listDevicesByUser = `-- name: ListDevicesByUser :many
SELECT user_id, name, type, last_seen
FROM devices
WHERE user_id = ?
ORDER BY name
`
func (q *Queries) ListDevicesByUser(ctx context.Context, userID string) ([]Device, error) {
rows, err := q.db.QueryContext(ctx, listDevicesByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Device
for rows.Next() {
var i Device
if err := rows.Scan(
&i.UserID,
&i.Name,
&i.Type,
&i.LastSeen,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertDevice = `-- name: UpsertDevice :exec
INSERT INTO devices (user_id, name, type, last_seen)
VALUES (?, ?, ?, ?)
ON CONFLICT (user_id, name) DO UPDATE SET
type = excluded.type,
last_seen = excluded.last_seen
`
type UpsertDeviceParams struct {
UserID string `json:"user_id"`
Name string `json:"name"`
Type string `json:"type"`
LastSeen int64 `json:"last_seen"`
}
func (q *Queries) UpsertDevice(ctx context.Context, arg UpsertDeviceParams) error {
_, err := q.db.ExecContext(ctx, upsertDevice,
arg.UserID,
arg.Name,
arg.Type,
arg.LastSeen,
)
return err
}
+51
View File
@@ -0,0 +1,51 @@
-- cdrop MVP schema (PROJECT_BRIEF.md §4)
-- 以 IF NOT EXISTS 形式由 internal/db/bootstrap.go 启动期单事务执行。
CREATE TABLE IF NOT EXISTS devices (
user_id TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
last_seen INTEGER NOT NULL,
PRIMARY KEY (user_id, name)
);
CREATE TABLE IF NOT EXISTS shortcut_tokens (
jti TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
label TEXT NOT NULL,
scopes TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
last_used_at INTEGER,
revoked INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS transfer_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
sender_name TEXT NOT NULL,
receiver_name TEXT NOT NULL,
state TEXT NOT NULL,
mode TEXT,
file_name TEXT NOT NULL,
file_size INTEGER NOT NULL,
file_sha256 TEXT,
bytes_transferred INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
finished_at INTEGER,
fail_reason TEXT
);
CREATE TABLE IF NOT EXISTS clipboard_state (
user_id TEXT PRIMARY KEY,
content_type TEXT NOT NULL,
content TEXT,
source_device TEXT,
updated_at INTEGER NOT NULL,
-- version 是每用户严格单调递增的版本号(每次写 +1),变更探测 / ETag 用;
-- updated_at 仍是墙钟(TTL / 展示)。既有库由 bootstrap 的幂等 ALTER 补列。
version INTEGER NOT NULL DEFAULT 0,
-- origin_ts 是内容在源设备被复制的时刻(ms)。冲突收敛按它做 LWW——写入仅在
-- origin_ts 严格大于现存时被接受,故延迟/乱序到达的旧复制不会盖掉新的。
origin_ts INTEGER NOT NULL DEFAULT 0
);
+49
View File
@@ -0,0 +1,49 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package db
type ClipboardState struct {
UserID string `json:"user_id"`
ContentType string `json:"content_type"`
Content *string `json:"content"`
SourceDevice *string `json:"source_device"`
UpdatedAt int64 `json:"updated_at"`
Version int64 `json:"version"`
OriginTs int64 `json:"origin_ts"`
}
type Device struct {
UserID string `json:"user_id"`
Name string `json:"name"`
Type string `json:"type"`
LastSeen int64 `json:"last_seen"`
}
type ShortcutToken struct {
Jti string `json:"jti"`
UserID string `json:"user_id"`
Label string `json:"label"`
Scopes string `json:"scopes"`
CreatedAt int64 `json:"created_at"`
ExpiresAt int64 `json:"expires_at"`
LastUsedAt *int64 `json:"last_used_at"`
Revoked int64 `json:"revoked"`
}
type TransferSession struct {
ID string `json:"id"`
UserID string `json:"user_id"`
SenderName string `json:"sender_name"`
ReceiverName string `json:"receiver_name"`
State string `json:"state"`
Mode *string `json:"mode"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
FileSha256 *string `json:"file_sha256"`
BytesTransferred int64 `json:"bytes_transferred"`
CreatedAt int64 `json:"created_at"`
FinishedAt *int64 `json:"finished_at"`
FailReason *string `json:"fail_reason"`
}
+28
View File
@@ -0,0 +1,28 @@
-- name: UpsertClipboard :one
-- New rows start version 1; conflicts bump version by 1 (per-user monotonic).
-- LWW: the DO UPDATE only runs when the new origin_ts is strictly greater than
-- the stored one, so a delayed / out-of-order older copy changes nothing and
-- RETURNING yields no row (the service reads sql.ErrNoRows as "stale, rejected").
INSERT INTO clipboard_state (user_id, content_type, content, source_device, updated_at, version, origin_ts)
VALUES (?, ?, ?, ?, ?, 1, ?)
ON CONFLICT (user_id) DO UPDATE SET
content_type = excluded.content_type,
content = excluded.content,
source_device = excluded.source_device,
updated_at = excluded.updated_at,
version = clipboard_state.version + 1,
origin_ts = excluded.origin_ts
WHERE excluded.origin_ts > clipboard_state.origin_ts
RETURNING version;
-- name: GetClipboard :one
SELECT user_id, content_type, content, source_device, updated_at, version, origin_ts
FROM clipboard_state
WHERE user_id = ?;
-- name: ClearExpiredClipboards :execrows
-- 清除 updated_at 早于 cutoff 的剪贴板内容(content / source_device 置空),
-- 由 sweeper 周期调用实现短 TTL;保留行本身、仅抹掉内容。
UPDATE clipboard_state
SET content = NULL, source_device = NULL
WHERE content IS NOT NULL AND updated_at < ?;
+20
View File
@@ -0,0 +1,20 @@
-- name: UpsertDevice :exec
INSERT INTO devices (user_id, name, type, last_seen)
VALUES (?, ?, ?, ?)
ON CONFLICT (user_id, name) DO UPDATE SET
type = excluded.type,
last_seen = excluded.last_seen;
-- name: ListDevicesByUser :many
SELECT user_id, name, type, last_seen
FROM devices
WHERE user_id = ?
ORDER BY name;
-- name: DeleteStaleDevices :exec
DELETE FROM devices
WHERE last_seen < ?;
-- name: DeleteDevice :execrows
DELETE FROM devices
WHERE user_id = ? AND name = ?;
+32
View File
@@ -0,0 +1,32 @@
-- shortcut_tokens: long-lived, scope-limited, revocable HS256 tokens for the
-- iOS Shortcut clipboard sync. Count backs the per-user cap; Revoke is scoped by
-- user_id to block cross-user revocation; Touch records last use asynchronously.
-- name: InsertShortcutToken :exec
INSERT INTO shortcut_tokens (jti, user_id, label, scopes, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?);
-- name: GetShortcutToken :one
SELECT jti, user_id, label, scopes, created_at, expires_at, last_used_at, revoked
FROM shortcut_tokens
WHERE jti = ?;
-- name: ListShortcutTokensByUser :many
SELECT jti, user_id, label, scopes, created_at, expires_at, last_used_at, revoked
FROM shortcut_tokens
WHERE user_id = ?
ORDER BY created_at DESC;
-- name: CountActiveShortcutTokensByUser :one
SELECT COUNT(*) FROM shortcut_tokens
WHERE user_id = ? AND revoked = 0 AND expires_at > ?;
-- name: RevokeShortcutToken :execrows
UPDATE shortcut_tokens
SET revoked = 1
WHERE jti = ? AND user_id = ?;
-- name: TouchShortcutTokenUsed :exec
UPDATE shortcut_tokens
SET last_used_at = ?
WHERE jti = ?;
+30
View File
@@ -0,0 +1,30 @@
-- name: CreateTransferSession :exec
INSERT INTO transfer_sessions (
id, user_id, sender_name, receiver_name, state,
file_name, file_size, file_sha256, created_at
)
VALUES (?, ?, ?, ?, 'PENDING', ?, ?, ?, ?);
-- name: GetTransferSession :one
SELECT id, user_id, sender_name, receiver_name, state, mode,
file_name, file_size, file_sha256, bytes_transferred,
created_at, finished_at, fail_reason
FROM transfer_sessions
WHERE id = ?;
-- name: UpdateTransferState :execrows
UPDATE transfer_sessions
SET state = sqlc.arg('state'),
mode = COALESCE(sqlc.narg('mode'), mode),
fail_reason = COALESCE(sqlc.narg('fail_reason'), fail_reason),
finished_at = COALESCE(sqlc.narg('finished_at'), finished_at),
bytes_transferred = COALESCE(sqlc.narg('bytes_transferred'), bytes_transferred)
WHERE id = sqlc.arg('id');
-- name: ExpirePendingSessions :many
UPDATE transfer_sessions
SET state = 'CANCELLED',
fail_reason = 'pending_timeout',
finished_at = sqlc.arg('finished_at')
WHERE state = 'PENDING' AND created_at < sqlc.arg('cutoff')
RETURNING id, user_id, sender_name, receiver_name;
+153
View File
@@ -0,0 +1,153 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: shortcut_tokens.sql
package db
import (
"context"
)
const countActiveShortcutTokensByUser = `-- name: CountActiveShortcutTokensByUser :one
SELECT COUNT(*) FROM shortcut_tokens
WHERE user_id = ? AND revoked = 0 AND expires_at > ?
`
type CountActiveShortcutTokensByUserParams struct {
UserID string `json:"user_id"`
ExpiresAt int64 `json:"expires_at"`
}
func (q *Queries) CountActiveShortcutTokensByUser(ctx context.Context, arg CountActiveShortcutTokensByUserParams) (int64, error) {
row := q.db.QueryRowContext(ctx, countActiveShortcutTokensByUser, arg.UserID, arg.ExpiresAt)
var count int64
err := row.Scan(&count)
return count, err
}
const getShortcutToken = `-- name: GetShortcutToken :one
SELECT jti, user_id, label, scopes, created_at, expires_at, last_used_at, revoked
FROM shortcut_tokens
WHERE jti = ?
`
func (q *Queries) GetShortcutToken(ctx context.Context, jti string) (ShortcutToken, error) {
row := q.db.QueryRowContext(ctx, getShortcutToken, jti)
var i ShortcutToken
err := row.Scan(
&i.Jti,
&i.UserID,
&i.Label,
&i.Scopes,
&i.CreatedAt,
&i.ExpiresAt,
&i.LastUsedAt,
&i.Revoked,
)
return i, err
}
const insertShortcutToken = `-- name: InsertShortcutToken :exec
INSERT INTO shortcut_tokens (jti, user_id, label, scopes, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
`
type InsertShortcutTokenParams struct {
Jti string `json:"jti"`
UserID string `json:"user_id"`
Label string `json:"label"`
Scopes string `json:"scopes"`
CreatedAt int64 `json:"created_at"`
ExpiresAt int64 `json:"expires_at"`
}
// shortcut_tokens: long-lived, scope-limited, revocable HS256 tokens for the
// iOS Shortcut clipboard sync. Count backs the per-user cap; Revoke is scoped by
// user_id to block cross-user revocation; Touch records last use asynchronously.
func (q *Queries) InsertShortcutToken(ctx context.Context, arg InsertShortcutTokenParams) error {
_, err := q.db.ExecContext(ctx, insertShortcutToken,
arg.Jti,
arg.UserID,
arg.Label,
arg.Scopes,
arg.CreatedAt,
arg.ExpiresAt,
)
return err
}
const listShortcutTokensByUser = `-- name: ListShortcutTokensByUser :many
SELECT jti, user_id, label, scopes, created_at, expires_at, last_used_at, revoked
FROM shortcut_tokens
WHERE user_id = ?
ORDER BY created_at DESC
`
func (q *Queries) ListShortcutTokensByUser(ctx context.Context, userID string) ([]ShortcutToken, error) {
rows, err := q.db.QueryContext(ctx, listShortcutTokensByUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ShortcutToken
for rows.Next() {
var i ShortcutToken
if err := rows.Scan(
&i.Jti,
&i.UserID,
&i.Label,
&i.Scopes,
&i.CreatedAt,
&i.ExpiresAt,
&i.LastUsedAt,
&i.Revoked,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const revokeShortcutToken = `-- name: RevokeShortcutToken :execrows
UPDATE shortcut_tokens
SET revoked = 1
WHERE jti = ? AND user_id = ?
`
type RevokeShortcutTokenParams struct {
Jti string `json:"jti"`
UserID string `json:"user_id"`
}
func (q *Queries) RevokeShortcutToken(ctx context.Context, arg RevokeShortcutTokenParams) (int64, error) {
result, err := q.db.ExecContext(ctx, revokeShortcutToken, arg.Jti, arg.UserID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const touchShortcutTokenUsed = `-- name: TouchShortcutTokenUsed :exec
UPDATE shortcut_tokens
SET last_used_at = ?
WHERE jti = ?
`
type TouchShortcutTokenUsedParams struct {
LastUsedAt *int64 `json:"last_used_at"`
Jti string `json:"jti"`
}
func (q *Queries) TouchShortcutTokenUsed(ctx context.Context, arg TouchShortcutTokenUsedParams) error {
_, err := q.db.ExecContext(ctx, touchShortcutTokenUsed, arg.LastUsedAt, arg.Jti)
return err
}
+155
View File
@@ -0,0 +1,155 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: transfers.sql
package db
import (
"context"
)
const createTransferSession = `-- name: CreateTransferSession :exec
INSERT INTO transfer_sessions (
id, user_id, sender_name, receiver_name, state,
file_name, file_size, file_sha256, created_at
)
VALUES (?, ?, ?, ?, 'PENDING', ?, ?, ?, ?)
`
type CreateTransferSessionParams struct {
ID string `json:"id"`
UserID string `json:"user_id"`
SenderName string `json:"sender_name"`
ReceiverName string `json:"receiver_name"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
FileSha256 *string `json:"file_sha256"`
CreatedAt int64 `json:"created_at"`
}
func (q *Queries) CreateTransferSession(ctx context.Context, arg CreateTransferSessionParams) error {
_, err := q.db.ExecContext(ctx, createTransferSession,
arg.ID,
arg.UserID,
arg.SenderName,
arg.ReceiverName,
arg.FileName,
arg.FileSize,
arg.FileSha256,
arg.CreatedAt,
)
return err
}
const expirePendingSessions = `-- name: ExpirePendingSessions :many
UPDATE transfer_sessions
SET state = 'CANCELLED',
fail_reason = 'pending_timeout',
finished_at = ?1
WHERE state = 'PENDING' AND created_at < ?2
RETURNING id, user_id, sender_name, receiver_name
`
type ExpirePendingSessionsParams struct {
FinishedAt *int64 `json:"finished_at"`
Cutoff int64 `json:"cutoff"`
}
type ExpirePendingSessionsRow struct {
ID string `json:"id"`
UserID string `json:"user_id"`
SenderName string `json:"sender_name"`
ReceiverName string `json:"receiver_name"`
}
func (q *Queries) ExpirePendingSessions(ctx context.Context, arg ExpirePendingSessionsParams) ([]ExpirePendingSessionsRow, error) {
rows, err := q.db.QueryContext(ctx, expirePendingSessions, arg.FinishedAt, arg.Cutoff)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ExpirePendingSessionsRow
for rows.Next() {
var i ExpirePendingSessionsRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.SenderName,
&i.ReceiverName,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getTransferSession = `-- name: GetTransferSession :one
SELECT id, user_id, sender_name, receiver_name, state, mode,
file_name, file_size, file_sha256, bytes_transferred,
created_at, finished_at, fail_reason
FROM transfer_sessions
WHERE id = ?
`
func (q *Queries) GetTransferSession(ctx context.Context, id string) (TransferSession, error) {
row := q.db.QueryRowContext(ctx, getTransferSession, id)
var i TransferSession
err := row.Scan(
&i.ID,
&i.UserID,
&i.SenderName,
&i.ReceiverName,
&i.State,
&i.Mode,
&i.FileName,
&i.FileSize,
&i.FileSha256,
&i.BytesTransferred,
&i.CreatedAt,
&i.FinishedAt,
&i.FailReason,
)
return i, err
}
const updateTransferState = `-- name: UpdateTransferState :execrows
UPDATE transfer_sessions
SET state = ?1,
mode = COALESCE(?2, mode),
fail_reason = COALESCE(?3, fail_reason),
finished_at = COALESCE(?4, finished_at),
bytes_transferred = COALESCE(?5, bytes_transferred)
WHERE id = ?6
`
type UpdateTransferStateParams struct {
State string `json:"state"`
Mode *string `json:"mode"`
FailReason *string `json:"fail_reason"`
FinishedAt *int64 `json:"finished_at"`
BytesTransferred *int64 `json:"bytes_transferred"`
ID string `json:"id"`
}
func (q *Queries) UpdateTransferState(ctx context.Context, arg UpdateTransferStateParams) (int64, error) {
result, err := q.db.ExecContext(ctx, updateTransferState,
arg.State,
arg.Mode,
arg.FailReason,
arg.FinishedAt,
arg.BytesTransferred,
arg.ID,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
+247
View File
@@ -0,0 +1,247 @@
package httpapi
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
)
// PROJECT_BRIEF.md §2 keeps the browser path on standard OIDC PKCE. Backend
// proxies the token endpoint so the browser doesn't need a CORS-friendly OIDC
// provider; the access_token still flows back to the browser unchanged.
type authConfigResp struct {
AuthMode string `json:"auth_mode"`
AuthorizeURL string `json:"authorize_url"`
// TokenURL is published so native clients (the desktop app) can run their
// own loopback PKCE flow directly against the provider, reusing this same
// client_id. The browser doesn't need it — it proxies through /api/auth/exchange.
TokenURL string `json:"token_url"`
ClientID string `json:"client_id"`
RedirectURI string `json:"redirect_uri"`
Scopes string `json:"scopes"`
}
// handleAuthConfig publishes everything the frontend needs to start the PKCE
// authorize redirect. No auth required (it's all public OIDC metadata).
func (s *Server) handleAuthConfig(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, authConfigResp{
AuthMode: s.cfg.AuthMode,
AuthorizeURL: s.cfg.OIDCAuthorizeURL,
TokenURL: s.cfg.OIDCTokenURL,
ClientID: s.cfg.OIDCClientID,
RedirectURI: s.cfg.OIDCRedirectURI,
Scopes: s.cfg.OIDCScopes,
})
}
type exchangeReq struct {
Code string `json:"code"`
CodeVerifier string `json:"code_verifier"`
}
type tokenResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}
type userResp struct {
ID string `json:"id"`
Name string `json:"name"`
Avatar string `json:"avatar,omitempty"`
}
type exchangeResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
User userResp `json:"user"`
}
func (s *Server) handleAuthExchange(w http.ResponseWriter, r *http.Request) {
var req exchangeReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if req.Code == "" || req.CodeVerifier == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing code or code_verifier"})
return
}
if s.cfg.OIDCTokenURL == "" {
writeJSON(w, http.StatusInternalServerError,
map[string]string{"error": "OIDC token URL not configured"})
return
}
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", req.Code)
form.Set("code_verifier", req.CodeVerifier)
form.Set("client_id", s.cfg.OIDCClientID)
form.Set("redirect_uri", s.cfg.OIDCRedirectURI)
// Casdoor's "cdrop" application is a confidential client; PKCE is layered
// on top of the standard secret. Skip the secret if not configured to keep
// public-client OIDC providers happy.
if s.cfg.OIDCClientSecret != "" {
form.Set("client_secret", s.cfg.OIDCClientSecret)
}
tr, err := postOIDCToken(r.Context(), s.cfg.OIDCTokenURL, form)
if err != nil {
slog.Warn("oidc exchange failed", "err", err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
user, err := extractUser(tr.IDToken, tr.AccessToken)
if err != nil {
writeJSON(w, http.StatusBadGateway,
map[string]string{"error": "extract user: " + err.Error()})
return
}
writeJSON(w, http.StatusOK, exchangeResp{
AccessToken: tr.AccessToken,
RefreshToken: tr.RefreshToken,
ExpiresIn: tr.ExpiresIn,
User: user,
})
}
type refreshReq struct {
RefreshToken string `json:"refresh_token"`
}
type refreshResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
}
func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) {
var req refreshReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if req.RefreshToken == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing refresh_token"})
return
}
if s.cfg.OIDCTokenURL == "" {
writeJSON(w, http.StatusInternalServerError,
map[string]string{"error": "OIDC token URL not configured"})
return
}
form := url.Values{}
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", req.RefreshToken)
form.Set("client_id", s.cfg.OIDCClientID)
if s.cfg.OIDCClientSecret != "" {
form.Set("client_secret", s.cfg.OIDCClientSecret)
}
tr, err := postOIDCToken(r.Context(), s.cfg.OIDCTokenURL, form)
if err != nil {
slog.Warn("oidc refresh failed", "err", err)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, refreshResp{
AccessToken: tr.AccessToken,
RefreshToken: tr.RefreshToken,
ExpiresIn: tr.ExpiresIn,
})
}
var oidcHTTPClient = &http.Client{Timeout: 10 * time.Second}
func postOIDCToken(ctx interface{ Done() <-chan struct{} }, tokenURL string, form url.Values) (*tokenResp, error) {
req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := oidcHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("oidc unreachable: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("oidc status %d: %s", resp.StatusCode, truncate(string(body), 200))
}
var tr tokenResp
if err := json.Unmarshal(body, &tr); err != nil {
return nil, fmt.Errorf("oidc malformed: %w", err)
}
if tr.AccessToken == "" {
return nil, fmt.Errorf("oidc returned no access_token")
}
return &tr, nil
}
// extractUser parses {sub, preferred_username | name} from the id_token if
// available, else falls back to access_token. We DON'T verify the signature
// here — the backend's auth middleware verifies access_token on every API
// call via the JWKS cache, so any tamper would surface there.
func extractUser(idToken, accessToken string) (userResp, error) {
pick := idToken
if pick == "" {
pick = accessToken
}
parsed, err := jwt.ParseSigned(pick, []jose.SignatureAlgorithm{
jose.RS256, jose.RS384, jose.RS512,
jose.ES256, jose.ES384, jose.ES512,
jose.HS256,
})
if err != nil {
return userResp{}, err
}
var std jwt.Claims
custom := map[string]any{}
if err := parsed.UnsafeClaimsWithoutVerification(&std, &custom); err != nil {
return userResp{}, err
}
u := userResp{ID: std.Subject}
if v, ok := custom["preferred_username"].(string); ok && v != "" {
u.Name = v
} else if v, ok := custom["name"].(string); ok && v != "" {
u.Name = v
} else if v, ok := custom["email"].(string); ok && v != "" {
u.Name = v
} else {
u.Name = u.ID
}
// Casdoor 的 OIDC id_token 暴露 avatar(自定义) 与 picture(标准 claim) 两种字段;
// 不同 IdP 实现各异,所以都查一遍。值为空字符串视为未提供,前端落到字母方块回退。
if v, ok := custom["avatar"].(string); ok && v != "" {
u.Avatar = v
} else if v, ok := custom["picture"].(string); ok && v != "" {
u.Avatar = v
}
return u, nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}

Some files were not shown because too many files have changed in this diff Show More