Compare commits

6 Commits

Author SHA1 Message Date
admin 1a8db5686a changelog:2026-06-26 鉴权并入 Auth Broker(委派设备会话统一模型)+ 四端迁移 + 显示名/头像修复
- 新增 2026-06-26 条目:后端委托 Auth Broker(路径 A,删自建鉴权)/ 统一“委派设备会话”模型 / 设备管理回归修复 / X-Auth-Name·X-Auth-Avatar 端到端补显示名与头像(含桌面 HealIdentity 自愈陈旧身份)/ 四端迁移(对应 main 10cf36e)
- 重新构建 CHANGELOG.html
2026-06-26 22:17:31 +08:00
admin 2dbe6c6134 changelog:2026-06-23 P2P 传输修复 + CJK 文字累积修复 + 传输速度显示
- 新增 2026-06-23 条目:接收混合落盘 sink / 发送进度与完成语义 / DynText 文字累积 / 速度显示(对应 main 0451549)
- 既有条目直角引号「」统一改为简体弯引号“”(zh-Hans 规范;繁体 runtime 注释不动)
2026-06-23 15:27:55 +08:00
admin 05f9f291f6 docs:CHANGELOG 补 2026-06-22 账户与登录子系统
扫码快登 + 薄账户层 + OIDC 统一自签 token + 即时吊销 / 设备自知 + 设备并入
会话列表 + 2FA step-up。src + 重建的 html 同步。
2026-06-22 11:55:23 +08:00
admin c306daff92 docs:CHANGELOG 补 2026-06-18 推送通知(Web Push + 桌面原生)
- 网页 / PWA Web Push(VAPID)+ 桌面原生通知一条;HTML 与 md twin 同步,build.sh 重建
  CHANGELOG.html
2026-06-18 17:01:41 +08:00
admin b414c57b6a docs:CHANGELOG 补 2026-06-16 浏览器免重登 + 2026-06-15 PWA / Safari 修复
- 浏览器免重登:HttpOnly cookie + 服务端 web_sessions 会话表 + refresh_token
  AES-256-GCM 落盘 + 7 天滑动失活 + 设备名随会话持久化 + striped 锁防多 tab
  并发 refresh;前端 store 移除 refreshToken 字段;prod 强制 CDROP_SESSION_SECRET(e51666c)
- PWA 安装支持 + Safari OAuth 回跳卡顿(改整页跳转)修复(5c28ddf / 7082ccc /
  af754dc / 8f550ad)
- HTML 与 md twin 同步,build.sh 重建 CHANGELOG.html
2026-06-16 01:11:43 +08:00
admin 19f8f7588e docs: 架构文档与变更记录
cdrop 的架构 / 协议 / 鉴权设计(PROJECT_BRIEF)、前端设计(FRONTEND_DESIGN)、变更记录(CHANGELOG)。

公开发布初始提交:完整文档历史留存于私有归档,公开分支自此干净起步。
2026-06-15 21:52:15 +08:00
345 changed files with 7540 additions and 46192 deletions
-29
View File
@@ -1,29 +0,0 @@
# 构建上下文裁剪:镜像构建只需源码 + go.mod/go.sum + web 源 + package*.json。
# node_modules 尤其关键——本地是宿主(macOS)原生二进制(esbuild/rollup),若被
# COPY 进 linux 构建层会覆盖 cdrop-base 烤好的 linux node_modules,使 vite 构建
# 因平台不符而失败。依赖一律由 cdrop-base 的 npm ci / go mod download 提供。
.git
.gitignore
.gitmodules
.dockerignore
# 依赖与构建产物(由镜像内重新安装 / 生成)
**/node_modules
vendor
web/dist
dist
desktop/build
# 本机产物 / 草稿 / 数据库
.scratch
*.db
*.db-shm
*.db-wal
.env
**/.DS_Store
.DS_Store
# 服务端镜像用不到的子项目
ios
docs
-19
View File
@@ -1,19 +0,0 @@
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
-50
View File
@@ -1,50 +0,0 @@
# dev 模式(本机开发,brief 实施原则 5)
# 二者必须同时设置;后端启动期校验,缺一即 panic
CDROP_AUTH_MODE=dev
CDROP_DEV_TOKEN=replace-with-32-byte-random-base64
# prod 模式所需(Auth Broker 路径 A)。cdrop 把身份与会话生命周期委托给 broker:每个受控
# 请求在边缘由 broker /verify(经 Caddy)鉴权并注入 X-Auth-* 头;扫码批准 / 设备续期 / 吊销
# 经 broker 内部 + 公开 API。cdrop 不再自做 OIDC、不存任何凭证。
# CDROP_AUTH_MODE=prod
# broker 内网地址(docker 内网直连,绝不经公网反代——以保 /internal/* 与 /refresh 可达)。
# prod 强制非空。
# CDROP_BROKER_BASE_URL=http://broker-internal-ip:8080
# broker /internal/* 端点共享密钥(作 X-Internal-Key 发送)。prod 强制非空,向运维取、勿硬编码。
# CDROP_BROKER_INTERNAL_KEY=
# broker 公开源(浏览器全局 SSO 登录跳转 / 原生客户端设备授权流的目标)。留空则
# /api/auth/login 与原生登录不可用。
# CDROP_BROKER_PUBLIC_URL=https://sso.your-domain.example
# 本应用在 broker apps 注册表里的 key(默认 cdrop)。
# CDROP_BROKER_APP=cdrop
# 部署公开源(拼扫码 QR 链接 / CSRF Origin 校验 / Web Push 默认联系标识)。
# CDROP_PUBLIC_URL=https://drop.your-domain.example
# CDROP_TURN_URL=stun:your-stun.example:3478
# 可选:Web PushVAPID)推送通知。用 `just vapid-keygen` 生成一对密钥;二者须同时
# 设置(半对即启动期校验失败),都留空则推送惰性关闭、不阻塞启动。生成后固定不变
# ——更换会使所有既有浏览器订阅失效。
# CDROP_VAPID_PUBLIC_KEY=
# CDROP_VAPID_PRIVATE_KEY=
# CDROP_VAPID_SUBJECT=mailto:you@your-domain.example
# 可选:扫码登录(QR)+ 按档令牌 TTL(批准后经 broker 委托签发对应 tier 的会话)。下列均有
# 默认值,不配即用默认。
# CDROP_QR_LOGIN_ENABLED=true # 关闭则 /api/auth/qr/* 返回 503
# CDROP_QR_REQUEST_TTL_SECONDS=120 # 二维码 / 登录请求有效期(秒)
# CDROP_FULL_ACCESS_TTL_SECONDS=900 # full 设备 access token TTL(秒)
# CDROP_FULL_REFRESH_TTL_SECONDS=604800 # full 设备 refresh TTL(秒,7 天,滑动)
# CDROP_GUEST_ACCESS_TTL_SECONDS=900 # guest(访客借用)access token TTL(秒)
# CDROP_GUEST_REFRESH_TTL_SECONDS=86400 # guest refresh TTL(秒,1 天,滑动)
# 通用
CDROP_DB_PATH=./cdrop.db
CDROP_LISTEN=:8080
# 设备行最大存活(鉴权中间件 TouchDevice 与 SSE keepalive 滑动刷新 last_seen)。
# 不应短于 broker 会话的 refresh 窗口(full 7 天)。默认 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
@@ -1,57 +0,0 @@
# 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
@@ -1,3 +0,0 @@
[submodule "web/src/lib/cjk-autospace"]
path = web/src/lib/cjk-autospace
url = https://git.commilitia.net/admin/cjk-autospace.git
-291
View File
@@ -1,291 +0,0 @@
# cdrop 账户与登录实施计划
> 内置账户层 · OAuth 为账户来源与凭证提供者 · cdrop 自维护薄账户数据与自签会话 · 扫码快登优先
> 状态:**规划中**。本文是折中架构定稿后的实施蓝本——把边界、数据模型、令牌架构、扫码流程、里程碑讲清楚,按里程碑分批落地。
> 关联:根 `README.md``internal/jwtauth`(认证中间件 / verify 链 / JWKS)、`internal/httpapi/session.go`(浏览器免重登 session)、`internal/httpapi/shortcut.go`HS256 scoped token)、`internal/db/migrations`。记忆 `cdrop-state` / `cdrop-next-phase` / `cdrop-security-scan`。
---
## 0. 定位、折中架构与关键决策
### 折中:OAuth 为体的凭证、cdrop 为体的会话
三方在 2026-06-21 收敛到一个折中位置,介于“OIDC 锚定身份”(原方案)与“内置账号系统取代 OIDC”(全反转)之间:
1. **身份来源仍是 OAuth provider**(当前自建 Casdoor)。`user_id` 继续等于 OIDC `sub`,cdrop 不引入内部 id 间接层。
2. **凭证机制全部委派给 provider**——账号密码、Passkey、2FA、密码重置、注册,cdrop 一律**不实现**。Casdoor 本就提供这些,cdrop 发起 OAuth 跳转即可复用。
3. **cdrop 只在其上自维护薄薄一层**:一张 `accounts` 表(cdrop 侧账户数据,不含任何凭证)+ 一套**自签会话令牌**(脱离 IdP refresh 流程,供扫码 / 访客 / 持久设备会话使用)。
4. **产品化时加一个内建、可启用的 OAuth 后端**。cdrop 消费侧保持“只认一个 OIDC provider”,该后端是 drop-in:今天 provider 是外部 Casdoor,将来翻开关变成内建的。账号密码 / Passkey / 2FA 那些机制届时才进 cdrop 代码库,但被隔离在 provider 模块里。
一句话:**凭证是 provider 的事,会话是 cdrop 的事,账户数据两边各持一薄层。**
### 决策记录
- **决策 A — 折中架构(已定)**:见上。否决“全反转”,因其令 cdrop 成为凭证保管者(argon2id / 防爆破 / 锁定 / 枚举防御 / 无邮件密码重置),攻击面与维护成本过高;这些 Casdoor 已成熟承担。
- **决策 B — `user_id` 继续 = OIDC `sub`,不引内部 id**。新增薄 `accounts` 表(键在 `sub`),其中存一个**稳定匹配键**(默认取 JWT 的 `email` claim)。日常切换 OAuth provider 即账号失效——**运维须知此前提**。仅当管理员显式启用“后端迁移标记”时,cdrop 才在新源登录时按该匹配键自动关联回同一账号(受控窗口,非日常自动 link,规避账号接管)。
- **决策 C — 2FA 委派给 provider**。cdrop 不自存任何第二因子密钥,仅保留敏感动作的 **step-up 钩子**(要求 provider 再认证),默认关闭。
- **决策 D — 扫码方向 = 陌生设备显码、已登录手机扫码批准**(微信 / WhatsApp 网页版模型)。陌生设备常无摄像头但有屏,最贴合“临时借设备传文件”。
- **决策 E — 扫码会话两档,scope 与持久性绑定**。批准时二选一:“信任此设备(完整会话,7 天滑动)”——授予 scope=full、kind=self,可完整使用本账号(自有设备用);“仅此次(受限访客,1 小时)”——授予 scope=guest、kind=guest,能收发文件与消息,但不能改账号、不能再批准别的设备、不能签发长效 token(陌生/借用设备用)。默认偏向更安全的“仅此次”。(原设计两档皆 guest、仅持久性不同;2026-06-21 实测后改为 scope 随档绑定,消除“信任却受限”的语义矛盾。)
> 本文取代 `.scratch/auth-inversion-diff.html` 中按“全反转”写的 §四 大半——那五个“凭证保管者”决策(无邮件重置 / 开放注册 / argon2id 等)在折中下随委派 provider 而消散。
---
## 1. 设计边界:cdrop 建什么 / 委派什么
| 能力 | 归属 | 说明 |
|---|---|---|
| 账号密码登录 | **provider** | Casdoor 登录页提供;cdrop 只发起 OIDC PKCE 跳转 |
| Passkey / WebAuthn 登录 | **provider** | 同上,provider 登录页内完成 |
| 2FATOTP / 短信 / passkey-as-2FA | **provider** | cdrop 仅保留 step-up 钩子(决策 C |
| 密码重置 / 找回 | **provider** | Casdoor 邮件流;cdrop 无 SMTP 设施,不介入 |
| 注册 / 开放注册策略 | **provider** | Casdoor 注册配置 |
| 账号枚举 / 防爆破 / 锁定 | **provider** | 凭证侧防护全在 provider |
| **扫码快速登录** | **cdrop** | 唯一的纯新增功能(§4 |
| **自签会话令牌** | **cdrop** | 脱离 IdP refresh,供扫码 / 访客 / 持久会话(§3) |
| **cdrop 侧账户数据** | **cdrop** | `accounts` 薄表:匹配键 / 显示名 / 头像 / 角色缓存(§2) |
| 设备列表 / 在线状态 | **cdrop** | 已有,沿用,键在 `user_id` |
| 会话 / 设备管理与吊销 | **cdrop** | 管理 cdrop 自签会话(§3、§4) |
**Provider 抽象边界**cdrop 认证消费侧(`/api/auth/config`、PKCE exchange、JWKS 验签)只依赖“一个 OIDC provider”的标准面,不内嵌任何 provider 专有逻辑。这是内建后端(§7)能非破坏性接入的前提。
---
## 2. 数据模型
### 2.1 新增 `accounts`(薄账户数据层)
```sql
CREATE TABLE IF NOT EXISTS accounts (
user_id TEXT PRIMARY KEY, -- = OIDC sub,与既有各表的 user_id 一致
match_key TEXT NOT NULL DEFAULT '', -- 稳定匹配键(默认 email claim),供迁移标记关联
display_name TEXT NOT NULL DEFAULT '',
avatar_url TEXT NOT NULL DEFAULT '',
roles TEXT NOT NULL DEFAULT '', -- OIDC groups claim 缓存(逗号分隔),admin 判定用
provider TEXT NOT NULL DEFAULT '', -- 来源标识(如 "casdoor"),迁移时区分新旧源
created_at INTEGER NOT NULL,
last_login_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_accounts_match_key ON accounts (match_key);
```
- **写入时机**OIDC exchange 成功后 upsert(捕获 `match_key` / 显示名 / 头像 / `roles`,刷新 `last_login_at`)。
- **不含任何凭证**——密码 / 第二因子全在 provider。
- `match_key` 仅在“迁移标记”开启时被读作 join key(§7),日常不参与登录判定。
### 2.2 泛化 `web_sessions` → 支持自签会话
既有 `web_sessions` 只承载“绑定 IdP refresh_token 的浏览器会话”。扫码 / 访客会话拿不到 IdP refresh_token,需泛化。新增列(`bootstrap.go` 幂等 ALTER 补列,既有行视为 `kind='oidc'``scope='full'`):
```sql
ALTER TABLE web_sessions ADD COLUMN kind TEXT NOT NULL DEFAULT 'oidc'; -- oidc | self | guest
ALTER TABLE web_sessions ADD COLUMN scope TEXT NOT NULL DEFAULT 'full'; -- full | guest
ALTER TABLE web_sessions ADD COLUMN granted_by TEXT NOT NULL DEFAULT ''; -- 扫码批准者的 session id(审计 / 连带吊销)
-- refresh_token 改为“可空语义”:self / guest 会话无 IdP refresh_token,存空串
```
| kind | refresh_token | refresh 行为 | 用途 |
|---|---|---|---|
| `oidc` | AES-GCM 密文 | 向 IdP 续 + 自签 access tokenIdP 拒绝则作废(保留 IdP 吊销传播) | 正常浏览器登录 |
| `self` | 空 | 仅按 session 行自签 access token,不触 IdP | 桌面 / 持久设备(未来)、扫码“信任” |
| `guest` | 空 | 同 `self`,但 `scope='guest'` 受限 | 扫码“仅此次” / 受限借用设备 |
### 2.3 新增 `login_requests`(扫码)
```sql
CREATE TABLE IF NOT EXISTS login_requests (
id TEXT PRIMARY KEY, -- request_idQR 与批准用
poll_secret TEXT NOT NULL, -- SHA-256(新设备私有轮询密钥),QR 不含,仅新设备持有
approval_code TEXT NOT NULL, -- 短码,随 QR 给批准方
status TEXT NOT NULL, -- pending | approved | denied | consumed | expired
new_device_name TEXT NOT NULL,
new_device_type TEXT NOT NULL,
user_agent TEXT NOT NULL DEFAULT '',
request_ip TEXT NOT NULL DEFAULT '',
approver_user_id TEXT NOT NULL DEFAULT '', -- 批准后填
grant_scope TEXT NOT NULL DEFAULT '', -- full | guest
grant_persist TEXT NOT NULL DEFAULT '', -- once | persist
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL, -- 短 TTL(默认 120s
approved_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_login_requests_expires ON login_requests (expires_at);
```
后台 reaper 清 `expires_at < now`(复用 `RunWebSessionReaper` 同款 goroutine)。
### 2.4 不动的表
`devices` / `shortcut_tokens` / `transfer_sessions` / `clipboard_state` / `push_subscriptions` 全部只键在 `user_id`,**一字不改**。这是折中改造面可控的根本原因——应用主体被 `user_id` 抽象隔离。
---
## 3. 令牌与会话架构
### 3.1 cdrop 自签 access token
现有架构:cookie`web_sessions`,窄 `Path=/api/auth`)→ `/auth/refresh` 签发 access token → JS 内存持有 → 作 Bearer 打 `/api/*`。折中改动**只在“access token 由谁签”**:从“IdP 签的 RS256”改为“**cdrop 自签的 HS256**”。
- **签名密钥**:从 `SESSION_SECRET` 派生独立 HMAC 密钥(`DeriveSessionTokenKey(SESSION_SECRET)`,与既有 `DeriveHS256Key(HS256Secret)` 区分密钥域)。`SESSION_SECRET` 在 prod 本就强制非空,无新增必填密钥。
- **载荷**`{ sub: user_id, sid: session_id, typ: "session", scope: "full"|"guest", iat, exp }`。短 TTL(默认 900s)。
- **校验**:中间件新增 `verifySelfToken` 分支(§9)。靠 `typ:"session"` + 独立签名密钥与 scoped shortcut token`typ` 缺省、`scope:"clipboard"`、走 `shortcut_tokens` 查吊销)区分,互不串验。
- **吊销 / 撤销(即时)**:长效凭证是 `web_sessions` 行;access token 短命且**绑定 `sid`**。`verifySelfToken` 每请求按 `sid` 查 session 行——行被删除(吊销)即当场拒绝,故吊销在被吊销设备的**下一次请求**即生效,而非等一个 TTL。`oidc` 会话 refresh 仍打 IdP,保留 IdP 侧登出 / 吊销传播。
- **被吊销设备的感知与回退**:服务端拒绝之外,前端把“访问被拒且续期也被拒”视作会话不可恢复 → 就地清空登录态并回到登录页(`net/api.ts``forceLogout` + `__root` 反应式守卫)。**离线设备**于下一次使用(请求 401 或开机 cookie 续期失败)得知;**在线设备**经 `Hub.Kick` 关流 → 约 1s 后重连撞 401 得知。两路殊途同归到 `verifySelfToken``sid` 查行。
> RS256IdP access token)已退出浏览器每请求热路径:浏览器登录(含 OIDC)一律持自签 tokenOIDC exchange 时验 `id_token` 签名一次(§5)作唯一信任锚,refresh 仍打 IdP 轮换 refresh_token 但同样回自签 token。中间件保留 RS256 分支仅供**桌面端**(自跑 loopback PKCE、直带 IdP token);桌面端即时吊销留后续。
### 3.2 受限能力门(guest 会话)
复用既有 `rejectScoped`(挡 scoped shortcut token 进非 clipboard 路由)的同款思路,新增 `requireFullSession` 守卫。`scope:"guest"` 的会话:
- **放行**`/clipboard``/transfer/*``/relay/*``/message``/hub/*``/devices`(看列表)、`/me``/push/*`
- **拒绝**`/auth/qr/approve`(再批准设备)、`/shortcut/*`(签长效 token)、`/devices/{name}` DELETE 别的设备、未来账号设置 / 会话管理。
能力矩阵随路由分组实现,集中在 `server.go` 中间件层(§9)。
---
## 4. 扫码登录(优先里程碑 B)
陌生设备显码、已登录手机扫码批准。**三方密钥分离**是安全核心:QR 可被偷拍,故 QR 里只放“能发起批准请求”的信息,而会话只投递给持有私有轮询密钥的原始新设备。
### 4.1 流程
1. **新设备发起**`POST /api/auth/qr/start { device_name, device_type }`(公开、限流)→ 服务端建 `login_requests` 行,返回 `{ request_id, poll_secret, qr_payload }`
- `poll_secret`:新设备**私有**,仅此响应返回一次,QR 不含。
- `qr_payload``request_id` + `approval_code`,编码进二维码展示。
2. **新设备轮询**`GET /api/auth/qr/status?request_id=…`HTTP header 带 `poll_secret`(公开、限流)。`pending` 时长轮询 / SSE 挂起等待。
3. **手机扫码**:扫得 `qr_payload` → 打开 `/link?request_id=…&code=…`。**须已登录**(否则先走正常 OAuth 登录)。页面显示“批准 <设备名>·<类型>·<IP 粗粒度> 登入你的账号?”,可选“信任此设备 7 天”或“仅此次 1 小时”,再点【批准】/【拒绝】。
4. **手机批准**`POST /api/auth/qr/approve { request_id, approval_code, scope, persist }`**完整会话鉴权 + `requireFullSession`**,可选 step-up)。服务端校验请求 `pending` 且未过期、`approval_code` 匹配 → 建 `web_sessions` 行(`user_id=批准者``kind=self|guest``scope``expires_at``persist``granted_by=批准者 sid`)→ 标 `login_requests``approved`
5. **新设备领取**:下一次 `qr/status` 轮询(凭 `poll_secret`)见 `approved` → 响应 `Set-Cookie`(新设备自己的 TLS 连接)下发 session cookie + 返回首枚 access token → 标 `consumed`(单次)。
**手机全程只发送批准、从不接触新设备的 session 密钥**;新设备的 cookie 只经其自身轮询连接下发。
### 4.2 安全要点
- `approval_code` / `poll_secret` 高熵随机;`login_requests` 短 TTL(默认 120s),`consumed` / `expired` 后不可复用。
- 偷拍 QR 者:无 `poll_secret`(领不到会话)、未登录(批不了准),双重失败。
- 访客会话受 §3.2 能力门约束;`granted_by` 支持“吊销我批准过的借用设备”与连带吊销。
- 批准动作可挂 step-up(§6)。
### 4.3 端点小结
| 方法 路径 | 鉴权 | 作用 |
|---|---|---|
| `POST /api/auth/qr/start` | 公开 + 限流 | 新设备建请求,返 `request_id` / `poll_secret` / `qr_payload` |
| `GET /api/auth/qr/status` | `poll_secret``X-Poll-Secret` 头)+ 限流 | 新设备长轮询,批准后**在本响应** Set-Cookie + 返回 access token,并标 `consumed`(单次) |
| `GET /api/auth/qr/request` | 完整会话 + `approval_code` | 批准方读新设备信息(名 / 类型 / 来源 IP)以渲染确认页 |
| `POST /api/auth/qr/approve` | 完整会话 + `requireFullSession` | 手机批准,记录 approver / scope / persist(会话在新设备领取时才建) |
| `POST /api/auth/qr/deny` | 完整会话 | 手机拒绝 |
前端:新设备“显码页”+ 手机“批准页”+ 设备 / 会话管理(列出 `granted_by` 借用设备、可吊销)。
### 4.4 登录会话列表 = 全部设备(统一视图 + 自动清除)
「登录会话」面板收敛到“一台设备 = 一条会话”:列表即当前登录此账号的全部设备。既往“手动清除设备”入口已撤,导致被吊销设备长期滞留——本节即为此消除。
- **统一列表**`GET /api/auth/sessions`):`web_sessions`(浏览器 oidc / self / guest、扫码)**并入**原生客户端设备(桌面 / iOS,`type ∈ {macos, windows, linux, ios}`)。原生客户端用 IdP 令牌、不入 `web_sessions`,故从设备登记表 `devices`(即决策里说的“等价表”)呈现:`id` 形如 `device:<设备名>``kind` 取设备类型、`native=true`。同名已有会话的设备不重复列出;无会话的 `browser` 型(孤儿)与 `shortcut` 型(自有面板)不在此列;过期会话已隐藏。这满足“原生设备也在会话列表登记”,且离线原生设备照常显示(`devices` 留有 `last_seen`)。
- **吊销即清设备**:吊销网页会话(`DELETE /api/auth/sessions/{id}`)在删会话行后**连带删除其设备登记**(除非另有同名在用会话),再 Kick + 广播 presence → 被吊销设备即时从所有列表消失。原生客户端的“登出”改走 `DELETE /api/devices/{name}`(同样要求 step-up),删设备 + Kick;该设备若仍在线,会在下次请求经中间件重新登记(原生**即时**吊销需客户端配合,留后续)。
- **孤儿自动清除**:设备清扫器(每 1h)除按 `last_seen` TTL 清陈旧设备外,新增清除“`browser` 型且无存活 `web_session`”的孤儿(会话已撤 / 过期但设备行滞留);`DeleteOrphanBrowserDevices` 一条 SQL 完成,原生 / shortcut 设备无 `web_session` 属正常、仅受 TTL 清扫。即时路径(吊销)+ 周期路径(清扫)合起来让设备列表与会话列表持续对齐。
---
## 5. 账号密码 / Passkey 登录(委派 provider
几乎零 cdrop 新增——复用既有 OIDC PKCE
- cdrop `/api/auth/config` 已暴露 `authorize_url` 等;前端“登录”按钮发起跳转,**Casdoor 登录页**呈现账号密码 / Passkey / 2FA 表单。
- 回调 → `/api/auth/exchange`(既有)→ 解析 token。**折中下的唯一安全强化**:`id_token` 必须在此**验签**(JWKS)——这是 cdrop 信任 OIDC 的唯一时刻(原方案故意不验、靠后续每请求 JWKS 兜底;自签 access token 后那层兜底消失)。
- exchange 成功 → upsert `accounts`(§2.1,捕获 `match_key` 等)→ 建 `kind='oidc'` 会话 → 自签首枚 access token。
“确保 Casdoor 启用了账号密码 / Passkey / 2FA”是部署配置项,非 cdrop 代码。
---
## 6. 2FA(委派 + step-up 钩子,已实装)
- **默认**2FA 在 provider 完成(Casdoor TOTP / passkey-as-2FA),cdrop 不感知、不存密钥。
- **step-up 钩子**(决策 C,默认关,`CDROP_STEP_UP_ENABLED`;新鲜度窗口 `CDROP_STEP_UP_MAX_AGE_SECONDS`,默认 300):开启后,批准扫码设备(`qr/approve`)这类敏感动作要求一次**现场再认证**。机制:前端先跑一次带 `prompt=login` / `max_age=0` 的新鲜 PKCE,把拿到的授权码 + verifier 交给 `qr/approve`;后端**就地**用它向 IdP 换 `id_token`JWKS 验签 + 校验 `sub` 等于调用者,方才放行(provider 若开了 2FA,即在这次 prompt=login 往返中强制)。`auth_time` 为**可选**——OIDC 仅在 IdP 选择遵循 `max_age` 时才要求它,**Casdoor 不下发**;有则强制 `StepUpMaxAgeSeconds` 窗口,无则以「单次短寿授权码刚换出」作为新鲜度界(否则会因缺 `auth_time` 而恒拒、陷死循环,2026-06-21 实测修正)。失败返回 `403 step_up_required`。授权码一次性、不可重放;cdrop 不存任何第二因子,也不留 step-up 服务端状态——证明随这一次批准请求过期。
- `qr/request` 响应回带 `step_up` 标志,供批准页判断是否需先再认证。
- passkey 作为“快速登录”的补充:陌生设备场景下扫码优于 passkey(passkey 设备绑定,陌生设备上没有),故 passkey 由 provider 承担、不进 cdrop 自建范围。
---
## 7. 内建 OAuth 后端(产品化,远期)
目标:自托管者不必跑外部 Casdoor。设计为 **drop-in provider**,不改 cdrop 消费侧。
- **形态**:cdrop 内嵌(或同容器旁挂)一个最小 OIDC provider 模块,提供账号密码 / Passkey / 2FA / 注册 / 重置。凭证机制届时才进 cdrop 代码库,但**隔离在 provider 模块**,主体仍只认标准 OIDC 面。
- **启用**`CDROP_BUILTIN_OAUTH_ENABLED=true``OIDC_*` 指向内建端点。
- **迁移标记**(决策 B):管理员置 `CDROP_OIDC_MIGRATION_MODE=true` 后,新源登录时按 `accounts.match_key`(默认 email)匹配既有账号并**自动改键关联**(把旧 `sub` 的数据迁到新 `sub`,或登记别名)。受控、可关闭,非日常自动 link。
- 本里程碑不在此展开内建后端内部设计——仅锁定“provider 抽象边界”这一前提,确保现在的实现不堵死将来。
---
## 8. 配置项(“均可配置关闭”)
| 键 | 默认 | 作用 |
|---|---|---|
| `CDROP_QR_LOGIN_ENABLED` | `true` | 扫码登录总开关(关闭则 `qr/*` 返 404 / 503 |
| `CDROP_QR_REQUEST_TTL_SECONDS` | `120` | 二维码 / 登录请求有效期 |
| `CDROP_QR_GUEST_TTL_SECONDS` | `3600` | “仅此次”访客会话 TTL |
| `CDROP_QR_PERSIST_TTL_HOURS` | `168` | “信任此设备”持久会话 TTL(与设备 TTL 一致) |
| `CDROP_SESSION_TOKEN_TTL_SECONDS` | `900` | cdrop 自签 access token TTL |
| `CDROP_SESSION_SECRET` | (prod 必填) | 既有;现额外派生自签会话签名密钥 |
| `CDROP_STEP_UP_ENABLED` | `false` | 批准扫码设备等敏感动作要求 provider 再认证 |
| `CDROP_STEP_UP_MAX_AGE_SECONDS` | `300` | step-up 的 `auth_time` 新鲜度窗口(秒) |
| `CDROP_ACCOUNT_MATCH_CLAIM` | `email` | `accounts.match_key` 取哪个 JWT claim |
| `CDROP_BUILTIN_OAUTH_ENABLED` | `false` | 远期:启用内建 OAuth 后端 |
| `CDROP_OIDC_MIGRATION_MODE` | `false` | 远期:按 `match_key` 关联跨源账号 |
账号密码 / Passkey / 2FA / 注册 / 重置的开关在 **provider**Casdoor)侧,非 cdrop env。
---
## 9. 认证中间件 `verify()` 扩展
现链:`dev → HS256(scoped shortcut) → RS256(OIDC)`。折中后:
```
verify(token):
dev 模式 → verifyDev
自签会话 HS256 → verifySelfToken ← 新增(typ=sessionSESSION_SECRET 派生密钥)
scoped shortcut HS256 → verifyHS256 ← 既有(typ 缺省,HS256Secret 派生,查 shortcut_tokens
OIDC RS256 → verifyRS256 ← 既有,保留作兼容(桌面直带 IdP token)
```
- 凭据来源新增:cookie(既有,`/auth/refresh` 用)之外,自签 access token 作 Bearer 走 `verifySelfToken`
- 路由守卫两层叠加:`requireScope("clipboard")`(既有,scoped token);`requireFullSession`(新增,挡 `scope=guest` 进敏感路由,§3.2)。
- `accounts.roles` 缓存供 admin 判定(既有“admin 仅 groups claim”语义保留,读 `accounts.roles` 而非每次解 token)。
---
## 10. 里程碑
| 里程碑 | 内容 | 阶段 |
|---|---|---|
| **A0** 自签会话原语 | `SESSION_SECRET` 派生会话签名密钥;自签 access token 签发 / 校验(`verifySelfToken`);`web_sessions``kind` / `scope` / `granted_by` 列,refresh 按 `kind` 分流;既有 `oidc` 会话**零行为变化** | 阶段一基座 |
| **A1** `accounts` 薄表 | 建表;exchange 时 upsert`match_key` / 显示名 / 头像 / `roles`);`id_token` exchange 时验签;`/me`、admin 判定改读 `accounts` | 阶段一 |
| **B** 扫码登录(优先) | `login_requests` 表;`qr/start` `qr/status` `qr/approve` `qr/deny`;访客 / 持久会话;`requireFullSession` 能力门;前端显码页 + 批准页 + 设备 / 会话吊销 | 阶段一(端到端,drop.commilitia.net 即可上线) |
| **C** 2FA step-up 钩子 | 敏感动作要求 provider 再认证(`prompt=login` / `max_age`);默认关 | 阶段三 |
| **D** 内建 OAuth 后端 | provider 抽象 drop-in;凭证机制隔离模块;迁移标记按 `match_key` 关联 | 远期 / 产品化 |
> 折中下“阶段二(账号密码登录)”几乎无 cdrop 工作量——账号密码 / Passkey 本就是 provider 提供,A1 的 `accounts` 落地即覆盖其 cdrop 侧需求。重心在阶段一的 **A0 + A1 + B**:自签会话原语 + 薄账户表 + 扫码,先在现有 Casdoor 部署上端到端可用,且对老用户零行为变化。
---
## 11. 安全要点
- **`id_token` 必须 exchange 时验签**——自签 access token 后,原“每请求 JWKS 兜底”消失,OIDC 信任收敛到这唯一一刻。
- **吊销即时**`verifySelfToken` 每请求按 `sid``web_sessions` 行,删行即当场拒绝,被吊销设备下一次请求即失效(不依赖 TTL);前端据 401 + 续期失败回退登录页(§3.1)。自签 access token 短 TTL(默认 900s)只是续期节律,长效凭证只在 `web_sessions` 行。
- **扫码三密钥分离**:QR 仅含批准信息,会话只投私有 `poll_secret` 持有者;偷拍 QR 双重失败。
- **访客会话能力门**`scope=guest` 服务端强制受限(§3.2),不依赖前端自律。
- **迁移标记受控**:跨源 `match_key` 关联仅在管理员显式开启时生效,非日常自动 link,规避 email 接管。
- **密钥域分离**:自签会话密钥(`SESSION_SECRET` 派生)与 scoped shortcut 密钥(`HS256Secret` 派生)不同域,互不串验。
- **限流**`qr/start` / `qr/status` 纳入既有 `/api/auth` 限流桶(60/min per-IP)或单独更严桶。
---
## 12. 不变的部分
设备模型、SSE Hub、传输状态机、剪贴板、Web Push、cookie 机制、AES-256-GCM 落盘、striped refresh 锁、HS256 scoped shortcut 基建——**全部只认 `user_id` 抽象,毫发无损**。折中被牢牢圈在“身份 / 会话层”,应用主体不动。这是本计划改造面可控、可分里程碑安全落地的根本保证。
+6722
View File
File diff suppressed because one or more lines are too long
+257
View File
@@ -0,0 +1,257 @@
<!DOCTYPE html>
<html lang="zh-Hans">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>cdrop — Changelog</title>
<meta name="generated-at" content="2026-06-16">
<meta name="source" content="cdrop · docs branch">
<!-- RUNTIME_CSS -->
</head>
<body>
<header class="page">
<div class="meta">
<h1>cdrop — Changelog</h1>
<p class="subtitle">时间倒序。细粒度迭代见 git 历史;本档只记录结构性 / 行为性变更与重要 fix。</p>
<p class="timestamp"><time>2026-06-16</time> · <span>docs branch</span></p>
</div>
<div class="actions">
<!-- ACTIONS -->
</div>
</header>
<main>
<p class="lede">本 Changelog 自 2026-05-22 起以 HTML 格式维护。此前之变更可从 main 分支 <code>git log</code> 取得;下表为近期较显著之 commit。</p>
<h2>2026-06-26 — 鉴权并入 Auth Broker:委派设备会话统一模型 · 四端迁移 · 显示名与头像修复</h2>
<p>cdrop 的登录 / 会话 / 设备 / 令牌生命周期并入既有 <strong>Auth Broker</strong>(边缘身份中介),删去 cdrop 内的自建鉴权副本;浏览器、桌面、手机一律收敛为同一套“委派设备会话”模型,统一管理;并修复部分客户端显示名为账户 UUID、头像缺失的回归。</p>
<ul>
<li><strong>后端委托 Auth Broker(路径 A</strong>:删自建 OIDC 交换 / 自签会话 / step-up / shortcut / <code>web_sessions</code> / <code>accounts</code> 及验证链,cdrop 不再存任何凭证;鉴权中间件改读边缘 <code>broker { app cdrop }</code> 注入的 <code>X-Auth-Subject/Name/Avatar/Scope/Meta</code> 头。新增 <code>internal/brokerclient</code>(委托铸造 / 吊销 / 续期 / 列举)</li>
<li><strong>统一会话模型“委派设备会话”</strong>:每个客户端 = 一条带 <code>meta</code>(device_id) + <code>label</code>(设备名) 的 broker 机器会话,Broker 作设备会话唯一注册表。浏览器全局 SSO、桌面设备授权(RFC 8252 loopback PKCE)、扫码批准,登录后一律“代铸”出设备会话(同一 device_id 幂等轮换、不再堆重复设备);cdrop 退化为薄覆盖层,会话真相与吊销授权一律走 Broker</li>
<li><strong>设备管理</strong>:浏览器 / 桌面 / 手机统一进一个设备列表,显示在线、当前设备与类型,可吊销、可改名(同 device_id 重铸换名、不产生重复行)。修复迁移引入的一组回归:设备列表为空 / 重复无名设备 / 看不到在线的另一台 / 吊销报“设备不存在”</li>
<li><strong>显示名与头像修复</strong>:经 Broker 端到端补 <code>X-Auth-Name</code> / <code>X-Auth-Avatar</code>,浏览器与桌面恢复真实显示名与头像(此前部分客户端显示账户 UUID、无头像)。桌面新增启动期 <code>HealIdentity</code>——注入前从 <code>/api/me</code> 自愈旧版烤进会话的陈旧身份并持久化,根治偶发 UUID</li>
<li><strong>四端迁移</strong>:后端、Web 前端、iOS、桌面(Wails)客户端同步迁移;存量会话于翻闸时失效、需重新登录</li>
<li>commit <code>10cf36e</code>main</li>
</ul>
<h2>2026-06-23 — P2P 传输修复:接收混合落盘 sink · 发送进度与完成语义 · 文字累积 · 速度显示</h2>
<p>修复 iOS PWA → Mac 桌面客户端大文件 P2P 传输约 15MB 处死锁,及随之暴露的两处发送端语义问题;并修一类前端 CJK 文字累积 bug、为传输卡片增设实时速度。</p>
<ul>
<li><strong>接收端混合有界内存 sink</strong>(桌面):<code>wails://</code> 自定义 scheme 无持久 WebView 存储 → OPFS 降级、<code>createWritable</code> 回压不畅,把接收 promise 链堵死、饿死 <code>dc.onmessage</code> → 接收方 SCTP <code>rwnd=0</code> → 发送方 <code>bufferedAmount</code> 卡死 16MiB(即“PWA 16.0MB / Mac 0B、约 15MB 卡住”)。改为:小文件(暂存 &lt; 256MB)纯内存、<code>close</code> 整文件过 Go 写盘;超上限大文件流式 spill 到 Go 临时文件(每 64MB 一批),内存恒定有界。浏览器 / iOS 接收仍走 OPFS(其内存预算紧、又无 Go 落盘,OPFS 才有意义)</li>
<li><strong>桌面流式落盘</strong><code>download.go</code> 新增 <code>Begin/Append/Finalize/AbortStreamingDownload</code>(复用 <code>SaveDownload</code> 的目录解析 / 文件名 sanitize / 不可写回退 / reveal,临时文件与目标同目录便于 finalize 原子 rename),<code>app.go</code> 加 4 个绑定 + <code>net/desktop.ts</code> base64 封装;<code>IncomingSink.close()</code> 返回 <code>SinkResult</code><code>blob</code> 已落盘 <code>path</code>),<code>deliverIncoming</code> 统一落地;relay 接收一并改用,桌面大文件经中继也有界落盘。含单测 <code>download_stream_test.go</code></li>
<li><strong>发送端进度按“已交付字节”计</strong>:原先用 <code>bytesSent</code>(已塞进本地 16MiB buffer 的量),buffer 被秒满后定格、长 backpressure 等待期误报“疑似卡住”。改用 <code>bytesSent bufferedAmount</code>(已离开本地 buffer ≈ 已交付)+ 250ms 轮询在等待期持续刷新,进度随接收端实际收程平滑爬升、不误判 stalled</li>
<li><strong>发送端等抽干再宣告完成</strong>:原先送完入 buffer 即 <code>setState("completed")</code>,此时仍可能有多达 16MiB 在途,与接收端“仍在下载”状态不一致。改为送完 + done 帧后等 <code>bufferedAmount</code> 落到 0(字节已全部交付网络)再宣告完成</li>
<li><strong>前端动态 CJK 文字累积修复</strong>:聚珍 shim 把会就地更新的 CJK 文本节点 charify 拆段后,旧碎片成了 React 不再追踪的兄弟节点残留、每更新一次累积一个多余字(典型“发送到 X”切设备后多出的“到”)。新增 <code>DynText</code><code>key</code>=文本内容,内容一变即整体卸载旧 span、连同 shim 碎片,再挂新 span 交 shim 处理一次——既消累积又<strong>保留</strong>中西排版,区别于 <code>data-jz-skip</code> 那样关掉排版),应用于发送按钮 / 设备名 / 在线数 / 相对时间 / 会话与令牌活跃时间 / 消息计数 / 剪贴板来源 / 问候语等就地更新处</li>
<li><strong>传输卡片实时速度</strong>store <code>upsertTransfer</code> 据相邻样本的字节 / 时间差分 + EMA(α=0.3)平滑算 <code>bytesPerSec</code><code>TransferRow</code> 仅在字节流动且未卡死、且速率为正时以“X/秒”展示(点分隔 meta 行追加),卡死时既有“似乎卡住了”状态已表达停滞、不显误导性 0 速</li>
<li>commit <code>0451549</code>main</li>
</ul>
<h2>2026-06-22 — 账户与登录子系统:扫码快登 · 自签会话 · OIDC 统一 · 即时吊销 · 设备=会话</h2>
<p>“OAuth 为账户来源与凭证提供者、cdrop 自维护薄账户层 + 自签会话”的折中架构落地(蓝本见仓库 <code>AUTH.md</code>)。一台设备 = 一条会话,吊销即时生效、被吊销设备自动回到未登录态。</p>
<ul>
<li><strong>扫码快速登录</strong>:陌生设备显码、已登录手机扫码批准(微信 / WhatsApp 网页版模型)。三密钥分离(<code>request_id</code> / <code>approval_code</code> / 仅新设备私有的 <code>poll_secret</code>),偷拍 QR 无法领取会话;会话只经新设备自身轮询连接下发 cookie。两档:“信任此设备”(<code>scope=full</code>、7 天滑动) / “仅此次”(<code>scope=guest</code>、1 小时受限),默认偏安全的“仅此次”。访客会话服务端强制受限(<code>requireFullSession</code> 守门)</li>
<li><strong>薄账户层 + 自签会话令牌</strong>:新增 <code>accounts</code> 表(cdrop 侧账户数据、不含凭证,存稳定 <code>match_key</code> 供管理员开启迁移时跨源关联);<code>web_sessions</code> 泛化出 <code>kind</code> / <code>scope</code> / <code>granted_by</code> / <code>stepped_up_at</code>。access token 改 cdrop 自签 HS256(密钥自 <code>CDROP_SESSION_SECRET</code> 域分离派生),脱离 IdP refresh、短 TTL(默认 900s)、载 <code>sid</code></li>
<li><strong>OIDC 统一到自签 token</strong>:浏览器登录(含 OIDC)一律持自签 token —— <code>/auth/exchange</code> 先 JWKS 验 <code>id_token</code> 签名(唯一信任锚)再签自签 token,<code>/auth/refresh</code> 仍打 IdP 轮换 refresh_token 但回自签 token。RS256 退出浏览器每请求热路径,仅保留分支供桌面端 loopback 直带 IdP token</li>
<li><strong>吊销即时 + 设备自知</strong><code>verifySelfToken</code> 每请求按 <code>sid</code> 查会话行,行删即拒 → 吊销在下次请求即生效(不等 TTL)。前端仅在“服务端以 401 确证失效”时清登录态回登录页(短期连接失败 / 5xx 不误清);离线设备下次使用即知,在线设备经 <code>Hub.Kick</code> 关流、约 1s 重连撞 401 即知</li>
<li><strong>设备列表 = 会话列表</strong>:“登录会话”面板统一 <code>web_sessions</code> 与原生客户端设备(桌面 / iOS,自 <code>devices</code> 登记呈现,<code>native=true</code>)。吊销网页会话连带删其设备登记、即时从所有列表消失;孤儿 <code>browser</code> 设备由清扫器(<code>DeleteOrphanBrowserDevices</code>)自动清除。原生设备“登出”走 <code>DELETE /api/devices/{name}</code>(同要求 step-up</li>
<li><strong>2FA step-up 钩子</strong>(默认关,<code>CDROP_STEP_UP_ENABLED</code>):批准扫码设备 / 吊销会话 / 注销设备等敏感动作要求一次现场 <code>prompt=login</code> 再认证(provider 的 2FA 即在此过),按会话绑定、<code>StepUpMaxAgeSeconds</code>(默认 300s)窗口内不复要求。<code>auth_time</code> 可选(Casdoor 不下发)</li>
<li><strong>应用内扫码器 + 聚珍崩溃修复</strong>:登录页内置 <code>getUserMedia</code> + jsqr 扫码(不再外跳系统应用、避免开错浏览器);扫码 / 批准等三状态机页整页跳过聚珍(<code>data-jz-skip</code>),修 MutationObserver 与 React 重渲染同子树冲突的 <code>insertBefore</code> 崩溃</li>
<li>蓝本 <code>AUTH.md</code>commit 待提交</li>
</ul>
<h2>2026-06-18 — 推送通知:网页 / PWA Web Push + 桌面原生通知</h2>
<p>页面 / PWA 关闭、或桌面窗口非前台时也能收到系统通知;判定统一为“页面打开走应用内提示,关闭则系统通知”,三端各自落地。</p>
<ul>
<li><strong>网页 / PWAWeb Push, VAPID</strong>:服务端在事件未经 SSE 投递到设备时(即该设备页面已关)发 Web Push —— 收到文件、消息、传输完成 / 失败均可。新增 <code>push_subscriptions</code> 表(含 <code>locale</code>,主键为 endpoint 的 SHA-256 以幂等 upsert+ <code>/api/push/{vapid-key, subscribe}</code> 端点 + <code>internal/push</code> 发送器(404 / 410 自动清理死订阅)。文案按订阅设备语言在服务端渲染(中简 / 中繁 / 英)。覆盖浏览器 / Android / iOS 16.4+ 已安装 PWA</li>
<li><strong>桌面客户端原生通知</strong>:仅窗口非前台时弹原生系统通知(前台仍走应用内提示),复用 WebView 事件分发 + 客户端本地化。macOS 走 <code>UNUserNotificationCenter</code>(未签名时安全空操作,待签名 / 公证后显示)、Windows 走原生 toast</li>
<li><strong>判定复用既有信号</strong>:网页端“页面是否打开”直接取 <code>Hub.SendTo</code> 的投递结果,零额外状态;离线消息改返 202,文本随推送送达</li>
<li><strong>开关与配置</strong>:设置页新增“推送通知”面板(仅浏览器 / PWA),登录后自动把既有订阅重新登记到当前用户;prod 可选设 <code>CDROP_VAPID_PUBLIC_KEY</code> + <code>CDROP_VAPID_PRIVATE_KEY</code><code>just vapid-keygen</code> 生成一对,二者须同时设置,都留空则推送惰性关闭)</li>
<li>commit <code>92a03ae</code></li>
</ul>
<h2>2026-06-16 — 浏览器免重登 + 设备名持久化</h2>
<p>浏览器端不再每次重启都要重新登录;长寿命 refresh_token 从此完全不进 JS,改由服务端 HttpOnly cookie 会话持有。对长寿命凭据而言比原先(refresh_token 在 JS 内存、开页期 XSS 可读)严格更安全。</p>
<ul>
<li><strong>服务端会话表 + cookie</strong>:新增 <code>web_sessions</code> 表(sqlc)。<code>/auth/exchange</code> 换完 token 后建会话并下发 cookie<code>HttpOnly; Secure; SameSite=Lax; Path=/api/auth</code>),响应体不再回传 refresh_tokenrefresh_token 以 AES-256-GCM 落盘(密钥取自 <code>CDROP_SESSION_SECRET</code>,在 env、不在库内),cookie 只放不透明随机串、表 id 存其 SHA-256 —— DB 单独泄露既换不出可用 cookie、也解不出 token</li>
<li><strong>开机静默免重登</strong><code>/auth/refresh</code> 改为 cookie 驱动(7 天滑动失活)—— 这条同时就是开机静默免重登路径,前端首屏渲染前 <code>bootstrapAuth</code> 用 cookie 续期,命中直接进已登录态、避免登录页闪现;新增 <code>/auth/logout</code>(删会话 + 清 cookie)、<code>/auth/device</code>(写设备名入会话)</li>
<li><strong>设备名持久化</strong>:设备名随会话存,开机 refresh 带回前端,抗 iOS PWA 存储清除 —— localStorage 被驱逐也能恢复,不再误跳设置页</li>
<li><strong>多 tab 并发 refresh 防护</strong>:同一用户多 tab 共享一个 cookie → 一个 refresh_token,加 striped per-session 锁串行化并发 refresh,防一次性 refresh_token 被花两次而把用户从所有 tab 一起登出</li>
<li><strong>凭据面收紧</strong>:前端 store 彻底移除 <code>refreshToken</code> 字段,浏览器侧长寿命凭据不再触及 JS。CSRF 由 SameSite=Lax + Origin 校验双保险。桌面端不受影响(自有 loopback flow + 系统密钥库,不碰这两端点)</li>
<li><strong>部署要求</strong>prod 须设 <code>CDROP_SESSION_SECRET</code>(任意长度高熵串,内部 SHA-256 派生 AES-256 密钥),缺则拒启动</li>
<li>commit <code>e51666c</code></li>
</ul>
<h2>2026-06-15 — PWA 安装支持 + Safari OAuth 回跳卡顿修复</h2>
<ul>
<li><strong>PWA 安装支持</strong>:web 可“添加到主屏幕”独立启动(manifest + iOS meta + 同源静态壳 service worker)。SW 绝不拦截 <code>/api</code>(SSE / 剪贴板 / 中继需活连接)、亦不拦导航;PKCE verifier 改 <code>localStorage</code>,使独立 PWA 在 OAuth 回跳重启上下文后仍能完成登录。面向用户短名统一小写 <code>cdrop</code></li>
<li><strong>Safari OAuth 回跳卡顿修复</strong>Safari / iOS PWA 从 IdP 回跳登录后,<code>setup.js</code> 与头像约 2 分钟拿不到连接(Chrome 正常)。根因是 <code>oauth.callback</code> 登录成功后用路由软导航,复用了 OAuth 流程遗留的 HTTP 连接,这些连接在 Safari 下卡死后续资源请求;改为整页跳转(<code>window.location.replace</code>)拿全新连接绕开。曾误判 service worker / h3 为主因,均已排除</li>
<li>commit <code>5c28ddf</code>PWA 安装)/ <code>7082ccc</code>(短名)/ <code>af754dc</code> · <code>8f550ad</code>Safari 修复)</li>
</ul>
<h2>2026-06-14 — 安全加固:中继会话防耗尽 + 令牌入系统密钥库 + 鉴权 / 限流多项收紧</h2>
<p>多 agent 安全审计后的一轮加固,按信任模型(信任账号所有者 + 服务端;不信任跨用户 / 网络 hop peer / 本机其他软件)逐项收紧。</p>
<ul>
<li><strong>中继会话防耗尽重构</strong>:中继改为“预登记”模型 —— 传输进入 <code>RELAY_ACTIVE</code> 时由服务端登记其两个合法参与方,之后仅已登记会话可接入、且调用方须为 sender / receiver。叠加 per-user 配额(3)+ 全局上限(8+ 空闲回收(2min+ ring 单 reader 断言。一举堵住“任意 id 凭空铸造会话耗尽全实例并钉 512 MiB”“幽灵会话”“id 泄露后无第二道防线”“双 reader 撕裂数据”四类问题</li>
<li><strong>桌面 refresh_token 入系统密钥库</strong>:长效 refresh_token 不再明文落盘,改存系统密钥库(macOS Keychain 经 <code>security</code> / Windows Credential Manager·DPAPI / Linux Secret Service),同机软件无法直读冒充;会话文件只留短效 access_token。遗留明文文件首次启动自动迁移并抹除</li>
<li><strong>HS256 token 强制 jti</strong>HS256 路径只签 clipboard 限域的快捷指令 token(必带 jti),无 jti 的有效签名一律拒签,把密钥泄露的爆炸半径压回 clipboard-only</li>
<li><strong>prod 强制 OIDC audience</strong>prod 下 <code>CDROP_OIDC_AUDIENCE</code> 为空即拒绝启动 —— 空 audience 会跳过校验、令同一 JWKS 下兄弟应用的 token 也能验过(跨 app 冒充)。<strong>部署要求</strong>:prod 须设该变量(web + 桌面 client_id 逗号分隔)</li>
<li><strong>请求面收紧</strong>:信令 / 文本消息加请求体上限(防 OOM);内置 TURN 凭据 TTL 由 24h 降至 1h(缩泄露窗口 24×);<code>/auth/exchange</code><code>/auth/refresh</code> 加 per-IP 限流(防把服务当 OIDC 暴力跳板)</li>
<li>commit <code>bcdc2b4</code>(中继 / HS256 / 密钥库 / body 上限)·<code>c536a35</code>audience / TURN TTL / 限流)</li>
</ul>
<h2>2026-06-14 — 桌面文件落盘可配置 + 传输列表行内操作 + macOS 下载兜底修正</h2>
<ul>
<li><strong>桌面端接收文件落盘到可配置目录</strong>(默认系统 <code>~/Downloads</code>):设置页加“下载目录”+ 原生目录选择框(<code>ChooseDownloadDir</code> → Wails <code>OpenDirectoryDialog</code>)。机制为 Go 绑定写盘 —— 桌面模式下 <code>downloadBlob</code> 改走 <code>SaveDownload</code>blob → base64 过桥 → Go 写盘),对端文件名经 <code>sanitizeFileName</code> 剥目录成分防路径穿越、重名加 <code>(n)</code>、落盘后 <code>open -R</code> / <code>explorer /select</code> 揭示</li>
<li><strong>macOS 下载兜底修正</strong>WKWebView 不处理 <code>&lt;a download&gt;</code><code>blob:</code>,原“失败回退浏览器下载”在 macOS 是空操作、会静默丢文件;改为写配置目录失败时自动改写系统 <code>~/Downloads</code>,连默认目录也失败才显式报错(<code>toast.error</code>),不再丢文件</li>
<li><strong>传输列表行内操作</strong>:每行加 —— 非终态“取消”(拆本端 P2P / relay + <code>POST /transfer/{id}/cancel</code>)、终态“删除”(从 active + history 移除)、发送方仍在试 P2P 时“立即中继”(清 30s ICE watchdog + <code>POST /transfer/{id}/fallback</code>,跳过等待)</li>
<li>双端桌面客户端(macOS universal <code>.app</code> + Windows <code>.exe</code>)重构并同步;web 端 prod 部署(<code>drop.commilitia.net</code></li>
<li>commit <code>43ddf36</code>(下载路径)/ <code>10a4c99</code>(传输 UX + 兜底修正)</li>
</ul>
<h2>2026-06-12 — 接入聚珍(Juzhen2.0 综合 CJK 排版:全角式 + 功能分级 + 本地优先字体</h2>
<ul>
<li>submodule <code>cjk-autospace</code> 升级 <code>a5faec5 → a3c94dd</code>:v1 自动空格 shim 从零重写为“聚珍(Juzhen)”2.0 综合排版引擎 —— 中西间隙 / 标点挤压(全角式)/ 禁则避头尾 / 垂悬字避免 / 长英文断词 / 功能分级;JS 只注入 <code>jz-*</code> 结构、视觉尺寸全在 <code>juzhen.css</code>(必配)</li>
<li><strong>功能分级</strong>:功能分文本级(中西间隙 / 标点挤压,任意文本片段适用)与段落级(禁则 / 垂悬字 / 长词断字,需行段布局,对单行 UI 文本无意义甚至有害)。薄壳 <code>level:{text:".juzhen"}</code> 令全树默认文本级(cdrop 绝大多数是 UI chrome);消息正文 / 剪贴板内容 / Callout / Toast 正文 / Field hint / 登录说明等 8 处流动正文加 <code>data-jz-level="paragraph"</code> opt-in 段落级,失败模式良性</li>
<li><strong>字体逐 region 明确化</strong><code>fonts.css</code><code>:lang()</code> 切 SC/TC/JP/KR):Sans Orbit Gothic CJKSerif Noto Serif CJK(本地合一)+ Noto Serif &lt;R&gt;(Web 子集、跨区并集覆盖);Mono Maple Mono + Orbit Gothic CJK</li>
<li><strong>本地优先字体层</strong>(新增 <code>fontface.css</code>):CDN 的 <code>@font-face</code><code>local()</code> 且 family 名与本机安装同名 → 被捕获、永远走网络。本层在 CDN <code>&lt;link&gt;</code> 之后用 <code>src: local(), url()</code> 重声明项目用到的字重,本机命中即零网络、否则回退 CDN(swap)</li>
<li>Badge 加固(children 包单个 inline span,防聚珍注入元素在 <code>inline-flex</code> gap 中各成 flex item 致 gap 叠加);术语“Pangu”统一改“聚珍”;commit <code>907d132</code> / <code>b3c74a6</code></li>
</ul>
<h2>2026-05-22 — README 补 clone 注意事项</h2>
<ul>
<li>新 clone 之 repo 预设不自动 fetch submodule<code>web/src/lib/cjk-autospace/</code> 为空时 <code>vite build</code> 会因找不到 import 而失败;错误信息虽含路径但不直观</li>
<li><code>README.md</code> 顶部加“克隆”小节,指引 <code>git clone --recurse-submodules &lt;URL&gt;</code> 或补拉 <code>git submodule update --init --recursive</code></li>
<li>commit <code>1443543</code></li>
</ul>
<h2>2026-05-22 — cjk-autospace 抽出为共用 submodule</h2>
<ul>
<li>把原内联在 <code>web/src/utils/cjkAutospace.ts</code> 的 Pangu shim 抽出为上游 git 仓库 <a href="https://git.commilitia.net/admin/cjk-autospace">cjk-autospace</a>(双 pass 架构:跨样式标签透明 + 行内块边界,commit <code>c3defc4</code></li>
<li>以 git submodule 引入 <code>web/src/lib/cjk-autospace/</code><code>cjkAutospace.ts</code> 缩为 ~50 行薄壳,只负责 cdrop 的 <code>pillSelector="code, kbd, .cjk-pill"</code> 配置 + React 下的 MutationObserver 增量观察(自反馈防护、<code>queueMicrotask</code> 防同 batch 重排)</li>
<li>单一真实源:lib 修改一次三端(HTML 模板 / Peek.nvim shim / cdrop)同步生效;本仓库 deploy 时 Vite 把 submodule 检出之文件当一般 TS / JS 模块打包,运行时不读 Dropbox 也不依赖 git 元数据</li>
<li>架构文档(<code>PROJECT_BRIEF.md</code> + <code>FRONTEND_DESIGN.md</code>+ Changelog 移到 docs 分支(本档)</li>
</ul>
<h2>历史变更(移转前未以本档记录)</h2>
<table>
<thead>
<tr><th>commit</th><th>说明</th></tr>
</thead>
<tbody>
<tr><td><code>aa80793</code></td><td>CJK 排版子系统:Pangu.js v7.2.1 端口 + cjk.css + Button label 包裹</td></tr>
<tr><td><code>4e8c541</code></td><td>前端视觉重塑后续迭代 + i18n 本地化清理</td></tr>
<tr><td><code>81dfbe1</code></td><td>前端重设计:tokens 变量化 + features-first + 原子组件 + 首页重画 + Toast + Dracula Pro 主题</td></tr>
<tr><td><code>6db6444</code></td><td>新增繁体中文(臺灣)+ locale 检测按 Hans / Hant + 区域子标签细分</td></tr>
</tbody>
</table>
</main>
<footer class="page">
<p>cdrop · docs branch · CHANGELOG</p>
</footer>
<!-- RUNTIME_SPRITE -->
<script type="text/markdown" id="md-source">
# cdrop Changelog
时间倒序细粒度迭代见 git 历史本档只记录结构性 / 行为性变更与重要 fix
## 2026-06-18 推送通知网页 / PWA Web Push + 桌面原生通知
页面 / PWA 关闭或桌面窗口非前台时也能收到系统通知判定统一为页面打开走应用内提示关闭则系统通知
- 网页 / PWAWeb Push, VAPID服务端在事件未经 SSE 投递到设备时发 Web Push收到文件 / 消息 / 传输完成 / 失败新增 push_subscriptions locale主键为 endpoint SHA-256+ /api/push/{vapid-key, subscribe} + internal/push 404 / 410 / / / Android / iOS 16.4+ PWA
- 桌面客户端原生通知仅窗口非前台时弹前台走应用内macOS UNUserNotificationCenter未签名时安全空操作待签名后显示Windows 原生 toast
- 判定复用 Hub.SendTo 的投递结果零额外状态离线消息改返 202文本随推送送达
- 设置页新增推送通知面板仅浏览器 / PWAprod 可选设 CDROP_VAPID_PUBLIC_KEY + CDROP_VAPID_PRIVATE_KEYjust vapid-keygen 生成都留空则惰性关闭
- commit 92a03ae
## 2026-06-16 浏览器免重登 + 设备名持久化
浏览器端不再每次重启都要重新登录长寿命 refresh_token 完全不进 JS改由服务端 HttpOnly cookie 会话持有比原先 refresh_token JS 内存严格更安全
- **服务端会话表 + cookie**新增 `web_sessions` sqlc`/auth/exchange` 建会话并下发 `HttpOnly; Secure; SameSite=Lax; Path=/api/auth` cookie响应体不再回传 refresh_tokenrefresh_token AES-256-GCM 落盘密钥 `CDROP_SESSION_SECRET` env不在库内cookie 只放不透明随机串 id 存其 SHA-256 DB 单独泄露既换不出可用 cookie也解不出 token
- **开机静默免重登**`/auth/refresh` cookie 驱动7 天滑动失活前端首屏前 `bootstrapAuth` 续期命中直接进已登录态新增 `/auth/logout``/auth/device`写设备名入会话
- **设备名持久化**设备名随会话存开机 refresh 带回前端 iOS PWA 存储清除localStorage 被驱逐也能恢复不再误跳设置页
- ** tab 并发 refresh 防护** tab 共享一个 cookie 一个 refresh_tokenstriped per-session 锁串行化并发 refresh防一次性 token 被花两次而全端登出
- **凭据面收紧**store 移除 `refreshToken` 字段浏览器侧长寿命凭据不再触及 JSCSRF SameSite=Lax + Origin 校验双保险桌面端不受影响
- **部署要求**prod 须设 `CDROP_SESSION_SECRET`任意长度高熵串缺则拒启动
- commit `e51666c`
## 2026-06-15 PWA 安装支持 + Safari OAuth 回跳卡顿修复
- **PWA 安装支持**web 添加到主屏幕独立启动manifest + iOS meta + 同源静态壳 SWSW 绝不拦截 `/api`亦不拦导航PKCE verifier `localStorage`使独立 PWA OAuth 回跳重启上下文后仍能完成登录短名统一小写 `cdrop`
- **Safari OAuth 回跳卡顿修复**Safari / iOS PWA 回跳登录后 `setup.js` 与头像约 2 分钟拿不到连接Chrome 正常根因是 `oauth.callback` 成功后路由软导航复用了 OAuth 流程遗留的 HTTP 连接 Safari 下卡死后续资源改为整页跳转`window.location.replace`拿全新连接绕开
- commit `5c28ddf`PWA/ `7082ccc`短名/ `af754dc` · `8f550ad`Safari 修复
## 2026-06-14 安全加固中继会话防耗尽 + 令牌入系统密钥库 + 鉴权 / 限流多项收紧
agent 安全审计后的一轮加固按信任模型信任账号所有者 + 服务端不信任跨用户 / 网络 hop peer / 本机其他软件逐项收紧
- **中继会话防耗尽重构**中继改为预登记模型 传输进入 `RELAY_ACTIVE` 时服务端登记两个合法参与方之后仅已登记会话可接入且调用方须为 sender / receiver叠加 per-user 配额3+ 全局上限8+ 空闲回收2min+ ring reader 断言堵住任意 id 铸造会话耗尽实例并钉 512 MiB幽灵会话id 泄露 reader 撕裂四类
- **桌面 refresh_token 入系统密钥库**长效令牌不再明文落盘改存 macOS Keychain / Windows DPAPI / Linux Secret Service同机软件无法直读会话文件只留短效 access_token遗留明文文件首次启动自动迁移并抹除
- **HS256 token 强制 jti** jti 的有效签名一律拒签把密钥泄露爆炸半径压回 clipboard-only
- **prod 强制 OIDC audience**`CDROP_OIDC_AUDIENCE` 为空即拒绝启动 audience 跳过校验 兄弟 app 跨冒充部署须设该变量web + 桌面 client_id 逗号分隔
- **请求面收紧**信令 / 消息加 body 上限TURN 凭据 TTL 24h 1h`/auth/exchange` + `/auth/refresh` per-IP 限流
- commit `bcdc2b4` · `c536a35`
## 2026-06-14 桌面文件落盘可配置 + 传输列表行内操作 + macOS 下载兜底修正
- **桌面端接收文件落盘到可配置目录**默认系统 `~/Downloads`设置页加下载目录+ 原生目录选择框机制为 Go 绑定写盘blob base64 过桥 `SaveDownload`文件名 `sanitizeFileName` 防路径穿越重名加 `(n)`落盘后揭示 Finder / Explorer
- **macOS 下载兜底修正**WKWebView 不处理 `<a download>` blob回退浏览器下载 macOS 空操作会丢文件改为配置目录失败时改写系统 `~/Downloads`仍失败才 `toast.error`
- **传输列表行内操作**非终态取消`/cancel`终态删除移除记录发送方立即中继 ICE watchdog + `/fallback`跳过 P2P 等待
- 双端桌面macOS universal `.app` + Windows `.exe`重构并同步web prod 部署
- commit `43ddf36` / `10a4c99`
## 2026-06-12 接入聚珍Juzhen2.0 综合 CJK 排版全角式 + 功能分级 + 本地优先字体
- submodule `cjk-autospace` 升级 `a5faec5 → a3c94dd`v1 自动空格 shim 重写为聚珍Juzhen2.0 综合排版引擎中西间隙 / 标点挤压全角式 / 禁则 / 垂悬字避免 / 长词断字 / 功能分级
- **功能分级**文本级间隙 / 标点挤压任意文本vs 段落级禁则 / 垂悬字 / 长词需行段布局薄壳 `level:{text:".juzhen"}` 全树默认文本级8 处流动正文加 `data-jz-level="paragraph"` opt-in 段落级
- **字体逐 region 明确化**`fonts.css`+ **本地优先字体层**新增 `fontface.css``local()` 优先覆盖 CDN url `@font-face`本机命中零网络
- Badge 加固术语 Pangu 聚珍commit `907d132` / `b3c74a6`
## 2026-05-22 README clone 注意事项
- `README.md` 顶部加克隆小节指引 `git clone --recurse-submodules <URL>` `git submodule update --init --recursive`避免 `web/src/lib/cjk-autospace/` 为空时 vite build 失败
- commit `1443543`
## 2026-05-22 cjk-autospace 抽出为共用 submodule
- 把原内联在 `cjkAutospace.ts` Pangu shim 抽出为上游 git 仓库 cjk-autospace pass 架构跨样式标签透明 + 行内块边界
- git submodule 引入 `web/src/lib/cjk-autospace/``cjkAutospace.ts` 缩为 ~50 行薄壳
- 架构文档 + Changelog 移到 docs 分支
## 历史变更
- `aa80793` CJK 排版子系统Pangu.js v7.2.1 端口
- `4e8c541` 前端视觉重塑后续迭代
- `81dfbe1` 前端重设计
- `6db6444` 新增繁体中文 locale 检测
</script>
<!-- RUNTIME_JS -->
</body>
</html>
+317
View File
@@ -0,0 +1,317 @@
# Commilitia Drop — 前端设计
补充 PROJECT_BRIEF.md,专注 web 前端实现。后端、协议、鉴权见主 brief。
> 本文记录 MVP 初版前端设计(Mantine + 浅色)。其后前端经「Theme B」重塑:自定义设计 token、品牌 web font、Dracula 深色、聚珍(JuzhenCJK 综合排版——视觉部分以 [`CHANGELOG`](CHANGELOG.html) 与实际代码为准。
---
## 1. 技术栈
```
Vite 构建
React 18 + TypeScript 框架
TanStack Router 路由(类型安全 > React Router
Zustand state 管理
Mantine v7 UI 组件库(CSS modules,零运行时开销)
@mantine/dropzone 文件拖拽
@mantine/notifications toast
Tailwind CSS utility 微调(不主导样式)
lucide-react 图标
原生 RTCPeerConnection P2P
```
**不引入** starter 模板。从 `npm create vite@latest -- --template react-ts` 起步,按需装包。
**不引入**MUI / Ant Designbundle 过大)、shadcn/ui(手搓成本与 MVP 速度冲突)、TanStack QueryMVP 阶段 SSE + Zustand 已够,需要时再加)、Storybook / i18next / Husky 等企业级配套。
---
## 2. 资源占用
前端是纯静态资源,部署在 Caddy 静态目录。服务器开销可忽略:
- gzipped bundle 估计 400-600 KB
- 服务器内存 / CPU 接近零(无 SSR)
- 真正吃资源的是后端 Go 进程的 ring buffer 和 SSE 长连接
构建在本地完成,部署只 push 静态文件。
---
## 3. 设计语言
中性、克制、工具感。参考点:
- **Linear**:留白、灰阶、克制的强调色
- **Cloudflare Dashboard**:信息密度合理的卡片式布局
- **Tailscale Admin**:设备列表、状态指示
避开:
- AirDrop 圆圈布局(不打算抄 Apple 的视觉语言,且圆圈在设备数 > 8 时退化严重)
- Snapdrop / PairDrop 极简主义(看着是玩具,不是工具)
- Material You(不适合自托管极简风)
**配色**:浅色优先,且 MVP 阶段**仅实现浅色**。深色模式属阶段三 UX 完善的范围,不在 MVP 内。Mantine 配置 `defaultColorScheme="light"`,不读取 `prefers-color-scheme`,不提供切换控件。
强调色 cdrop 蓝(`#3b82f6` 量级,待最终选定)。文本主色深灰(`#1f2937`),次级灰阶按 Mantine `gray.6 / gray.7` 走。背景纯白或近白(`#fafafa`)以减少屏幕反光强度。
### 字体与 CJK 排版
> 原 MVP 计划用系统字体栈、不引入 web font;前端重塑(Theme B)后改为品牌 web font CJK 综合排版。
**字体栈**`web/src/styles/fonts.css`,逐 region 由 `:lang()` 切 SC/TC/JP/KR`:root` 默认 SC,亦兼英文 / 无 lang 回退):
- **Sans**(正文):`Orbit Gothic CJK <R>` 系统回退
- **Serif**`Noto Serif CJK <R>`(本地合一、全字覆盖)→ `Noto Serif <R>`Google Fonts Web 子集,跨区并集补字)→ 系统
- **Mono**`Maple Mono` `Orbit Gothic CJK <R>` 系统
web font 由三个 CDN 的 `@font-face` 提供(`index.html` 三条 `<link>`),均 `font-display: swap`。因这些 `@font-face` 不含 `local()`、family 名又与本机安装同名 → 浏览器被 `@font-face` 捕获、永远走网络(同名本地副本不被用)。故 `web/src/styles/fontface.css` 加**本地优先覆盖层**:在 CDN `<link>` 之后、对项目用到的字重重声明 `@font-face``src: local(...), url(<cdn>)` —— 本机命中即零网络瞬时渲染,未装才回退 CDN(swap)。
**CJK 综合排版(聚珍 / Juzhen**submodule `web/src/lib/cjk-autospace`(上游 `git.commilitia.net/admin/cjk-autospace`;引擎机制见其 `README.md` / `ARCHITECTURE.md`)。`web/src/utils/cjkAutospace.ts` 为 cdrop 侧薄壳:
- `createJuzhen({ lang:{style:"quanjiao"}, scope:{include:".juzhen"}, level:{text:".juzhen"}, pillSelector:"code, kbd, .cjk-pill" })``render` / `revert` MutationObserver 增量(render 幂等)。
- **全角式**`quanjiao`):句内点号不挤、占全角,仅承载 justify 单份间距与行端收缩。
- **作用域** `.juzhen``#root` 加该类):处理与 normalize 收敛于此子树,`body` 上 Mantine portal 不受扰。
- **功能分级**:文本级(中西间隙 / 标点挤压,任意文本片段适用)全树默认跑;段落级(禁则避头尾 / 垂悬字避免 / 长英文断词,需行段布局)仅带 `data-jz-level="paragraph"` 的流动正文(消息 / 剪贴板 / Callout / Toast / 登录说明等)跑。cdrop 绝大多数是 UI chrome(短标签),故默认文本级、正文 opt-in,失败模式良性。
- **copy-clean**:注入的间隙 / 结构元素不入剪贴板,复制得紧凑原文。
---
## 4. 核心交互:选择目标设备
放弃圆圈布局,采用**单行可横向滚动的设备条**(接近 AirDrop iOS 分享面板)。要求同时支持两种顺序:
- **先选文件,再选设备**(拖入 → 高亮可用设备 → 点击设备发送)
- **先选设备,再选文件**(点击设备打钩 → 拖入或点击"选择文件" → 自动发送)
### 布局(Home 页)
```
┌──────────────────────────────────────────────────────────────┐
│ Commilitia Drop [Settings] [User] │
├──────────────────────────────────────────────────────────────┤
│ │
│ Devices (3 online) │
│ ┌──────────┬──────────┬──────────┬──────────┬───── │
│ │ ● │ ● │ ○ │ ● │ … │
│ │ MBA M4 │ Win-Desk │ iPhone │ Mac mini │ │
│ │ desktop │ desktop │ mobile │ desktop │ │
│ │ this dev │ ✓ │ │ │ │
│ └──────────┴──────────┴──────────┴──────────┴───── │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Drop files here, or click to browse │ │
│ │ │ │
│ │ Selected: report.pdf (4.2 MB) [×] │ │
│ │ │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ [ Send to Win-Desk ] ← primary CTA, 状态联动 │
│ │
├──────────────────────────────────────────────────────────────┤
│ Recent transfers │
│ • report.pdf → Win-Desk ✓ 2 min ago │
│ • photo.zip ← iPhone ✓ 5 min ago │
│ • video.mp4 → Mac mini × failed (timeout) │
└──────────────────────────────────────────────────────────────┘
```
### 设备卡片状态
每张卡片宽度约 120-140 px,固定不随屏幕拉伸。横向 `overflow-x: auto`,触控板 / 鼠标滚轮可推。
| 状态 | 视觉 |
|---|---|
| this device | 灰色边框 + "this device" 标签,不可选 |
| online idle | 白底 + 浅灰边框 + 绿点,可选 |
| online selected | 蓝色描边 + ✓ 角标 + 浅蓝底 |
| online incompatible(文件已选但目标设备 capability 不允许) | 灰底 + 50% 透明 + 不可选 |
| online incompatible(被选后用户拖入了不兼容文件) | 同上,自动取消选中 + Notification 提示 |
| online busy(已在收发) | 黄色环 + 进度数字 |
| offline | 灰底 + 50% 透明 + 不可选(默认隐藏,"显示离线"开关展开) |
每张卡片显示:状态点 + 设备名 + 类型小字。不显示头像 / 图标(避免维护设备图标资源)。设备名超长截断为省略号,hover 显示 tooltip 全名。
### 双向选择顺序的状态机
文件和目标设备分别独立可选,CTA 状态由两者组合:
```
state = (file: File | null, target: Device | null)
(null, null) → CTA: disabled "Select file and device"
(file, null) → CTA: disabled "Select a device"
(null, device) → CTA: disabled "Select a file"
(file, device, ok) → CTA: enabled "Send to {device.name}"
(file, device, !compat) → CTA: disabled "Cannot send {file.type} to {device.type}"
```
CTA 按钮永远可见在拖拽区下方,文案随状态变化。点击 CTA 即触发 `POST /api/transfer/initiate`
### 拖拽优化
- **Tab 键支持**:设备条可用方向键导航,Space 切换选中。无障碍是免费的,Mantine 默认就有
- **拖入页面任意位置**触发全屏 dropzone overlay,提示松手位置(默认行为继承 dropzone)
- 文件拖到具体设备卡片上 → 该设备自动选中并立即发送("快捷路径",跳过 CTA)。这是 AirDrop 没有的额外便捷
- 选了不止一个文件 → MVP 拒绝(提示"目前每次只支持一个文件")。多文件留到阶段三
### 为什么不用圆圈
- 圆圈布局在设备数 > 8 时退化为环形排列,视觉混乱
- 圆圈区分度低,必须靠头像区分设备,但头像维护成本高
- 单行卡片在桌面浏览器(cdrop 主要场景)上信息密度更合适
- 一行卡片可以承载更多信息(设备名 + 类型 + 状态 + 选中标),圆圈只能放头像和小字
- AirDrop 单行布局是 Apple 在分享面板里给出的样式,本身就是为"多目标快速选择"优化过的
---
## 5. 路由
```
/ Home(设备 + 拖拽 + 历史)
/login Casdoor PKCE 跳转中转
/oauth/callback Casdoor 回调
/setup 首次填设备名(设备名未设时强制跳转此页)
/settings 设备名修改、退出登录
后续:shortcut token 管理、外观(深色切换)
```
TanStack Router file-based 路由模式,类型安全 + 自动代码分割。
---
## 6. 状态管理
Zustand 一个 store,分 slice
```
useStore = {
auth: { tokens, user, login, logout, refresh }
device: { selfName, selfType, set, hydrate }
hub: { connected, devices, signal, sse-handlers }
transfer: { current, history, initiate, accept, cancel }
ui: { selectedDevice, selectedFile, notifications }
}
```
不引入 Redux 或 Jotai。Zustand 的 selector + `useShallow` 处理性能问题足够。
---
## 7. 组件清单
业务组件(自己写):
```
DeviceCard 单设备卡片(状态联动)
DeviceStrip 横向滚动设备条
FileDropzone 拖拽区(@mantine/dropzone 包一层)
SendCTA 主按钮(状态机文案)
TransferRow 历史记录单行
TransferProgress 进度条 / RingProgress
DeviceTypeBadge desktop / mobile / browser 标签
```
页面级:
```
HomePage
LoginPage
SetupPage
SettingsPage
OAuthCallbackPage
```
通用 lib(与组件无关):
```
lib/auth.ts PKCE flow, refresh, fetch wrapper
lib/api.ts 带 Authorization + X-Device-Name 的 fetch 封装
lib/hub.ts 自实现 SSE 客户端(fetch + ReadableStream
lib/p2p.ts P2PSession(裸 RTCPeerConnection
lib/relay.ts RelaySession(流式 GET + 分块 POST
lib/transfer.ts Orchestrator(决定 P2P / Relay / fallback
```
---
## 8. SSE 客户端要点
浏览器原生 `EventSource` 不支持自定义 header,所以不能直接带 `Authorization`。自实现:
```ts
const res = await fetch('/api/hub/events', {
headers: { Authorization: `Bearer ${token}`, 'X-Device-Name': name },
signal: abortController.signal
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
// 按 '\n\n' 切分 SSE 事件,按 'event:' / 'data:' 字段解析
}
```
重连:指数退避(1s → 2s → 4s → 8s → 16s 上限),断开时 UI 显示 "Reconnecting..."。
token 接近过期时(< 5min)先 refresh,再重建 SSE 连接,避免连接中途 401。
---
## 9. 性能 / Bundle 优化
- TanStack Router 自动代码分割:每个路由独立 chunk
- Mantine 按需 import,不要 `import { Button } from '@mantine/core/dist/...'`
- 使用 `vite-plugin-imp` 或依赖 Mantine 自身的 ESM tree-shakev7 默认就好)
- recharts / d3 不需要(cdrop 不画图)
- `react-helmet` 不需要(应用很简单,document.title 直接改)
构建产物期望:
```
index.html ~ 2 KB
assets/index.[hash].js ~ 250-350 KB (gzipped)
assets/index.[hash].css ~ 30-50 KB (gzipped)
其他 chunk 按路由懒加载
```
---
## 10. 浏览器兼容
目标:
- Chrome / Edge ≥ 110(涵盖 99% Chromium 用户)
- Firefox ≥ 110
- Safari ≥ 16.4macOS 13+ / iOS 16.4+WebRTC 完整支持)
不支持 IE / 旧 Edge / iOS 15 及以下。原因:WebRTC DataChannel 行为差异和 fetch streaming 兼容问题不值得投入精力。
---
## 11. 与主 brief 的对齐
§3 技术栈一行更新为:
```
**前端**Vite + React 18 + TypeScript / Mantine v7 + Tailwind / TanStack Router / Zustand / 原生 RTCPeerConnection / lucide-react
```
§7 MVP 验收标准的 "Home 页看到对方在线" 默认指本文件 §4 描述的设备条。
§8 阶段三 UX 完善中"暗色模式"对应本文件 §3 提到的 MVP 不实现深色——阶段三再做。
---
## 12. 你(Claude Code)需要决策的事
1. 设备卡片的具体尺寸、间距、状态色值,等 §3 强调色锁定后再敲定
2. CTA 按钮的具体文案(中文 vs 英文 vs 双语)。MVP 用英文,i18n 走阶段三
3. 历史记录显示几条 / 是否分页 / 是否支持过滤。MVP 默认显示最近 20 条
4. 有更好的设计参考时,可以反驳 §3 的方向选择
-105
View File
@@ -1,105 +0,0 @@
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 ./...
# 生成一对 VAPID 密钥(Web Push 用)。输出两行 env,写进部署密钥后固定不变——
# 更换会使所有既有浏览器订阅失效。两者皆设才启用推送。
vapid-keygen:
go run ./cmd/vapidgen
# ---- 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
# ---- iOS 真机 ----
# 列出已连接的真机(手机插 USB 后取 UDID,填进门户 Devices + just ios-device)。
ios-devices:
xcrun devicectl list devices
# 真机构建 + 装机(自动 provisioning,经 ASC API Key)。前置:付费 ADPApp ID 的 App Groups /
# Push 能力(门户建好或自动建);根目录 gitignore 的 .env 配齐:CDROP_TEAM_ID / CDROP_ASC_KEY_PATH
# .p8 路径)/ CDROP_ASC_KEY_ID / CDROP_ASC_ISSUER_ID。-allowProvisioningUpdates 自动登记连接的
# 设备 + 建 profile(绕过手动 profile 的设备选择坑)。模拟器构建不需这些(base ad-hoc)。
# 用法:just ios-device <设备UDID>UDID 见 just ios-devices)。
ios-device udid:
[ -n "$CDROP_TEAM_ID" ] && [ -n "$CDROP_ASC_KEY_PATH" ] && [ -n "$CDROP_ASC_KEY_ID" ] && [ -n "$CDROP_ASC_ISSUER_ID" ] || { echo "缺 .envCDROP_TEAM_ID / CDROP_ASC_KEY_PATH / CDROP_ASC_KEY_ID / CDROP_ASC_ISSUER_ID)——见 ios/CDrop/REALDEVICE.md"; exit 1; }
cd ios/CDrop && xcodegen generate
cd ios/CDrop && xcodebuild -project CDrop.xcodeproj -scheme CDrop -configuration Debug -destination "platform=iOS,id={{udid}}" -derivedDataPath build/DD-device -allowProvisioningUpdates -authenticationKeyPath "$CDROP_ASC_KEY_PATH" -authenticationKeyID "$CDROP_ASC_KEY_ID" -authenticationKeyIssuerID "$CDROP_ASC_ISSUER_ID" DEVELOPMENT_TEAM="$CDROP_TEAM_ID" CODE_SIGN_STYLE=Automatic CODE_SIGN_IDENTITY="Apple Development" build
xcrun devicectl device install app --device {{udid}} ios/CDrop/build/DD-device/Build/Products/Debug-iphoneos/CDrop.app
# ---- 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 .
+244
View File
@@ -0,0 +1,244 @@
# Commilitia Drop — 跨 OS 剪贴板与文件传输服务
> 本文是项目最初的 MVP 规格(设计基线),保留作架构参考;**当前实现状态以 [`CHANGELOG`](CHANGELOG.html) 为准**——其后已落地剪贴板/消息通道、Wails 桌面端、安全加固等,部分「远期」项已实现。
项目代号 / 仓库名 / 二进制名:`cdrop`
部署域名:`drop.commilitia.net`
节点:自托管(单机 Docker + 反代)
---
## 1. 目标
跨 OSWin / macOS / Linux / iOS / Android)服务,提供:
1. 剪贴板同步(双向)
2. ad-hoc 文件传输(双设备均在线)
复用现有 Casdoor SSO,前置 Caddy。
---
## 2. 锁定的架构决策(不要重新讨论)
### 身份与鉴权
Casdoor 已为 cdrop 创建独立 application。Token 配置:
- `ExpireInHours = 1`access_token 1 小时)
- `RefreshExpireInHours = 196`refresh_token 约 8.16 天,sliding 行为已基于源码分析确认)
- 启用 grant types`authorization_code``refresh_token`
- 启用 PKCE
三条客户端路径:
- **浏览器**:标准 Casdoor OAuth + PKCE。access_token 存内存或 sessionStoragerefresh_token 存内存(不写 localStorage 防 XSS)。
- **Native client**desktop / mobile,远期):标准 Casdoor OAuth + PKCE + refresh_token rotation。系统浏览器或 loopback redirect 完成首次登录;token 存 OS keychainmacOS Keychain / Windows DPAPI / Linux Secret Service)。access_token 即将过期时(< 5 min)调 `/api/login/oauth/refresh_token` 换新 pair。Casdoor 每次 refresh 返回新 refresh_token,新 token 的 `exp` 重置(sliding 7-8 天窗口)。
- **iOS Shortcut**(远期):cdrop 自签 JWTHS256),长 TTL(1 年),不刷新。`shortcut_tokens` 表只存元数据(jti / scope / expires_at / revoked),不存 token 本身。
后端鉴权中间件统一处理:
1. 优先尝试 cdrop 自签 HS256 验签(命中 → Shortcut 路径)
2. 失败回退 Casdoor JWKS RS256 验签(命中 → 浏览器或 native client 路径)
不引入 caddy-security。
### 设备身份
`X-Device-Name` header 自报,UPSERT 到 `devices` 表。不做注册分配 UUID。
### 设备类型
| type | clipboard | file send | file recv |
|---|---|---|---|
| desktop | yes | yes | yes |
| mobile | yes | yes | yes |
| browser | no | yes | yes |
| ios_shortcut | yes | no | no |
同一物理设备的浏览器和 Shortcut 视作两个独立 device 记录。
### 传输层(h2/h3 偏好,禁用 WebSocket
WebSocket 在 Caddy + Go 当前栈下会被强制降级到 h1.1(RFC 8441 未实现),违背 h2/h3 偏好。改用:
- **Hub 通道**presence、信令、传输状态):SSEserver→client+ POSTclient→server
- 不用浏览器原生 EventSource(不支持自定义 header),用 fetch + ReadableStream 自实现 SSE 解析
- **Relay 通道**P2P 失败时):
- Receiver:流式 GETchunked transfer-encoding
- Sender:多次分块 POSTSafari / Firefox 不支持 streaming POST
- Server 端 in-memory ring buffer 桥接
### 文件传输
- **优先 P2P**WebRTC DataChannelDTLS 即 E2E
- 不用 simple-peer,自己包 RTCPeerConnection
- ICE 仅自建 STUN`stun:your-stun.example:3478`),不配 TURN(自建 coturn 跑 STUN-only;现已改用 Cloudflare Realtime TURN
- **P2P 失败 → Relay****不**做 Pipe 持久化邮箱)
- 双方均在线;传输中掉线重连最多 3 次
- 同 user 自动接受;> 100 MB 二次确认;不限文件大小,但中继有内存与并发上限
### 存储
- SQLitemodernc.org/sqlite,纯 Go 无 cgo
- 启用 WAL`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;`
- 中继不落盘,仅内存
### 权限
cdrop 服务侧不调 Casdoor enforce API。仅读 JWT 的 `groups` 数组自判。MVP 阶段所有合法登录用户视作普通用户,不分等级。
---
## 3. 技术栈
**后端**Go 1.22+ / chi / go-jose v4 / modernc.org/sqlite / koanf / sqlc
**前端**Vite + React + TypeScript / Tailwind / Zustand / 原生 RTCPeerConnection
代码风格:严格遵守 `~/.claude/rules/code-style.md`。Go 用 gofmtTS / JSX / JSON 按 Allman、数组内空格、运算符前置。
---
## 4. SQLite SchemaMVP
`devices``shortcut_tokens``transfer_sessions` 三表写入;`clipboard_state` 建表留空壳。
native client 不需要 cdrop 自管 token 表(直接验 Casdoor JWKS)。
```sql
CREATE TABLE 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 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 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 clipboard_state (
user_id TEXT PRIMARY KEY,
content_type TEXT NOT NULL,
content TEXT,
source_device TEXT,
updated_at INTEGER NOT NULL
);
```
State machine: `PENDING → ACCEPTED → (P2P_ACTIVE | RELAY_ACTIVE) → DONE | FAILED | CANCELLED`
---
## 5. SSE 消息协议
**server → client** (event types):
- `presence` { devices: [ { name, type, online } ] }
- `signal` { from, payload }
- `transfer:incoming` { session, auto_accept }
- `transfer:state` { session_id, state }
- `transfer:relay_ready` { session_id, sender_url, receiver_url }
- `ping` (keepalive)
**client → server** (POST body):
- `{ type: "signal", to, payload }`
- `{ type: "transfer:fallback", session_id }`
---
## 6. 前置条件(缺失就问我)
1. OIDC application 详细参数(client_id、JWKS endpoint、issuer、audience、token format
2. 反代(Caddy 等)现状(版本、h3 是否启用、站点 block 是否已配)
3. STUN / TURN 是否已就绪
4. DNS 是否已指向部署节点
---
## 7. MVP 范围
**只做 Web 文件传输端到端**
不做:客户端(任何形态)、剪贴板逻辑、用户间共享、Pipe 模型、历史 UI、audit log 持久化、i18n。
验收标准:
1. 两台桌面浏览器 Casdoor 登录
2. Home 页看到对方在线
3. 拖拽文件 → P2P 直传成功
4. 模拟 NAT 阻塞 → 自动 fallback Relay 成功
5. 传输中关闭一方 → 重连续传;3 次失败终止
MVP 阶段只用浏览器路径。Native client 和 Shortcut 路径在 §2 已锁定但属于后续阶段。
---
## 8. MVP 后开发计划
### 阶段二:剪贴板同步
`clipboard_state` CRUD + Hub 推送;桌面客户端(Tauri 候选,前端复用,Casdoor OAuth 走系统浏览器 + loopback redirect);iOS Shortcut 模板生成(启用 cdrop 自签 token 路径);Android 客户端原型。
### 阶段三:UX 完善
Inbox 历史、设备管理、多文件 / 文件夹、PWA 化、通知、i18n、暗色模式。
### 阶段四:生产化
audit log 持久化、限流、Prometheus 指标、健康检查、SQLite → PostgreSQL 迁移路径、配额、备份、安全审计。
### 阶段五:扩展能力(远期)
用户间共享、命名 pipe、原生客户端、WebTransport 切换、E2E Relay。
---
## 9. Plan 模式输出要求
至少包含:
1. §6 前置条件的获取方案(哪些自己探测、哪些问我)
2. MVP 实施计划(粒度自定)
3. 最高风险点 + 缓解思路
4. 本 brief 中你认为不清楚或需要决策的地方
---
## 10. 协作方式
你直接对我负责。可自然决策的部分不必复述。
允许向我提出**任何**合理建议——包括质疑本 brief 中的决策、提出更好的实现方式、要求补充信息、推迟某个里程碑等。我接受被反驳。
红线:
- 不跳过 Plan 直接编码
- 不重新讨论 §2 已锁定的架构(除非有强证据)
- 增加未列出依赖前先确认
- 不把 MVP 扩大到剪贴板或客户端
-166
View File
@@ -1,166 +0,0 @@
# 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 存系统密钥库(信封加密)
- **浏览器免重登 + PWA**:可安装为独立 PWA;浏览器侧 refresh_token 不再进 JS,改由服务端 HttpOnly cookie 会话持有(AES-256-GCM 落盘),关闭重开自动免重登(7 天滑动失活),设备名随会话持久化(抗 PWA 存储清除)
- **推送通知**:页面 / PWA 关闭时,收到文件、消息、传输完成或失败以系统通知提醒;页面打开时仍走应用内提示。网页走 Web Push(VAPID,覆盖浏览器 / Android / iOS 16.4+ 已安装 PWA),桌面客户端走原生系统通知(仅窗口非前台才弹)。文案按设备语言在服务端渲染(中简 / 中繁 / 英)
- **扫码登录**:临时借用陌生设备时,陌生设备显二维码、已登录手机扫码批准即可登入(微信 / WhatsApp 网页版模型)。批准方选“信任此设备(7 天)”或“仅此次(1 小时)”,授予的是受限访客会话(能收发文件,不能改账号 / 不能再批准别的设备 / 不能签发长效 token)。三密钥分离:二维码只含批准信息,会话只投递给持私有轮询密钥的原设备,偷拍二维码者拿不到会话。身份仍源自 OAuth providercdrop 自维护薄账户层 + 自签会话令牌(详见 `AUTH.md`
- **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_SESSION_SECRET` 必须非空**(任意长度高熵串)—— 浏览器免重登会话的 refresh_token 落盘加密密钥(内部 SHA-256 派生 AES-256 密钥);后端 prod 启动期强制此项,缺则拒启动
5.`CDROP_HS256_SECRET`iOS Shortcut scoped token 用,不用可留空)
6. 可选 `CDROP_CF_TURN_KEY_ID` + `CDROP_CF_TURN_API_TOKEN` 启用 Cloudflare Realtime TURN over TLS(不配则前端 STUN-only 兜底)
7. 可选 `CDROP_VAPID_PUBLIC_KEY` + `CDROP_VAPID_PRIVATE_KEY` 启用 Web Push 推送通知(用 `just vapid-keygen` 生成一对;两者须同时设置,半对则拒启动;都留空则推送惰性关闭,不阻塞启动)。`CDROP_VAPID_SUBJECT` 可选(联系标识,留空回退站点 URL)
-209
View File
@@ -1,209 +0,0 @@
# 子会话(subordinate session)— 提交 Auth Broker 评审
> 在现有“委派设备会话”(R1 列举 + R2 幂等铸造,`meta` 为稳定设备身份)之上,引入“子会话”:
> 一台设备下可有多条独立令牌的逻辑会话,但对外仍呈现为**一台设备、一条会话**。
> 状态:**待 Broker 评审**。关联:委派设备会话模型、cdrop `internal/httpapi/device_session.go` / `sessions.go`。
---
## 1. 背景与动机
cdrop 的 iOS 客户端天然是**多进程**:
- **主 app**:完整会话,跑 SSE 信令 / 传输 / presence。
- **控制中心控件 + 主屏小组件扩展**:独立 OS 进程,仅调云剪贴板 REST(`/api/clipboard`)。系统会在主 app 关闭 / 墓碑时单独拉起该扩展,故它必须能**脱离主 app 独立工作**。
这两个进程需要**各自独立的令牌生命周期**:单次轮换(single-use rotating)的 refresh token 若被两进程共用,会并发抢轮换——一方轮换后另一方持有的旧 refresh 立即失效,触发误登出。cdrop 已在主会话上踩过同类坑(冷启并发 401 抢轮换 → 误 forceLogout → 隔夜被迫重登,已修)。结论:**控件会话必须是与主会话隔离的第二条逻辑会话。**
但在**用户视角**,二者必须是**一台设备、一条会话**:
- 在用户**任何设备**的“设备 / 会话”列表里(该列表由 Broker R1 权威呈现,cdrop 仅叠加 type/online),iOS 只能出现**一次**。
- **登出该 iOS 设备**(在别处吊销)应**连带**登出其控件会话——不能留一条游离的控件会话还能读写剪贴板。
当前模型“一个 `meta` = 一台设备 = 一条会话”(R2 同 `meta` 即轮换替换那一条)无法表达“一台设备下多条逻辑会话”:若控件用同 `meta` 铸造,R2 会把主会话的令牌轮换掉(打断主 app);若控件用另一个 `meta`,它就成了**第二台设备**,违反“一个设备”诉求。
---
## 2. 诉求:最小扩展为“子会话”
在一条**父设备会话**之下铸一条**子会话**:
- **共享父的设备身份**(同一 `meta`)→ R1 里仍是同一台设备。
- **独立令牌对**access + refresh),**独立刷新**,与父互不抢轮换。
- 可带**缩减 scope**(如 `clipboard`),最小权限。
- **连带吊销**:吊销设备 / 父会话即吊销其全部子会话。
- 对外(R1 列表、任何 Broker 账户 UI)**不单独呈现**为设备 / 会话。
---
## 3. 数据模型与语义(建议)
### 3.1 会话增加子标识 `role`
- 会话表增可选列 `role`(或 `sub`),默认空串 `""` = 主会话。
- **R2 幂等键**从 `(user, meta)` 扩为 `(user, meta, role)`
- `(user, meta, "")` = 主会话(行为不变,老客户端零影响)。
- `(user, meta, "widget")` = 控件子会话,与主会话**并存**、各自独立令牌。
-`(user, meta, role)` 再铸 = 幂等轮换那一条(沿用现 R2 语义到 role 维度)。
### 3.2 scope 缩减
- 子会话铸造时可声明 `scope`(如 `clipboard`),Broker 在签发的令牌里带上;cdrop 路由层据此只放行剪贴板(cdrop 已有 `requireScope("clipboard")` 同款门,复用)。
### 3.3 列表 R1 归并
- R1 **按 `meta` 归并**:一台 `meta` 只呈现**一条**设备 / 会话(取主会话的 label / last_seen 等)。
- 子会话**不单列**。两种实现皆可,Broker 择一:
- (a) R1 直接隐藏 `role != ""` 的行;或
- (b) R1 仍返回但带 `role` / `parent_sid` 字段,由消费方(cdrop)归并隐藏。
- cdrop 倾向 (a)(权威端归并最干净,任何消费方都见一台)。
### 3.4 吊销级联
- 按**设备 `meta`** 吊销(cdrop 的“移除设备”/“登出”即按 device_id=`meta` 走)→ Broker 级联吊销该 `meta` 下**全部 role**(主 + 子)。
- 按单条 `sid` 吊销沿用现语义(可单独吊销某子会话,可选)。
### 3.5 刷新
- 每条会话按**自身** refresh token 独立轮换,无需区分主 / 子。**无跨进程抢轮换**——这正是隔离两条会话的目的。
---
## 4. API 形态(建议,二选一)
**方案甲(最小侵入)**:现有 device-session 铸造端点加两个可选参数 `role``scope`。缺省 `role=""` 即今日行为。
**方案乙(显式)**:新增 `POST /device/subsession { parent_meta, role, scope }`,语义同上。
刷新 / 撤销端点**无需改签名**:刷新按 refresh token;撤销按 sid 沿用,新增“按 meta 级联撤销”(cdrop 移除设备时用)。
---
## 5. cdrop / iOS 侧配合(Broker 支持后)
- iOS 控件会话改为“**主设备 `meta` + `role=widget` + `scope=clipboard`**”铸造,**弃用**独立 `dev_widget` device_id。
- cdrop `/api/sessions` 直接呈现 Broker 归并后的列表 → iOS 只出现一台。
- 移除设备 / 登出按 `meta` 级联 → 控件会话随之失效。
- 控件令牌泄露面缩到“仅剪贴板”。
改动量小且不触 cdrop 的主会话刷新热路径(不引入 #7 类风险)。
---
## 6. 备选与取舍
- **cdrop 侧过滤(不改 Broker**cdrop 在自己的 `/api/sessions` 里隐藏控件会话行。问题:
- 只修 cdrop 自己的列表视图;Broker **权威 R1** + 任何 Broker 账户 UI / 其他消费方仍见**两条**——违反“在用户任何设备的列表里都是一个”。
- 级联吊销需 cdrop **自行维护**父子映射并双吊销,Broker 不知二者关联,脆弱、易漏。
- 控件会话在 Broker 仍算一台“设备”,占设备数配额、被 Broker 自身管理面看见。
- **结论**:不干净,仅作 Broker 支持前的**临时回退**(若需提前上线,可先 cdrop 侧隐藏 + 按 user 维度级联,待 Broker 子会话就绪再切换)。
---
## 7. 安全要点
- **scope 最小化**:子会话仅 `clipboard`,缩小扩展进程(独立沙箱)令牌泄露面。
- **级联吊销**:保证“登出一台 iOS”彻底,含其全部子会话——不留游离凭证。
- **独立刷新**:各会话自轮换,无跨进程抢单次轮换 refresh(杜绝 #7 类误登出)。
- **老客户端零影响**`role=""` 即现行为,R2/R1/refresh/revoke 对既有会话语义不变。
---
## 8. 给 Broker 的一句话
> 在“委派设备会话”上,把 R2 幂等键由 `(user, meta)` 扩为 `(user, meta, role)`R1 按 `meta` 归并、按 `meta` 级联吊销,子会话可带缩减 scope——即可让 cdrop 的多进程 iOS 客户端在用户视角是“一台设备、一条会话”,而内部是各自独立刷新、互不抢轮换的两条逻辑会话。
---
## Broker 方评审与接口约定(2026-06-27
总评:诉求清晰、与“委派设备会话”正交、可最小扩展实现,**接受**。核心洞见正确——“一设备多逻辑会话、对外仍一台”靠在 `meta`(设备身份)之下加一维**会话判别子键**即可,且老客户端零影响。已对读 broker 实现(`internal.go` handleInternalMint/handleInternalList、`tokens.go` mintScopedSession、`store.go` sessionCols/Put、`verify.go` injectIdentity/scopeCoversApp、`authn.go` lockRefresh/lockMintIdent)。逐节回应并约定接口;有三处对建议做了收敛(命名、scope 复用、R1 归并位置),请确认。
### 一、命名:用 `sub`,不用 `role`
broker 侧 `role` 已被授权语义占用(Casdoor role、apps.json `required_role`、verify 角色校验)。会话判别键叫 `role` 会与鉴权角色混淆。**定为 `sub`**,与 `meta` 并列:`meta`=设备身份(归并/级联键),`sub`=设备内会话判别(幂等子键)。默认 `""`=主会话。
### 二、数据模型:新增 `sub` 槽(必要,无更省编码)
- `authcore.Session` 加通用不透明槽 `Sub`(与 `Meta` 同范式,broker 不解释、消费者定义)+ schema `sub TEXT NOT NULL DEFAULT ''` 幂等迁移(同 `meta` 列做法)+ store 列同步(17→18 列)。
- **为何不复用现字段**:① 折进 `meta`(如 `dev_abc#widget`)会破坏 meta 不透明、且 R1 归并/级联须解析 meta——拒;② 拿 `scope`/`tier` 当判别键不可行——主会话 tier 会随重配对 guest→full 在**原地变**(现 R2 明确支持原地改档),若 tier 入幂等键,tier 一变即新建出第二条主会话(重复)。故判别键必须**与 scope/tier 解耦且稳定**——这是 `sub` 必须独立于 `tier` 的根因。
- **R2 幂等键** `(user, app, meta)``(user, app, meta, sub)`:查既有过滤加 `&& ex.Sub==req.Sub``lockMintIdent` 键改 `user|app|meta|sub`。老客户端 `sub=""`、既有会话 `Sub=""`→照旧匹配轮换,零影响。
### 三、scope 缩减:复用现有 `tier`broker 无新增
你要的“子会话带缩减 scope(clipboard)”现机制已能给:`tier` 即 scope 后缀(token scope`app:cdrop:<tier>`cdrop 读末段)。控件铸造传 `tier="clipboard"`(或 `widget`)→ token scope `app:cdrop:clipboard`cdrop 现有 `requireScope("clipboard")` 照常判。**无需新增 `scope` 参数**。
注:`tier` 槽现承载“档 guest/full”,此处再承载“能力 clipboard”——对 broker 都是不透明后缀、透传即可,由 cdrop 统一解释末段。若你要把“档”与“能力”分两维,须另开 scope 字段;单一消费者下复用 `tier` 最省,**推荐复用**。
### 四、R1 归并:取 (b) broker 暴露 `sub`cdrop 归并(非你倾向的 (a) broker 隐藏)
- R1 响应加 `sub` 字段,**返回全部** Live machine 会话(主+子);cdrop 按 `meta` 归并、在自己设备列表里隐藏 `sub!=""`
- **为何 (b) 而非 (a)**:① **正确性**——(a) 朴素版“隐藏 sub!="" 行”会让“仅控件存活(主会话已过期、控件独立刷新着)”的设备从列表**消失**;(b) 下 cdrop 按 meta 归并,任一会话存活设备即在列。② **broker 保通用**——R1 是“列某 app 的机器会话”通用原语,“一设备一行”是 cdrop 产品视图,不该烙进 broker。③ cdrop 本就“仅叠加 type/online”后处理 R1,加“按 meta 归并+隐藏 sub”到同一步极廉价、归属也对。
- 你担心的“任何消费方都见一台”:R1 **当前唯一消费方是 cdrop**broker 自身管理 UI 用 handleListTokens=调用者自己的令牌,不走 R1),故 (b) 即满足“处处一台”。**待出现第二个 R1 消费方**再加 broker 侧归并(`?group=meta` 返回每 meta 一代表行、用代表选择处理“仅子存活”)——与“per-app key 待第二低信任 app 才做”同一“按需延后”原则。若你坚持现在就要 broker 归并,我加 `?group=meta`(默认仍 per-session)。
### 五、级联吊销:新增按 meta 端点
- 新增 `DELETE /internal/sessions?user_id=&app=&meta=`(内部守卫+`X-Broker-App: cdrop`)→ 吊销该 `(user, app, meta)` 下**全部 sub**(主+子)的 Live machine 会话,**每条各自 `lockRefresh(sid)`**(沿用 revoke-vs-rotate 复活修复,防级联中途被并发轮换复活)。返回 `200 {"revoked": N}`(幂等:已空→`{revoked:0}`,非 404)。`user_id` 必填(store 按 user 索引;`app`/`meta` 过滤)。
- 现有 `DELETE /internal/sessions/{sid}` 不变(仍可单吊一条子会话,即 §3.4 的“可选单吊”)。
- cdrop“移除设备/登出”按 device_id=`meta` 调此端点即彻底,不留游离控件会话。
### 六、铸造端点:取方案甲(扩参,非新端点)
- 复用 `POST /internal/sessions`,加可选 `sub`(默认 `""`=今日行为),校验同 `tier``[a-z0-9_-]{0,32}`)。响应不变(`issueResp`)。
- **不取方案乙**(新 `/device/subsession`):铸造本是同一“委派 mint”、只多一维,新端点徒增重复逻辑。刷新(按 refresh token)/单吊(按 sid)签名不变。
### 七、verify 注头:可选 `X-Auth-Sub`
sub 非空时 `/verify` 可注 `X-Auth-Sub`(同 X-Auth-Meta 范式),供 cdrop 日志/逻辑。**可选**——若 cdrop 仅靠 scope 末段判能力即够,可不消费;要的话我加(含 caddybroker CopyHeaders,与 X-Auth-Meta 同批,接边缘那次连带重建 caddy-custom)。请告知是否要。
### 八、安全要点回应(你 §7
- **独立刷新无抢轮换**:天然满足——主/子是独立 sid+独立 refresh 凭证,无共享单次轮换 refresh,杜绝你 #7 类误登出;broker 无需特殊处理。
- **scope 最小化**:控件 tier=clipboard,泄露面仅剪贴板。✓
- **级联彻底**:按 meta 吊全 sub,不留游离。✓
- **老客户端零影响**sub="" 即现行为,R1/R2/refresh/revoke 既有语义不变。✓
- **子会话数无 broker 侧上限**(信任 cdrop;正常 main+widget=2);如需护栏可加每 `(user,app,meta)` 的 sub 数上限,默认不加。
### 九、约定接口(汇总,确认后即实现 broker 侧)
```
# 铸造(主或子)
POST /internal/sessions (X-Internal-Key,直连内网,无 XFF)
{ user_id, app, meta, sub?="", tier?, access_ttl?, refresh_ttl?, sliding?, refresh?, label? }
幂等键 (user, app, meta, sub);命中即原地轮换(稳 sid)。
→ 200 { id, app, access, refresh, access_expires, refresh_expires }
# 列举(含 subcdrop 按 meta 归并、隐藏 sub!=""
GET /internal/sessions?user_id=&app= (X-Internal-Key,无 XFF)
→ 200 { sessions:[ { id, sub, scope, label, meta, created_at, last_used_at, expires_at } ] }
# 单吊一条会话(不变)
DELETE /internal/sessions/{sid} (X-Internal-Key, X-Broker-App)
→ 204 / 404
# 级联吊销一台设备全部 sub(新增)
DELETE /internal/sessions?user_id=&app=&meta= (X-Internal-Key, X-Broker-App)
→ 200 { revoked: N }
```
控件会话铸造示例:`{ user_id, app:"cdrop", meta:"<主设备同 meta>", sub:"widget", tier:"clipboard", refresh:true }`
### 待你确认(确认后:broker 侧我实现,cdrop 侧你实现)
1. 命名 `sub`(替 `role`)——认可?
2. R1 取 (b)broker 暴露 subcdrop 归并)——认可?还是要 broker 侧 `?group=meta`
3. scope 缩减复用 `tier`(控件 `tier="clipboard"`)——认可?还是要独立 scope 维?
4. 级联吊销端点形态 `DELETE …?user_id=&app=&meta=`——认可?(备选 `POST /internal/sessions/revoke {…}`
5. 要不要 `X-Auth-Sub` 注头(要则接边缘时连带重建 caddy-custom)?
6. `sub` 取值集(现仅 `"" / "widget"`?将来还有别的扩展进程→是否要 sub 数护栏)。
分工照旧:接口确认后 broker 侧由我实现(`Session.Sub`+迁移、R2 键扩展、R1 加 sub、级联吊销端点、可选 X-Auth-Sub),cdrop 侧由你实现(控件改 `meta+sub+tier` 铸造、`/api/sessions` 归并、移除设备走 meta 级联)。接边缘(若要 X-Auth-Sub)那次连带重建 caddy-custom。
---
## cdrop 方确认(2026-06-27
评审收敛得很到位,三处收敛全部接受,逐条确认如下——broker 侧可据此开工。
1. **命名 `sub`(替 `role`)——确认。** 与 broker 的 `role`Casdoor / `required_role` / verify 角色)解耦正确:`meta`=设备身份、`sub`=设备内会话判别。默认 `""`=主会话。
2. **R1 取 (b)broker 暴露 `sub` + cdrop 按 meta 归并隐藏 `sub!=""`)——确认。** 你的“仅子存活则设备不该消失”反例是决定性的:cdrop 按 `meta` 归并能让“任一会话存活即在列”,而 (a) 朴素隐藏会误删只剩控件会话的设备。R1 当前唯一消费方是 cdrop,(b) 即满足“处处一台”。**不要 `?group=meta`**——待出现第二个 R1 消费方再按需加(同你“按需延后”原则)。
3. **scope 缩减复用 `tier`(控件 `tier="clipboard"`)——确认。** token scope `app:cdrop:clipboard`cdrop 现有 clipboard scope 门照判(clipboard 路由放行、其余路由 reject)。不开独立 scope 维——单一消费者下复用 `tier` 最省,“档 / 能力”都由 cdrop 统一解释 scope 末段。cdrop 侧据此校验:clipboard 档会话仅 `/api/clipboard` 放行。
4. **级联吊销 `DELETE …?user_id=&app=&meta=` → `{revoked:N}`——确认。** 与既有 `DELETE /internal/sessions/{sid}` 同风格,优于 `POST /revoke``user_id` 必填我方满足(移除设备时 cdrop 持 user + device_id=meta)。每条各自 `lockRefresh(sid)` 防级联中途复活——同意。
5. **`X-Auth-Sub` 注头——暂不需要,不必为此重建 caddy-custom。** cdrop 判能力只靠 scope 末段(clipboard)即够;设备列表归并用的 `sub` 来自 R1 内部响应、非边缘头。控件只打 `/api/clipboard`、不入 presence / 传输,故请求期无需区分 main / widget。**待将来确有需要再加**(与 X-Auth-Sub 接边缘那次一并)。
6. **`sub` 取值集:现仅 `""`(主)/ `"widget"`(控制中心 + 主屏小组件,tier=clipboard)。** 命名空间预留给未来扩展进程(如 `"share"` 分享扩展、`"siri"`)。**暂不要 sub 数护栏**——cdrop 自控铸造、正常恒 2 条;若日后扩展进程增多再加每 `(user,app,meta)` 上限。
### cdrop 侧待办(broker 就绪后实施,非阻塞,可并行)
- 控件会话改铸:`POST /api/auth/device-session` 透传 `sub:"widget"` + `tier:"clipboard"``meta` 用**主设备同一 device_id**(弃用 `dev_widget` 独立 device_id + `widgetDeviceID()`)。
- `/api/sessions` + `/api/devices` 列表:按 `meta` 归并、隐藏 `sub!=""`(消费 R1 的 `sub` 字段)。
- 移除设备 / 登出:`revokeDevice` 改调级联端点 `DELETE …?meta=`(取代现按单 sid),保证连带吊控件会话。
- iOS:控件会话 provision 改用主 device_id + sub`WidgetSessionStore` 仍隔离持有控件令牌对(独立刷新不变,#7 隔离不动)。
> 在 broker 实现 `sub` 之前,cdrop 维持现状(控件用独立 `dev_widget` device_id,列表里多一台),不做 §6 的临时 cdrop 侧隐藏——等 broker 干净方案。
-124
View File
@@ -1,124 +0,0 @@
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"commilitia.net/cdrop/internal/apns"
"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/push"
"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)
pushSender := push.NewSender(queries, cfg.VAPIDPublicKey, cfg.VAPIDPrivateKey, cfg.VAPIDSubjectOrDefault())
if pushSender.Enabled() {
slog.Info("web push enabled (VAPID configured)")
} else {
slog.Info("web push disabled (VAPID keys unset)")
}
apnsSender := apns.NewSender(queries, cfg.APNSKeyPEM(), cfg.APNSKeyID, cfg.APNSTeamID, cfg.APNSTopic, cfg.APNSEnv)
if apnsSender.Enabled() {
slog.Info("apns enabled", "topic", cfg.APNSTopic, "env", cfg.APNSEnv)
} else {
slog.Info("apns disabled (APNS keys unset)")
}
transfers := transfer.NewService(queries, h, relayMgr, pushSender, apnsSender)
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)
go httpapi.RunLoginRequestReaper(ctx, queries)
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, pushSender, apnsSender).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")
}
-23
View File
@@ -1,23 +0,0 @@
// Command vapidgen prints a fresh VAPID key pair for Web Push, formatted as the
// two env vars cdrop reads. Generate once and store the keys as deployment
// secrets — rotating them invalidates every existing browser subscription.
//
// just vapid-keygen # or: go run ./cmd/vapidgen
package main
import (
"fmt"
"os"
webpush "github.com/SherClockHolmes/webpush-go"
)
func main() {
priv, pub, err := webpush.GenerateVAPIDKeys()
if err != nil {
fmt.Fprintln(os.Stderr, "vapidgen: generate keys:", err)
os.Exit(1)
}
fmt.Println("CDROP_VAPID_PUBLIC_KEY=" + pub)
fmt.Println("CDROP_VAPID_PRIVATE_KEY=" + priv)
}
-3
View File
@@ -1,3 +0,0 @@
build/bin
node_modules
frontend/dist
-403
View File
@@ -1,403 +0,0 @@
# 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
@@ -1,19 +0,0 @@
# 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
@@ -1,694 +0,0 @@
# 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
-424
View File
@@ -1,424 +0,0 @@
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"
)
// 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)
user platform.UserInfo // display identity from 代铸; preserved across refresh (machine tokens are nameless)
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,
}
a.user = s.User
}
// 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)
// 触发 macOS 本地网络权限(macOS 15+ 隐私门):使本进程 WKWebView 的 WebRTC 能收集 host /
// mDNS 候选、同内网走直连而非 prflx↔prflx 慢路径(见 platform.TriggerLocalNetwork)。其他平台空实现。
platform.TriggerLocalNetwork()
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())
}
// ShowNotification is bound to the WebView. The JS hub calls it when a
// notify-worthy event (incoming file / message / transfer done|failed) arrives
// while the window is NOT in the foreground — so the user gets a native system
// notification instead of an in-app cue they'd miss. Foreground events stay
// in-app: the JS gates on document focus/visibility, so this is only ever
// invoked for the background case. Title/body arrive already localized by the
// WebView (client-side i18n). macOS display additionally needs a signed bundle
// (D6) — until then platform.Notify no-ops safely there; Windows shows now.
func (a *App) ShowNotification(title, body string) {
platform.Notify(title, body)
}
// 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)
})
// Step 1: device-authorize → a bootstrap machine token (proves identity, no meta).
boot, err := flow.Login(a.ctx)
if err != nil {
runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()})
return
}
// Step 2: 代铸 the bootstrap token into a cdrop-managed device session bound to this
// device's stable device_id, so the desktop is managed exactly like a browser. The
// device session's tokens supersede the bootstrap (which is discarded, left to expire).
deviceID, err := platform.ResolveDeviceID()
if err != nil {
runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()})
return
}
ds, err := platform.MintDeviceSession(a.ctx, a.apiBase, boot.AccessToken, deviceID, platform.ResolveDeviceName(), platform.DeviceType())
if err != nil {
runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()})
return
}
// Identity from the verified 代铸 response (X-Auth-Subject / X-Auth-Name / X-Auth-Avatar),
// so the real display name and picture show rather than the subject UUID a nameless
// machine token yields.
user := platform.UserInfo{ID: ds.UserID, Name: ds.Name, Avatar: ds.Avatar}
if user.Name == "" {
user.Name = user.ID
}
res := &platform.LoginResult{
AccessToken: ds.AccessToken,
RefreshToken: ds.RefreshToken,
ExpiresIn: ds.ExpiresIn,
User: user,
}
a.token = &platform.TokenResult{AccessToken: ds.AccessToken, RefreshToken: ds.RefreshToken, ExpiresIn: ds.ExpiresIn}
a.user = user
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
}
// Preserve the identity established at login: the refreshed access token is a nameless
// machine token (it carries meta, not the display name), so re-deriving identity from it
// would regress the name to the subject UUID. The device session keeps its meta across
// rotation, so the device stays the same managed device.
res := &platform.LoginResult{
AccessToken: tok.AccessToken,
RefreshToken: tok.RefreshToken,
ExpiresIn: tok.ExpiresIn,
User: a.user,
}
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)
}
// BeginDownload / AppendDownload / FinalizeDownload / AbortDownload are the
// streaming counterpart of SaveDownload, bound to the WebView. The receiver's
// hybrid sink keeps a file in memory until it crosses a cap, then spills the
// overflow here so large files don't sit in the WebView heap (the wails:// no-OPFS
// deadlock). Begin opens a temp file in the download dir, Append streams batches
// (base64 over the bridge), Finalize renames it into place and returns the path,
// Abort discards it on cancel / failure.
func (a *App) BeginDownload(sessionId string) error {
return platform.BeginStreamingDownload(platform.ResolveDownloadDir(), sessionId)
}
func (a *App) AppendDownload(sessionId string, data []byte) error {
return platform.AppendStreamingDownload(sessionId, data)
}
func (a *App) FinalizeDownload(sessionId, name string) (string, error) {
return platform.FinalizeStreamingDownload(sessionId, name)
}
func (a *App) AbortDownload(sessionId string) {
platform.AbortStreamingDownload(sessionId)
}
// 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 broker);
// otherwise it fetches /api/auth/config from the backend for the broker's public URL,
// then runs the broker device-authorization flow against it.
func (a *App) resolveOAuthConfig() (platform.OAuthConfig, error) {
if env := oauthConfigFromEnv(); env.BrokerURL != "" {
return env, nil
}
return platform.FetchOAuthConfig(a.ctx, a.apiBase)
}
func oauthConfigFromEnv() platform.OAuthConfig {
return platform.OAuthConfig{
BrokerURL: os.Getenv("CDROP_BROKER_URL"),
App: envOr("CDROP_BROKER_APP", "cdrop"),
}
}
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
@@ -1,35 +0,0 @@
# 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.

Before

Width:  |  Height:  |  Size: 1.4 MiB

-76
View File
@@ -1,76 +0,0 @@
<!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>
<key>NSCameraUsageDescription</key>
<string>Commilitia Drop 使用相机扫描二维码以登录 / 批准设备。</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Commilitia Drop 使用本地网络发现同内网设备并建立点对点直连传输。</string>
<key>NSBonjourServices</key>
<array>
<string>_cdrop._tcp</string>
</array>
{{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>
-71
View File
@@ -1,71 +0,0 @@
<!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>
<key>NSCameraUsageDescription</key>
<string>Commilitia Drop 使用相机扫描二维码以登录 / 批准设备。</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Commilitia Drop 使用本地网络发现同内网设备并建立点对点直连传输。</string>
<key>NSBonjourServices</key>
<array>
<string>_cdrop._tcp</string>
</array>
{{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.

Before

Width:  |  Height:  |  Size: 168 KiB

-15
View File
@@ -1,15 +0,0 @@
{
"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
@@ -1,114 +0,0 @@
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
@@ -1,249 +0,0 @@
# 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
@@ -1,15 +0,0 @@
<?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
@@ -1,101 +0,0 @@
//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
@@ -1,12 +0,0 @@
<!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
@@ -1,679 +0,0 @@
{
"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
@@ -1,14 +0,0 @@
{
"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
@@ -1 +0,0 @@
f8412e249716e9530567e4bc92d8331c
-54
View File
@@ -1,54 +0,0 @@
#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
@@ -1,93 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 136 KiB

-50
View File
@@ -1,50 +0,0 @@
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
@@ -1,26 +0,0 @@
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
@@ -1 +0,0 @@
/// <reference types="vite/client" />
-25
View File
@@ -1,25 +0,0 @@
{
"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
@@ -1,49 +0,0 @@
module cdrop-desktop
go 1.24
require (
fyne.io/systray v1.12.2
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3
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 (
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
@@ -1,103 +0,0 @@
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=
-103
View File
@@ -1,103 +0,0 @@
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 := platform.DeviceType()
// 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()
// Heal a stale display identity (older builds baked the subject UUID and no avatar into
// the on-disk session) from the authoritative /api/me, and re-persist, BEFORE injecting it
// — so the name + picture are correct on every launch instead of depending on a fragile
// per-launch WebView /api/me (the source of the intermittent UUID regression). Bounded +
// best-effort: offline keeps the persisted identity.
session = platform.HealIdentity(session, app.apiBase)
// 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
@@ -1,108 +0,0 @@
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
@@ -1,90 +0,0 @@
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")
}
}
-145
View File
@@ -1,145 +0,0 @@
package platform
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"os"
"path/filepath"
"runtime"
)
// 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
// DeviceID is this device's stable opaque cdrop id — the broker `meta` and the
// session<->device join key. Minted once on first login and persisted so re-logins
// rotate the one managed device session (broker R2) instead of spawning duplicates.
DeviceID string `json:"device_id,omitempty"`
}
// DeviceType maps the host OS to the cdrop device kind, shown in the unified device list
// and sent as device_type when 代铸ing this device's session.
func DeviceType() string {
switch runtime.GOOS {
case "darwin":
return "macos"
case "windows":
return "windows"
case "linux":
return "linux"
default:
return "browser"
}
}
// ResolveDeviceID returns the persisted stable device_id, minting and persisting a fresh
// one ("dev_" + base64url(16 random bytes)) on first use. The format matches the backend's
// newDeviceID, so it passes validDeviceID and the broker's meta validation.
func ResolveDeviceID() (string, error) {
cfg, _ := LoadConfig()
if cfg.DeviceID != "" {
return cfg.DeviceID, nil
}
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
cfg.DeviceID = "dev_" + base64.RawURLEncoding.EncodeToString(b)
if err := SaveConfig(cfg); err != nil {
return "", err
}
return cfg.DeviceID, nil
}
// 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
@@ -1,55 +0,0 @@
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
@@ -1,151 +0,0 @@
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
@@ -1,80 +0,0 @@
//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
@@ -1,42 +0,0 @@
#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];
}
@@ -1,24 +0,0 @@
//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
@@ -1,199 +0,0 @@
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
@@ -1,67 +0,0 @@
//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
}
-53
View File
@@ -1,53 +0,0 @@
package platform
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
)
// FetchOAuthConfig pulls the Auth Broker coordinates from the cdrop backend's
// /api/auth/config (broker_url = the broker's public origin). The desktop runs the
// broker device-authorization flow (RFC 8252 loopback PKCE) against that broker,
// minting a cdrop-scoped machine token. 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 {
BrokerURL string `json:"broker_url"`
App string `json:"broker_app"`
}
if err := json.NewDecoder(resp.Body).Decode(&c); err != nil {
return OAuthConfig{}, fmt.Errorf("oauth: decode config: %w", err)
}
app := c.App
if app == "" {
app = "cdrop"
}
cfg := OAuthConfig{BrokerURL: c.BrokerURL, App: app}
if cfg.BrokerURL == "" {
return OAuthConfig{}, errors.New("oauth: backend config missing broker_url")
}
return cfg, nil
}
-74
View File
@@ -1,74 +0,0 @@
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",
"broker_url": "https://sso.example.net",
"broker_app": "cdrop",
})
}))
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.BrokerURL != "https://sso.example.net" {
t.Errorf("broker_url = %q", cfg.BrokerURL)
}
if cfg.App != "cdrop" {
t.Errorf("app = %q, want cdrop", cfg.App)
}
}
func TestFetchOAuthConfig_DefaultsApp(t *testing.T) {
// broker_app omitted → defaults to "cdrop".
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{"broker_url": "https://sso.example.net"})
}))
defer srv.Close()
cfg, err := FetchOAuthConfig(context.Background(), srv.URL)
if err != nil {
t.Fatalf("FetchOAuthConfig: %v", err)
}
if cfg.App != "cdrop" {
t.Errorf("app = %q, want default cdrop", cfg.App)
}
}
func TestFetchOAuthConfig_Incomplete(t *testing.T) {
// backend missing broker_url (native login not configured) 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": "prod"})
}))
defer srv.Close()
if _, err := FetchOAuthConfig(context.Background(), srv.URL); err == nil {
t.Fatal("want error for missing broker_url, 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")
}
}
-89
View File
@@ -1,89 +0,0 @@
package platform
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
)
// 代铸 (proxy-mint) on the desktop. The device-authorization flow yields a bootstrap
// machine token (scope app:cdrop, no meta) that proves the user's identity but is not a
// cdrop-managed device. This call exchanges it for a managed device session bound to this
// device's stable device_id, so the desktop joins cdrop's unified device list and is
// managed exactly like a browser — the same model, not a separate native-only track.
// DeviceSessionResult is the managed device session minted by 代铸: the device tokens that
// supersede the bootstrap token, plus the verified identity (so a native client, which only
// ever sees nameless machine tokens, shows the real display name instead of the sub UUID).
type DeviceSessionResult struct {
AccessToken string
RefreshToken string
ExpiresIn int
DeviceID string
UserID string
Name string
Avatar string
}
// MintDeviceSession POSTs to {apiBase}/api/auth/device-session with the bootstrap token as
// Bearer; cdrop vouches for the verified subject and has the broker mint (R2-idempotent by
// user+app+meta) a session bound to deviceID. Re-login with the same deviceID rotates the
// one session rather than piling up duplicates.
func MintDeviceSession(ctx context.Context, apiBase, bootstrapToken, deviceID, deviceName, deviceType string) (*DeviceSessionResult, error) {
body, err := json.Marshal(map[string]string{
"device_id": deviceID,
"device_name": deviceName,
"device_type": deviceType,
})
if err != nil {
return nil, fmt.Errorf("device-session: marshal: %w", err)
}
endpoint := strings.TrimRight(apiBase, "/") + "/api/auth/device-session"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("device-session: build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+bootstrapToken)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("device-session: request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("device-session: status %d", resp.StatusCode)
}
var dr struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
DeviceID string `json:"device_id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Avatar string `json:"avatar"`
}
if err := json.NewDecoder(resp.Body).Decode(&dr); err != nil {
return nil, fmt.Errorf("device-session: decode: %w", err)
}
if dr.AccessToken == "" {
return nil, errors.New("device-session: response missing access token")
}
return &DeviceSessionResult{
AccessToken: dr.AccessToken,
RefreshToken: dr.RefreshToken,
ExpiresIn: dr.ExpiresIn,
DeviceID: dr.DeviceID,
UserID: dr.UserID,
Name: dr.Name,
Avatar: dr.Avatar,
}, nil
}
-81
View File
@@ -1,81 +0,0 @@
package platform
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// MintDeviceSession sends the bootstrap token as Bearer + the device descriptor, and maps
// the response into the device-session result the desktop then rides.
func TestMintDeviceSession(t *testing.T) {
var gotAuth string
var gotBody map[string]string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/auth/device-session" {
http.NotFound(w, r)
return
}
gotAuth = r.Header.Get("Authorization")
_ = json.NewDecoder(r.Body).Decode(&gotBody)
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "acc-x", "refresh_token": "rtk-x", "expires_in": 900,
"device_id": "dev_abc", "user_id": "sub-1", "name": "Commilitia",
"avatar": "https://example.net/a.png",
})
}))
defer srv.Close()
res, err := MintDeviceSession(context.Background(), srv.URL, "boot-token", "dev_abc", "Mac", "macos")
if err != nil {
t.Fatalf("MintDeviceSession: %v", err)
}
if gotAuth != "Bearer boot-token" {
t.Errorf("Authorization: got %q, want Bearer boot-token", gotAuth)
}
if gotBody["device_id"] != "dev_abc" || gotBody["device_name"] != "Mac" || gotBody["device_type"] != "macos" {
t.Errorf("request body: %+v", gotBody)
}
if res.AccessToken != "acc-x" || res.RefreshToken != "rtk-x" || res.DeviceID != "dev_abc" ||
res.UserID != "sub-1" || res.Name != "Commilitia" || res.ExpiresIn != 900 ||
res.Avatar != "https://example.net/a.png" {
t.Errorf("result: %+v", res)
}
}
// A non-200 from the endpoint surfaces as an error rather than a half-built session.
func TestMintDeviceSession_ErrorStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}))
defer srv.Close()
if _, err := MintDeviceSession(context.Background(), srv.URL, "boot", "dev_abc", "Mac", "macos"); err == nil {
t.Fatal("expected error on non-200, got nil")
}
}
// ResolveDeviceID mints a validMeta-safe "dev_"-prefixed id and persists it (stable across
// calls). Uses an isolated config dir so it doesn't touch the real one.
func TestResolveDeviceID(t *testing.T) {
// os.UserConfigDir honours XDG_CONFIG_HOME on Linux and HOME on macOS.
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv("HOME", dir)
id, err := ResolveDeviceID()
if err != nil {
t.Fatalf("ResolveDeviceID: %v", err)
}
if !strings.HasPrefix(id, "dev_") || len(id) < 8 {
t.Errorf("device id format: %q", id)
}
again, err := ResolveDeviceID()
if err != nil {
t.Fatalf("ResolveDeviceID (2): %v", err)
}
if again != id {
t.Errorf("device id not stable: %q != %q", again, id)
}
}
-274
View File
@@ -1,274 +0,0 @@
package platform
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
)
// 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.
// CDROP_NO_REVEAL skips the side effect — for headless / CI / test runs that
// shouldn't pop a Finder/Explorer window.
func revealInFileManager(path string) {
if os.Getenv("CDROP_NO_REVEAL") != "" {
return
}
switch runtime.GOOS {
case "darwin":
_ = exec.Command("open", "-R", path).Start()
case "windows":
_ = exec.Command("explorer", "/select,"+path).Start()
}
}
// Streaming downloads — the receiver's hybrid sink (web/.../incomingSink.ts) keeps
// a file in memory until it crosses a cap (default 256 MB), then spills to disk
// through this manager instead of carrying the whole thing in the WebView heap. We
// can't use SaveDownload for that: it takes the complete bytes at once. The reason
// the whole hybrid exists is the wails:// scheme has no persistent WebView storage,
// so OPFS degrades and starves the receive loop (the PWA→Mac ~15 MB deadlock); the
// fix is to stage bounded memory and stream the overflow into a Go-owned temp file.
//
// The temp file lives in the SAME directory as the final target so finalize is an
// atomic rename, not a cross-device copy. The writable dir is chosen up front (with
// the same unwritable→system-Downloads fallback as SaveDownload) because once GBs
// are streamed we can't cheaply switch volumes.
type streamingDownload struct {
file *os.File
dir string // the writable dir chosen at Begin; the final file is renamed into it
tmp string // the .part temp path under dir
}
var (
streamMu sync.Mutex
streamDownloads = map[string]*streamingDownload{}
)
// BeginStreamingDownload opens a .part temp file under a writable download dir for
// the session. dir == "" → system Downloads; an unwritable dir falls back to system
// Downloads (mirrors SaveDownload). A stale entry for the same session id is closed
// and removed first, so a retried transfer never leaks a handle or temp file.
func BeginStreamingDownload(dir, sessionId string) error {
if dir == "" {
dir = DefaultDownloadDir()
}
// Drop any stale handle for this session FIRST (a retried transfer). Its temp
// path equals the new one (both derive from the session id), so removing it
// after opening the fresh file would delete the file we just created. Clean
// up first; openStreamTemp then re-creates the temp via O_TRUNC.
streamMu.Lock()
if prev, ok := streamDownloads[sessionId]; ok {
_ = prev.file.Close()
_ = os.Remove(prev.tmp)
delete(streamDownloads, sessionId)
}
streamMu.Unlock()
f, dirUsed, tmp, err := openStreamTemp(dir, sessionId)
if err != nil {
return err
}
streamMu.Lock()
streamDownloads[sessionId] = &streamingDownload{file: f, dir: dirUsed, tmp: tmp}
streamMu.Unlock()
return nil
}
// openStreamTemp creates a writable .part temp file under dir, retrying into the
// system Downloads dir if dir is unwritable (mirrors SaveDownload's fallback).
func openStreamTemp(dir, sessionId string) (*os.File, string, string, error) {
f, tmp, err := tryStreamTemp(dir, sessionId)
if err == nil {
return f, dir, tmp, nil
}
if def := DefaultDownloadDir(); def != dir {
if f2, tmp2, ferr := tryStreamTemp(def, sessionId); ferr == nil {
return f2, def, tmp2, nil
}
}
return nil, "", "", err
}
func tryStreamTemp(dir, sessionId string) (*os.File, string, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, "", err
}
tmp := filepath.Join(dir, ".cdrop-"+sanitizeSessionID(sessionId)+".part")
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
return nil, "", err
}
return f, tmp, nil
}
// sanitizeSessionID reduces the peer-supplied session id to filename-safe chars so
// it can name the .part temp file. It only has to be unique and path-safe, not
// reversible — the real filename is applied at finalize.
func sanitizeSessionID(id string) string {
var b strings.Builder
for _, r := range id {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
b.WriteRune(r)
}
}
s := b.String()
if s == "" {
s = "session"
}
if len(s) > 64 {
s = s[:64]
}
return s
}
// AppendStreamingDownload writes a batch of bytes to the session's temp file.
func AppendStreamingDownload(sessionId string, data []byte) error {
streamMu.Lock()
d := streamDownloads[sessionId]
streamMu.Unlock()
if d == nil {
return fmt.Errorf("no streaming download for session %q", sessionId)
}
_, err := d.file.Write(data)
return err
}
// FinalizeStreamingDownload closes the temp file and renames it to a non-colliding
// final name under the same dir (name sanitized like SaveDownload), then reveals
// it. Returns the absolute path written.
func FinalizeStreamingDownload(sessionId, name string) (string, error) {
streamMu.Lock()
d := streamDownloads[sessionId]
delete(streamDownloads, sessionId)
streamMu.Unlock()
if d == nil {
return "", fmt.Errorf("no streaming download for session %q", sessionId)
}
if err := d.file.Close(); err != nil {
_ = os.Remove(d.tmp)
return "", err
}
safe := sanitizeFileName(name)
if safe == "" {
safe = "cdrop-download"
}
target := uniquePath(d.dir, safe)
if err := os.Rename(d.tmp, target); err != nil {
_ = os.Remove(d.tmp)
return "", err
}
revealInFileManager(target)
return target, nil
}
// AbortStreamingDownload closes and deletes the session's temp file (cancel /
// failure path). No-op when the session isn't streaming.
func AbortStreamingDownload(sessionId string) {
streamMu.Lock()
d := streamDownloads[sessionId]
delete(streamDownloads, sessionId)
streamMu.Unlock()
if d == nil {
return
}
_ = d.file.Close()
_ = os.Remove(d.tmp)
}
-136
View File
@@ -1,136 +0,0 @@
package platform
import (
"bytes"
"os"
"path/filepath"
"testing"
)
// streamAll runs the full Begin → Append* → Finalize cycle for one session into
// dir and returns the final path. CDROP_NO_REVEAL keeps Finder/Explorer shut.
func streamAll(t *testing.T, dir, sessionID, name string, chunks ...[]byte) string {
t.Helper()
t.Setenv("CDROP_NO_REVEAL", "1")
if err := BeginStreamingDownload(dir, sessionID); err != nil {
t.Fatalf("Begin: %v", err)
}
for i, c := range chunks {
if err := AppendStreamingDownload(sessionID, c); err != nil {
t.Fatalf("Append %d: %v", i, err)
}
}
path, err := FinalizeStreamingDownload(sessionID, name)
if err != nil {
t.Fatalf("Finalize: %v", err)
}
return path
}
func TestStreamingDownload_WritesAllBytes(t *testing.T) {
dir := t.TempDir()
path := streamAll(t, dir, "sess-1", "greeting.txt",
[]byte("hello "), []byte("streamed "), []byte("world"))
if got, want := path, filepath.Join(dir, "greeting.txt"); got != want {
t.Errorf("path = %q, want %q", got, want)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if want := "hello streamed world"; string(got) != want {
t.Errorf("content = %q, want %q", got, want)
}
// The .part temp must be gone after a successful finalize.
if _, err := os.Stat(filepath.Join(dir, ".cdrop-sess-1.part")); !os.IsNotExist(err) {
t.Errorf("temp file not cleaned up: %v", err)
}
}
func TestStreamingDownload_NoCollisionOverwrite(t *testing.T) {
dir := t.TempDir()
// Pre-existing file with the same name must not be overwritten.
if err := os.WriteFile(filepath.Join(dir, "f.bin"), []byte("original"), 0o644); err != nil {
t.Fatal(err)
}
path := streamAll(t, dir, "sess-2", "f.bin", []byte("new"))
if got, want := path, filepath.Join(dir, "f (1).bin"); got != want {
t.Errorf("path = %q, want %q", got, want)
}
orig, _ := os.ReadFile(filepath.Join(dir, "f.bin"))
if string(orig) != "original" {
t.Errorf("original file was overwritten: %q", orig)
}
}
func TestStreamingDownload_SanitizesTraversalName(t *testing.T) {
dir := t.TempDir()
// A crafted name with path components must collapse to a bare filename under
// dir — it must never escape via "../".
path := streamAll(t, dir, "sess-3", "../../etc/evil", []byte("x"))
if filepath.Dir(path) != dir {
t.Errorf("escaped dir: path = %q, dir = %q", path, dir)
}
if got := filepath.Base(path); got != "evil" {
t.Errorf("base = %q, want %q", got, "evil")
}
}
func TestStreamingDownload_AbortRemovesTemp(t *testing.T) {
dir := t.TempDir()
t.Setenv("CDROP_NO_REVEAL", "1")
if err := BeginStreamingDownload(dir, "sess-4"); err != nil {
t.Fatalf("Begin: %v", err)
}
if err := AppendStreamingDownload("sess-4", []byte("partial")); err != nil {
t.Fatalf("Append: %v", err)
}
tmp := filepath.Join(dir, ".cdrop-sess-4.part")
if _, err := os.Stat(tmp); err != nil {
t.Fatalf("temp should exist mid-stream: %v", err)
}
AbortStreamingDownload("sess-4")
if _, err := os.Stat(tmp); !os.IsNotExist(err) {
t.Errorf("temp file not removed after abort: %v", err)
}
// A second append after abort must error, not panic or write a stray file.
if err := AppendStreamingDownload("sess-4", []byte("late")); err == nil {
t.Error("Append after abort: want error, got nil")
}
}
func TestStreamingDownload_ReBeginReplacesStale(t *testing.T) {
dir := t.TempDir()
t.Setenv("CDROP_NO_REVEAL", "1")
// First Begin + partial write, then a second Begin for the same session id
// (a retried transfer) must truncate, not append to, the stale temp.
if err := BeginStreamingDownload(dir, "sess-5"); err != nil {
t.Fatalf("Begin 1: %v", err)
}
if err := AppendStreamingDownload("sess-5", []byte("STALE")); err != nil {
t.Fatalf("Append 1: %v", err)
}
if err := BeginStreamingDownload(dir, "sess-5"); err != nil {
t.Fatalf("Begin 2: %v", err)
}
if err := AppendStreamingDownload("sess-5", []byte("fresh")); err != nil {
t.Fatalf("Append 2: %v", err)
}
path, err := FinalizeStreamingDownload("sess-5", "out.bin")
if err != nil {
t.Fatalf("Finalize: %v", err)
}
got, _ := os.ReadFile(path)
if !bytes.Equal(got, []byte("fresh")) {
t.Errorf("content = %q, want %q (stale bytes leaked)", got, "fresh")
}
}
func TestStreamingDownload_FinalizeUnknownSession(t *testing.T) {
if _, err := FinalizeStreamingDownload("never-began", "x"); err == nil {
t.Error("Finalize unknown session: want error, got nil")
}
}
-104
View File
@@ -1,104 +0,0 @@
package platform
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strings"
"time"
)
// HealIdentity refreshes a persisted session's display identity (name + avatar) from the
// authoritative /api/me and re-persists it, BEFORE the session is injected into the WebView.
//
// Why: a session minted by an older build derived identity from the broker's nameless machine
// token, baking the subject UUID (and no avatar) into the on-disk session. The newer login path
// records the real name/picture, but an already-logged-in user keeps the stale session. Correcting
// it only in the WebView (per launch, via /api/me) is fragile — when the boot access token has
// expired and the refresh is momentarily flaky, the correction silently fails and the UUID shows
// again (the reported intermittent regression). Healing the persisted session here fixes it at the
// source: once a single launch succeeds, the on-disk identity is correct and every later boot is
// correct without any /api/me dependency.
//
// Synchronous and bounded so it can run before wails.Run; best-effort — on any network failure it
// returns the session unchanged (offline simply keeps whatever was last persisted, which a prior
// successful heal already corrected). If the access token has expired it refreshes once (and the
// rotated refresh token is persisted so it is not lost).
func HealIdentity(s *LoginResult, apiBase string) *LoginResult {
if s == nil || s.AccessToken == "" {
return s
}
// Once healed, the persisted name is a real display name (not the subject UUID); skip the
// /api/me round-trip then so it costs nothing on every later launch. A stale session (name
// still equal to the UUID) falls through and heals — fetching the avatar in the same pass.
if s.User.Name != "" && s.User.Name != s.User.ID {
return s
}
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
info, status := fetchMe(ctx, apiBase, s.AccessToken)
tokenRotated := false
if status == http.StatusUnauthorized && s.RefreshToken != "" {
if cfg, err := FetchOAuthConfig(ctx, apiBase); err == nil {
if tok, rerr := NewFlow(cfg, func(string) {}).Refresh(ctx, s.RefreshToken); rerr == nil && tok.AccessToken != "" {
s.AccessToken = tok.AccessToken
if tok.RefreshToken != "" {
s.RefreshToken = tok.RefreshToken // broker rotates refresh; the old one is now dead
}
s.ExpiresIn = tok.ExpiresIn
tokenRotated = true
info, status = fetchMe(ctx, apiBase, s.AccessToken)
}
}
}
changed := tokenRotated // a rotated refresh token must be persisted, regardless of the heal outcome
if status == http.StatusOK && info.ID != "" {
// Replace a stale name only with a real display name (not the subject UUID).
if info.Name != "" && info.Name != info.ID && info.Name != s.User.Name {
s.User.Name = info.Name
changed = true
}
if info.Avatar != "" && info.Avatar != s.User.Avatar {
s.User.Avatar = info.Avatar
changed = true
}
}
if changed {
if err := SaveSession(*s); err != nil {
slog.Warn("cdrop: heal identity save failed", "err", err)
}
}
return s
}
// fetchMe GETs /api/me with the access token, returning the identity and HTTP status (0 on a
// transport error). It hits the public origin directly (no WebView proxy needed here).
func fetchMe(ctx context.Context, apiBase, accessToken string) (UserInfo, int) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(apiBase, "/")+"/api/me", nil)
if err != nil {
return UserInfo{}, 0
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/json")
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
return UserInfo{}, 0
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return UserInfo{}, resp.StatusCode
}
var m struct {
UserID string `json:"user_id"`
Name string `json:"name"`
Avatar string `json:"avatar"`
}
if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
return UserInfo{}, 0
}
return UserInfo{ID: m.UserID, Name: m.Name, Avatar: m.Avatar}, http.StatusOK
}
-66
View File
@@ -1,66 +0,0 @@
package platform
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
keyring "github.com/zalando/go-keyring"
)
// A session whose name is still the subject UUID (minted by an older build) heals from
// /api/me — name + avatar corrected and re-persisted, so later launches are correct without
// any further /api/me.
func TestHealIdentity_StaleNameHealed(t *testing.T) {
keyring.MockInit()
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv("HOME", dir)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/me" {
_ = json.NewEncoder(w).Encode(map[string]any{
"user_id": "sub-1", "name": "Commilitia", "avatar": "https://a.net/x.png",
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
s := &LoginResult{
AccessToken: "acc", RefreshToken: "rtk", ExpiresIn: 900,
User: UserInfo{ID: "sub-1", Name: "sub-1"}, // stale: name == subject UUID
}
out := HealIdentity(s, srv.URL)
if out.User.Name != "Commilitia" {
t.Errorf("name: got %q, want Commilitia", out.User.Name)
}
if out.User.Avatar != "https://a.net/x.png" {
t.Errorf("avatar: got %q, want the /api/me avatar", out.User.Avatar)
}
loaded, err := LoadSession()
if err != nil || loaded == nil || loaded.User.Name != "Commilitia" || loaded.User.Avatar != "https://a.net/x.png" {
t.Errorf("heal not persisted: %+v (err %v)", loaded, err)
}
}
// A session that already has a real display name is left untouched and never hits /api/me.
func TestHealIdentity_HealthyNameSkips(t *testing.T) {
called := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
s := &LoginResult{AccessToken: "acc", User: UserInfo{ID: "sub-1", Name: "Commilitia"}}
out := HealIdentity(s, srv.URL)
if called {
t.Error("a healthy identity must skip the /api/me round-trip")
}
if out.User.Name != "Commilitia" {
t.Errorf("name changed unexpectedly: %q", out.User.Name)
}
}
-104
View File
@@ -1,104 +0,0 @@
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
@@ -1,94 +0,0 @@
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
@@ -1,117 +0,0 @@
//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)
}
@@ -1,66 +0,0 @@
//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
@@ -1,13 +0,0 @@
//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
@@ -1,53 +0,0 @@
//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
}
-21
View File
@@ -1,21 +0,0 @@
//go:build darwin
package platform
/*
#cgo darwin CFLAGS: -x objective-c -fobjc-arc
#cgo darwin LDFLAGS: -framework Network
#include <stdlib.h>
void cdropTriggerLocalNetwork(void);
*/
import "C"
// TriggerLocalNetwork 在 macOS 上发起一次 Bonjour 浏览,触发「本地网络」权限请求(macOS 15+ /
// Sequoia 起也有本地网络隐私门)。未授权时本进程内的 WKWebView WebRTC 收集不到 host / mDNS 候选
// → 同内网传输退到 prflx↔prflx 慢路径(实测桌面→iOS 仅几百 KB/s,而 Chrome 走 host 可达近
// 10 MB/s)。授权后 WKWebView 才能拿到本地候选实现直连。仅 macOS 需要;Windows 无此门(见
// localnetwork_other.go 的空实现)。幂等:原生侧保活单个 browser。
func TriggerLocalNetwork() {
C.cdropTriggerLocalNetwork()
}
-33
View File
@@ -1,33 +0,0 @@
#import <Network/Network.h>
// cdropTriggerLocalNetwork:起一个对 _cdrop._tcp 的 Bonjour 浏览,触发 macOS 本地网络权限弹窗。
// WKWebView 自身不会触发该权限请求,须由宿主 App 主动发起一次本地网络访问;授权后本进程内的
// WebRTC 才能收集 host / mDNS 候选实现同内网直连(见 localnetwork_darwin.go 注释)。浏览结果本身
// 不关心——「发起访问」这一动作即触发授权。保活单个 browser(静态全局,ARC 下持有),幂等。
static nw_browser_t gCdropBrowser = nil;
void cdropTriggerLocalNetwork(void) {
if (gCdropBrowser != nil) {
return;
}
nw_browse_descriptor_t descriptor =
nw_browse_descriptor_create_bonjour_service("_cdrop._tcp", NULL);
nw_parameters_t parameters = nw_parameters_create();
nw_parameters_set_include_peer_to_peer(parameters, true);
nw_browser_t browser = nw_browser_create(descriptor, parameters);
nw_browser_set_queue(browser, dispatch_get_main_queue());
nw_browser_set_browse_results_changed_handler(
browser, ^(nw_browse_result_t old_result, nw_browse_result_t new_result, bool batch_complete) {
// 不关心结果;浏览动作本身即触发权限请求。
});
nw_browser_set_state_changed_handler(
browser, ^(nw_browser_state_t state, nw_error_t error) {
// 进入 failed/cancelled 时释放,允许下次重试。
if (state == nw_browser_state_failed || state == nw_browser_state_cancelled) {
gCdropBrowser = nil;
}
});
gCdropBrowser = browser;
nw_browser_start(browser);
}
-6
View File
@@ -1,6 +0,0 @@
//go:build !darwin
package platform
// TriggerLocalNetwork 仅 macOS 需要(本地网络隐私门)。Windows / Linux 无此门,空实现。
func TriggerLocalNetwork() {}
-68
View File
@@ -1,68 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 109 KiB

-31
View File
@@ -1,31 +0,0 @@
//go:build darwin
package platform
/*
#cgo darwin CFLAGS: -x objective-c -fobjc-arc
#cgo darwin LDFLAGS: -framework Foundation -framework UserNotifications
#include <stdlib.h>
void cdropNotify(const char *title, const char *body);
*/
import "C"
import "unsafe"
// Notify shows a native system notification.
//
// macOS uses UNUserNotificationCenter, which REQUIRES the app bundle to be
// code-signed with a valid bundle identifier (see desktop/PLAN.md D3/D6). On an
// unsigned / ad-hoc build the ObjC side detects the missing bundle proxy and
// no-ops rather than crashing — so this is safe to call from the current build;
// it simply won't display until the signing + notarization pipeline (D6) ships.
//
// Safe to call from any goroutine: the native work hops to the main queue.
func Notify(title, body string) {
ct := C.CString(title)
cb := C.CString(body)
defer C.free(unsafe.Pointer(ct))
defer C.free(unsafe.Pointer(cb))
C.cdropNotify(ct, cb)
}
-54
View File
@@ -1,54 +0,0 @@
//go:build darwin
#import <Foundation/Foundation.h>
#import <UserNotifications/UserNotifications.h>
// cdropNotify posts a user notification via UNUserNotificationCenter.
//
// Guarded for the unsigned case: UNUserNotificationCenter raises
// NSInternalInconsistencyException ("bundleProxyForCurrentProcess is nil") when
// the process has no signed bundle proxy. We bail when there's no bundle
// identifier and wrap the calls in @try/@catch, so an unsigned / ad-hoc build
// degrades to a silent no-op instead of crashing the app. On a signed build it
// requests authorization once (idempotent after the first grant) and delivers.
void cdropNotify(const char *title, const char *body) {
if (title == NULL || body == NULL) {
return;
}
NSString *nsTitle = [NSString stringWithUTF8String:title];
NSString *nsBody = [NSString stringWithUTF8String:body];
// No bundle identifier → unsigned / non-bundle build → UNUserNotificationCenter
// would throw. Skip; display lights up once the bundle is signed (D6).
if ([[NSBundle mainBundle] bundleIdentifier] == nil) {
return;
}
dispatch_async(dispatch_get_main_queue(), ^{
// UNUserNotificationCenter is macOS 10.14+. Guard so older targets (Wails
// defaults the deployment target to 10.13) compile clean and no-op there.
if (@available(macOS 10.14, *)) {
@try {
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
[center requestAuthorizationWithOptions:(UNAuthorizationOptionAlert | UNAuthorizationOptionSound)
completionHandler:^(BOOL granted, NSError *_Nullable error) {
if (!granted) {
return;
}
UNMutableNotificationContent *content = [[UNMutableNotificationContent alloc] init];
content.title = nsTitle;
content.body = nsBody;
content.sound = [UNNotificationSound defaultSound];
UNNotificationRequest *request =
[UNNotificationRequest requestWithIdentifier:[[NSUUID UUID] UUIDString]
content:content
trigger:nil];
[center addNotificationRequest:request withCompletionHandler:nil];
}];
} @catch (NSException *exception) {
// Unsigned bundle or notifications unavailable — ignore.
}
}
});
}
-8
View File
@@ -1,8 +0,0 @@
//go:build !darwin && !windows
package platform
// Notify is a no-op on platforms without a native notification backend wired up.
// The desktop client targets macOS and Windows; this keeps the package building
// on other GOOS (e.g. a Linux `go vet` / CI pass).
func Notify(_, _ string) {}
-42
View File
@@ -1,42 +0,0 @@
//go:build windows
package platform
import (
"os"
"sync"
toast "git.sr.ht/~jackmordaunt/go-toast/v2"
)
const (
// toastAppID 是 Windows Action Center 里显示的应用标识,与 macOS bundle id 对齐。
toastAppID = "net.commilitia.cdrop"
// toastGUID 固定不变——它把通知归属到注册表里的本应用条目;更换会让既有通知
// 失去归属。
toastGUID = "{c4d8e2a1-6b3f-4e7a-9c2d-1f5b8a0e3d6c}"
)
var toastInit sync.Once
// Notify shows a native Windows toast. go-toast renders via the WinRT/COM path
// (PowerShell fallback when the AppID isn't registered), so it works without code
// signing — SmartScreen only gates the installer, not notifications. Best-effort:
// errors are swallowed. SetAppData registers the app identity once so the toast
// shows "cdrop" rather than the PowerShell host.
func Notify(title, body string) {
toastInit.Do(func() {
data := toast.AppData{AppID: toastAppID, GUID: toastGUID}
if exe, err := os.Executable(); err == nil {
data.ActivationExe = exe
}
_ = toast.SetAppData(data)
})
n := toast.Notification{
AppID: toastAppID,
Title: title,
Body: body,
}
_ = n.Push()
}
-265
View File
@@ -1,265 +0,0 @@
// Package platform implements cdrop desktop's native capabilities — the Go
// shell that the shared web UI drives over Wails bindings.
//
// This file owns the login flow: the Auth Broker device-authorization flow (RFC
// 8252 loopback redirect with PKCE, as a public client — no secret). The desktop
// opens the system browser at the broker's consent page; on approval the broker
// redirects to the loopback /callback with a one-time code, which Go exchanges
// (with the PKCE verifier) for a broker machine token. The verifier and the
// resulting tokens never enter the WebView / JS context — refresh is Go-driven.
package platform
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// OAuthConfig carries the Auth Broker coordinates. BrokerURL is the broker's PUBLIC
// origin (e.g. https://sso.commilitia.net); App is this app's key in the broker apps
// registry ("cdrop"). The app's loopback redirect must be registered in the broker's
// apps.json (redirect_uris) for the device flow to accept it.
type OAuthConfig struct {
BrokerURL string
App string
}
// TokenResult is the subset of the broker token response the shell keeps. ExpiresIn
// is derived from the broker's access_expires (unix) at parse time.
type TokenResult struct {
AccessToken string
RefreshToken string
ExpiresIn int
}
// 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 broker device-authorization loopback PKCE flow and returns the
// minted machine token.
//
// Steps: generate verifier/challenge/state → bind an ephemeral 127.0.0.1 listener →
// open the system browser at the broker's /device/authorize (which sends the user
// through broker SSO if needed, then shows a consent page) → wait for the browser to
// hit the loopback /callback with the code → validate state → exchange code+verifier
// at /device/token. The verifier never leaves Go.
func (f *Flow) Login(ctx context.Context) (*TokenResult, error) {
if f.cfg.BrokerURL == "" || f.cfg.App == "" {
return nil, errors.New("oauth: incomplete config (broker_url / app)")
}
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 := strings.TrimRight(f.cfg.BrokerURL, "/") + "/device/authorize?" + url.Values{
"app": {f.cfg.App},
"redirect_uri": {redirectURI},
"state": {state},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
"description": {"cdrop 桌面客户端"},
}.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)
}
}
// exchange trades the authorization code + PKCE verifier (+ the same redirect_uri)
// for the broker machine token at /device/token.
func (f *Flow) exchange(ctx context.Context, code, verifier, redirectURI string) (*TokenResult, error) {
return f.postJSON(ctx, strings.TrimRight(f.cfg.BrokerURL, "/")+"/device/token", map[string]string{
"code": code,
"code_verifier": verifier,
"redirect_uri": redirectURI,
})
}
// Refresh exchanges a refresh credential for a fresh access token (+ rotated refresh)
// at the broker's /refresh. Call it when the access token nears expiry; the refresh
// credential 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.BrokerURL == "" {
return nil, errors.New("oauth: incomplete config (broker_url)")
}
if refreshToken == "" {
return nil, errors.New("oauth: empty refresh token")
}
return f.postJSON(ctx, strings.TrimRight(f.cfg.BrokerURL, "/")+"/refresh", map[string]string{
"refresh": refreshToken,
})
}
// brokerTokenResp is the broker's response shape for /device/token and /refresh.
type brokerTokenResp struct {
Access string `json:"access"`
Refresh string `json:"refresh"`
AccessExpires int64 `json:"access_expires"`
}
// postJSON POSTs a JSON body to a broker token endpoint and decodes the response.
// Shared by the device-token exchange and refresh.
func (f *Flow) postJSON(ctx context.Context, endpoint string, body map[string]string) (*TokenResult, error) {
buf, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("oauth: marshal token request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(buf)))
if err != nil {
return nil, fmt.Errorf("oauth: build token request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
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 tr brokerTokenResp
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
return nil, fmt.Errorf("oauth: decode token: %w", err)
}
if tr.Access == "" {
return nil, errors.New("oauth: token response missing access")
}
expiresIn := int(tr.AccessExpires - time.Now().Unix())
if expiresIn < 0 {
expiresIn = 0
}
return &TokenResult{AccessToken: tr.Access, RefreshToken: tr.Refresh, ExpiresIn: expiresIn}, 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)
}
-181
View File
@@ -1,181 +0,0 @@
package platform
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
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")
}
}
// decodeBody reads a JSON request body into a map.
func decodeBody(t *testing.T, r *http.Request) map[string]string {
t.Helper()
var m map[string]string
if err := json.NewDecoder(r.Body).Decode(&m); err != nil {
t.Fatalf("decode body: %v", err)
}
return m
}
// TestLogin_Success drives the whole broker device-authorization loopback flow with a
// fake /device/token endpoint and a fake browser — no real network or prod dependency.
func TestLogin_Success(t *testing.T) {
var gotBody map[string]string
brokerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/device/token" {
t.Errorf("token path = %s, want /device/token", r.URL.Path)
}
gotBody = decodeBody(t, r)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "sid-1",
"app": "cdrop",
"access": "at-123",
"refresh": "rtk-456",
"access_expires": time.Now().Add(15 * time.Minute).Unix(),
})
}))
defer brokerSrv.Close()
cfg := OAuthConfig{BrokerURL: brokerSrv.URL, App: "cdrop"}
// Fake browser: parse the /device/authorize URL, assert it carries app + PKCE,
// then GET the loopback redirect with a code + the same state (approved).
openURL := func(authURL string) {
u, err := url.Parse(authURL)
if err != nil {
t.Errorf("parse authURL: %v", err)
return
}
if !strings.HasSuffix(u.Path, "/device/authorize") {
t.Errorf("authorize path = %s, want /device/authorize", u.Path)
}
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("app") != "cdrop" {
t.Errorf("authorize app = %q", q.Get("app"))
}
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 = %q, want at-123", tok.AccessToken)
}
if tok.RefreshToken != "rtk-456" {
t.Errorf("refresh = %q, want rtk-456", tok.RefreshToken)
}
if tok.ExpiresIn <= 0 || tok.ExpiresIn > 900 {
t.Errorf("expires_in = %d, want ~900 (derived from access_expires)", tok.ExpiresIn)
}
// The exchange must carry the code, the PKCE verifier, and the same redirect_uri.
if gotBody["code"] != "auth-code-xyz" {
t.Errorf("code = %q", gotBody["code"])
}
if gotBody["code_verifier"] == "" {
t.Error("token exchange missing code_verifier")
}
if !strings.HasPrefix(gotBody["redirect_uri"], "http://127.0.0.1:") {
t.Errorf("redirect_uri = %q, want loopback", gotBody["redirect_uri"])
}
}
func TestLogin_StateMismatch(t *testing.T) {
cfg := OAuthConfig{BrokerURL: "https://sso.example.net", App: "cdrop"}
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{App: "only-app"}, func(string) {}).Login(context.Background())
if err == nil {
t.Fatal("want error for incomplete config, got nil")
}
}
func TestRefresh_Success(t *testing.T) {
var gotBody map[string]string
brokerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/refresh" {
t.Errorf("refresh path = %s, want /refresh", r.URL.Path)
}
gotBody = decodeBody(t, r)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"access": "new-at",
"refresh": "new-rtk",
"access_expires": time.Now().Add(15 * time.Minute).Unix(),
})
}))
defer brokerSrv.Close()
cfg := OAuthConfig{BrokerURL: brokerSrv.URL, App: "cdrop"}
tok, err := NewFlow(cfg, func(string) {}).Refresh(context.Background(), "old-rtk")
if err != nil {
t.Fatalf("Refresh: %v", err)
}
if tok.AccessToken != "new-at" {
t.Errorf("access = %q, want new-at", tok.AccessToken)
}
if tok.RefreshToken != "new-rtk" {
t.Errorf("refresh = %q, want new-rtk (rotated)", tok.RefreshToken)
}
if gotBody["refresh"] != "old-rtk" {
t.Errorf("sent refresh = %q, want old-rtk", gotBody["refresh"])
}
}
func TestRefresh_EmptyToken(t *testing.T) {
cfg := OAuthConfig{BrokerURL: "https://sso.example.net", App: "cdrop"}
if _, err := NewFlow(cfg, func(string) {}).Refresh(context.Background(), ""); err == nil {
t.Fatal("want error for empty refresh token")
}
}
-55
View File
@@ -1,55 +0,0 @@
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
@@ -1,145 +0,0 @@
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
@@ -1,258 +0,0 @@
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
@@ -1,197 +0,0 @@
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
@@ -1,116 +0,0 @@
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
@@ -1,109 +0,0 @@
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
@@ -1,23 +0,0 @@
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
@@ -1,80 +0,0 @@
//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
@@ -1,133 +0,0 @@
//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
@@ -1,19 +0,0 @@
//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
@@ -1,74 +0,0 @@
//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.

Before

Width:  |  Height:  |  Size: 11 KiB

-16
View File
@@ -1,16 +0,0 @@
{
"$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"
}
}
-64
View File
@@ -1,64 +0,0 @@
# cdropdrop.<your-domain>)的 Caddy 接线示例。换上你的域名、cdrop 服务名(NN-cdrop)、
# broker 内网地址(BROKER-IP:PORT)。
#
# cdrop 迁移到 Auth Broker(路径 A)后,边缘由 broker /verify 终结鉴权并注入 X-Auth-* 头:
# - 含 Authorization: Bearer 的请求 → 机器分支(QR 配对设备 / iOS / 桌面,持 broker 机器令牌)
# - 不含 Bearer 的请求 → 人类分支(全新浏览器全局 SSO,带 broker 域 cookie
# - 一组公开端点必须放行不鉴权,否则未登录设备无从登录 / 刷新。
#
# 前置:用 xcaddy 把 caddybroker 编进 caddy-custom,并在全局块声明
# { order broker before reverse_proxy }
# 否则 `handle { broker ... }` 报 "not an ordered HTTP handler"。
#
# 关键开关:
# - flush_interval -1 :禁用 reverse_proxy 对 SSE 长响应的缓冲,否则 /api/hub/events 看似无事件
# - copy_headers 须含 X-Auth-Metacdrop 据它定位托管设备);caddybroker 默认已含,
# 但旧 caddy-custom 镜像须**重建**才生效——接 cdrop 那次必须连带重建。
drop.your-domain.example {
import tls_min
# 公开端点(放行不鉴权):登录引导(302 跳 broker/ 原生取 broker 坐标 / 令牌刷新代理 /
# 扫码公开端点(新设备未登录,凭 poll_secret 自证)。
@public path /api/auth/login /api/auth/config /api/auth/refresh /api/auth/qr/start /api/auth/qr/status
handle @public {
reverse_proxy NN-cdrop:8080 {
flush_interval -1
}
}
# 受控 /api/* 机器分支:含 Bearer → broker /verify 验机器令牌 → 注入 X-Auth-*。
@apiBearer {
path /api/*
header Authorization Bearer*
}
handle @apiBearer {
broker BROKER-IP:8080 {
app cdrop
copy_headers X-Auth-Subject X-Auth-Name X-Auth-Roles X-Auth-Scope X-Auth-Kind X-Auth-Meta
}
reverse_proxy NN-cdrop:8080 {
flush_interval -1
}
}
# 受控 /api/* 人类分支:无 Bearer → broker /verify 验全局 SSO cookie → 注入 X-Auth-*。
@api path /api/*
handle @api {
broker BROKER-IP:8080 {
app cdrop
copy_headers X-Auth-Subject X-Auth-Name X-Auth-Roles X-Auth-Scope X-Auth-Kind X-Auth-Meta
}
reverse_proxy NN-cdrop:8080 {
flush_interval -1
}
}
# 静态 SPA 壳(其余路径):放行不鉴权,前端自行引导登录。
handle {
encode gzip
reverse_proxy NN-cdrop:8080 {
flush_interval -1
}
}
}
-43
View File
@@ -1,43 +0,0 @@
# 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"]
-32
View File
@@ -1,32 +0,0 @@
# 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 缓存层。GOPROXY 可经 --build-arg 覆盖——受限网络(如构建 VM 连不上
# proxy.golang.org)时传入区域镜像即可,默认仍是官方代理,不在仓库内固化任何区域值。
ARG GOPROXY=https://proxy.golang.org,direct
ENV GOPROXY=${GOPROXY}
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
-52
View File
@@ -1,52 +0,0 @@
# 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 委托 Auth Broker(路径 A):身份与会话生命周期归 broker,cdrop 不存凭证。
# broker 内网地址(docker 内网直连,绝不经公网反代)。prod 强制非空。
# CDROP_BROKER_BASE_URL: http://broker-internal-ip:8080
# broker /internal/* 共享密钥(X-Internal-Key)。prod 强制非空,向运维取。
# CDROP_BROKER_INTERNAL_KEY: REPLACE_WITH_BROKER_INTERNAL_KEY
# broker 公开源(浏览器全局 SSO 登录跳转 / 原生设备授权流目标)。
# CDROP_BROKER_PUBLIC_URL: https://sso.your-domain.example
# 本应用在 broker apps 注册表里的 key(默认 cdrop)。
# CDROP_BROKER_APP: cdrop
# 部署公开源(扫码 QR 链接 / CSRF Origin / Web Push 默认联系标识)。
# CDROP_PUBLIC_URL: https://drop.your-domain.example
# ---- 可选:Web Push(VAPID)推送通知(不配则推送惰性关闭)----
# 用 `just vapid-keygen` 生成一对,二者须同时设置(半对即拒启动)
# CDROP_VAPID_PUBLIC_KEY: <just vapid-keygen 输出>
# CDROP_VAPID_PRIVATE_KEY: <just vapid-keygen 输出>
# ---- 可选:扫码登录(QR)+ 按档令牌 TTL(批准后经 broker 委托签发对应 tier----
# 均有默认值;下列按需覆盖。关 QR_LOGIN_ENABLED 则 /api/auth/qr/* 返回 503。
# CDROP_QR_LOGIN_ENABLED: "true"
# CDROP_QR_REQUEST_TTL_SECONDS: "120" # 二维码 / 登录请求有效期
# CDROP_FULL_ACCESS_TTL_SECONDS: "900" # full 设备 access TTL
# CDROP_FULL_REFRESH_TTL_SECONDS: "604800" # full 设备 refresh TTL7 天)
# CDROP_GUEST_ACCESS_TTL_SECONDS: "900" # guest 访客 access TTL
# CDROP_GUEST_REFRESH_TTL_SECONDS: "86400" # guest refresh TTL1 天)
# ---- 可选: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
@@ -1,148 +0,0 @@
# 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。
-36
View File
@@ -1,36 +0,0 @@
module commilitia.net/cdrop
go 1.26.2
require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/httprate v0.15.0
github.com/golang-jwt/jwt/v5 v5.2.1
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/crypto v0.31.0 // 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
)
-164
View File
@@ -1,164 +0,0 @@
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
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-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
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/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
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.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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=

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