Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
1a8db5686a
|
|||
|
2dbe6c6134
|
|||
|
05f9f291f6
|
|||
|
c306daff92
|
|||
|
b414c57b6a
|
|||
|
19f8f7588e
|
@@ -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
|
||||
@@ -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
|
||||
@@ -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 Push(VAPID)推送通知。用 `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
@@ -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 baseline;never入库)
|
||||
/docker/.remote/
|
||||
|
||||
# Desktop client (Wails) 产物
|
||||
/desktop/build/bin/
|
||||
/desktop/frontend/node_modules/
|
||||
/desktop/frontend/dist/
|
||||
/desktop/frontend/wailsjs/
|
||||
|
||||
# Scratch / ad-hoc artifacts
|
||||
/.scratch/
|
||||
@@ -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
|
||||
@@ -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 登录页内完成 |
|
||||
| 2FA(TOTP / 短信 / 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 token;IdP 拒绝则作废(保留 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_id,QR 与批准用
|
||||
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` 查行。
|
||||
|
||||
> RS256(IdP access token)已退出浏览器每请求热路径:浏览器登录(含 OIDC)一律持自签 token,OIDC 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=session,SESSION_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
File diff suppressed because one or more lines are too long
@@ -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 卡住”)。改为:小文件(暂存 < 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>网页 / PWA(Web 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_token;refresh_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><a download></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 — 接入聚珍(Juzhen)2.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 CJK;Serif = Noto Serif CJK(本地合一)+ Noto Serif <R>(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><link></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 <URL></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 关闭、或桌面窗口非前台时也能收到系统通知;判定统一为“页面打开走应用内提示,关闭则系统通知”。
|
||||
|
||||
- 网页 / PWA(Web 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,文本随推送送达
|
||||
- 设置页新增“推送通知”面板(仅浏览器 / PWA);prod 可选设 CDROP_VAPID_PUBLIC_KEY + CDROP_VAPID_PRIVATE_KEY(just 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_token;refresh_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_token,striped per-session 锁串行化并发 refresh,防一次性 token 被花两次而全端登出
|
||||
- **凭据面收紧**:store 移除 `refreshToken` 字段(浏览器侧长寿命凭据不再触及 JS);CSRF 由 SameSite=Lax + Origin 校验双保险。桌面端不受影响
|
||||
- **部署要求**:prod 须设 `CDROP_SESSION_SECRET`(任意长度高熵串),缺则拒启动
|
||||
- commit `e51666c`
|
||||
|
||||
## 2026-06-15 — PWA 安装支持 + Safari OAuth 回跳卡顿修复
|
||||
|
||||
- **PWA 安装支持**:web 可“添加到主屏幕”独立启动(manifest + iOS meta + 同源静态壳 SW)。SW 绝不拦截 `/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 — 接入聚珍(Juzhen)2.0 综合 CJK 排版:全角式 + 功能分级 + 本地优先字体
|
||||
|
||||
- submodule `cjk-autospace` 升级 `a5faec5 → a3c94dd`:v1 自动空格 shim 重写为“聚珍(Juzhen)”2.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>
|
||||
@@ -0,0 +1,317 @@
|
||||
# Commilitia Drop — 前端设计
|
||||
|
||||
补充 PROJECT_BRIEF.md,专注 web 前端实现。后端、协议、鉴权见主 brief。
|
||||
|
||||
> 本文记录 MVP 初版前端设计(Mantine + 浅色)。其后前端经「Theme B」重塑:自定义设计 token、品牌 web font、Dracula 深色、聚珍(Juzhen)CJK 综合排版——视觉部分以 [`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 Design(bundle 过大)、shadcn/ui(手搓成本与 MVP 速度冲突)、TanStack Query(MVP 阶段 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-shake(v7 默认就好)
|
||||
- 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.4(macOS 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 的方向选择
|
||||
@@ -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 侧无 cgo(darwin 的
|
||||
# NSStatusBar/NSPasteboard CGO 走 build tag 排除,WebView2 是纯 Go),不需 mingw。
|
||||
# 与 mac 同:先手动构建前端再 -s(wails 自带 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)。前置:付费 ADP;App 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 "缺 .env(CDROP_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 改动时需要重跑。
|
||||
# 用宿主原生 arch(macOS 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 .
|
||||
@@ -0,0 +1,244 @@
|
||||
# Commilitia Drop — 跨 OS 剪贴板与文件传输服务
|
||||
|
||||
> 本文是项目最初的 MVP 规格(设计基线),保留作架构参考;**当前实现状态以 [`CHANGELOG`](CHANGELOG.html) 为准**——其后已落地剪贴板/消息通道、Wails 桌面端、安全加固等,部分「远期」项已实现。
|
||||
|
||||
项目代号 / 仓库名 / 二进制名:`cdrop`
|
||||
部署域名:`drop.commilitia.net`
|
||||
节点:自托管(单机 Docker + 反代)
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标
|
||||
|
||||
跨 OS(Win / 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 存内存或 sessionStorage;refresh_token 存内存(不写 localStorage 防 XSS)。
|
||||
- **Native client**(desktop / mobile,远期):标准 Casdoor OAuth + PKCE + refresh_token rotation。系统浏览器或 loopback redirect 完成首次登录;token 存 OS keychain(macOS 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 自签 JWT(HS256),长 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、信令、传输状态):SSE(server→client)+ POST(client→server)
|
||||
- 不用浏览器原生 EventSource(不支持自定义 header),用 fetch + ReadableStream 自实现 SSE 解析
|
||||
- **Relay 通道**(P2P 失败时):
|
||||
- Receiver:流式 GET(chunked transfer-encoding)
|
||||
- Sender:多次分块 POST(Safari / Firefox 不支持 streaming POST)
|
||||
- Server 端 in-memory ring buffer 桥接
|
||||
|
||||
### 文件传输
|
||||
|
||||
- **优先 P2P**:WebRTC DataChannel,DTLS 即 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 二次确认;不限文件大小,但中继有内存与并发上限
|
||||
|
||||
### 存储
|
||||
|
||||
- SQLite(modernc.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 用 gofmt;TS / JSX / JSON 按 Allman、数组内空格、运算符前置。
|
||||
|
||||
---
|
||||
|
||||
## 4. SQLite Schema(MVP)
|
||||
|
||||
`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 扩大到剪贴板或客户端
|
||||
@@ -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.0:CJK 综合排版——中西间隙 / 长英文断词 / 标点挤压 / 禁则 / justify,跨 Claude / Nvim / cdrop 共享)。首次 clone 须带 `--recurse-submodules`,否则 `vite build` 会因找不到 import 而失败:
|
||||
|
||||
```sh
|
||||
git clone --recurse-submodules <URL> cdrop
|
||||
# 已 clone 但未拉 submodule:
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
## 当前阶段
|
||||
|
||||
MVP(M0–M13)已上线 prod(OIDC PKCE 接入自建 Casdoor,Cloudflare Realtime TURN over TLS)。其后进入阶段二,已落地:
|
||||
|
||||
- **剪贴板同步**:单条覆写式云剪贴板(短 TTL 限暴露面)+ 文本消息一次性通道
|
||||
- **桌面客户端**:Wails v2(macOS universal `.app` + Windows `.exe`)—— 瘦客户端复用 web,业务全走后端 API;refresh_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 provider,cdrop 自维护薄账户层 + 自签会话令牌(详见 `AUTH.md`)
|
||||
- **iOS Shortcut**:HS256 scoped token 手动签发路径(网页签发 UI 暂停)
|
||||
- **安全加固**:中继会话防耗尽、HS256 强制 jti、prod 强制 audience、TURN 短 TTL、OAuth 端点限流(详见 CHANGELOG)
|
||||
|
||||
MVP 里程碑(历史记录):
|
||||
|
||||
| 里程碑 | 内容 | 状态 |
|
||||
|---|---|---|
|
||||
| M0–M5 | 后端:chi / sqlite WAL / dev+prod auth 中间件 / SSE Hub / signaling + 状态机 / Relay ring buffer | 完成 |
|
||||
| M6–M10 | 前端: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 + callback;Casdoor confidential client + client_secret | 完成 |
|
||||
| M13 | 部署:cdrop-base 缓存层 + 应用镜像(BUILDPLATFORM 跨编译,6 MB distroless),Cloudflare Realtime TURN App,反代 reverse_proxy + flush_interval -1(SSE 友好),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
|
||||
# 监听 :8080,json 日志到 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 + 拖文件 → Send;tab-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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
# 列举(含 sub;cdrop 按 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 暴露 sub+cdrop 归并)——认可?还是要 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 干净方案。
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
build/bin
|
||||
node_modules
|
||||
frontend/dist
|
||||
-403
@@ -1,403 +0,0 @@
|
||||
# cdrop 桌面客户端实施计划
|
||||
|
||||
> Wails v2 · macOS 优先 + Windows 已落地
|
||||
> 状态:**macOS 与 Windows 双端均已实现并 RDP/真机实测通过**——A2 前端复用 + 托盘/菜单栏 + 剪贴板双向同步 + 桌面设置 + session 持久化 + 开机自启(含自启静默驻留)+ 设备类型登记。macOS 见 §10,Windows 落地(含「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. 范围、非范围与本轮关键决策
|
||||
|
||||
**范围**:常驻桌面客户端,补浏览器做不到的——后台常驻、剪贴板自动监听上传、文件落盘、系统通知、全局快捷键。平台 **macOS(Apple 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 + 多窗口 + ActivationPolicy;D2/D5 前定夺 |
|
||||
| 淘汰 | Electron / Tauri / 原生双栈 / PWA | Electron 200+ MB;Tauri 多一门 Rust;原生双 codebase;PWA 无后台常驻 |
|
||||
| 资源预算 | macOS idle 80–120 MB;Windows idle 100–150 MB;包体 10–20 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 回调 + 注入(§3,D1) |
|
||||
| **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. D1:OAuth 登录闭环(已据 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 client(PKCE 时 `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)。
|
||||
- [ ] Go:loopback server + `runtime.BrowserOpenURL` + state 校验 + token 交换 + `EventsEmit`;token 持久化(Keychain / DPAPI)。
|
||||
- **验收**:冷启动 → 点登录 → 系统浏览器授权 → 自动回应用已登录;杀进程重启仍登录;refresh 跑通。dev 模式(`VITE_CDROP_DEV_TOKEN`)可先绕 Casdoor 打通壳。
|
||||
|
||||
---
|
||||
|
||||
## 4. D2–D6 详解(含 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` 及私有 UTI;Windows 读后 `EnumClipboardFormats` 查 `ExcludeClipboardContentFromMonitorProcessing` / `CanUploadToCloudClipboard`,命中跳过上传。
|
||||
2. **回环防护**(R4):自写剪贴板会自增计数触发自己的 Watch 风暴;写入前记 hash + 自写序号,Watch 收变更先比对跳过。
|
||||
3. **去重 + debounce**:复用已在 go.mod 的 `github.com/bep/debounce` + 内容 hash。
|
||||
- **写回本机剪贴板**优先用库的 `Write`(规避 Wails #4132:macOS 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.24,v0.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`。
|
||||
- **版本检查**:启动 / 定时拉版本端点,过期提示去下载,**不自更新**。
|
||||
- **将来获证书**:完整 codesign(Developer ID)+ notarytool + DMG + CI 流程见 `desktop/RESEARCH.md` D6 手册,届时直接套用。
|
||||
- **CI**:D6 后 GitHub Actions 双 runner(macos-latest / windows-latest)跑构建 + 防回归(无证书阶段仅构建 + ad-hoc,不跑公证)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 跨平台代码组织(防分叉硬约束)
|
||||
|
||||
```
|
||||
desktop/
|
||||
main.go # Wails 入口(窗口 / bind / 托盘装配)
|
||||
app.go # App struct + 暴露给 WebView 的方法(bind:StartLogin 等)
|
||||
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**;②Windows(Parallels Win11 ARM VM)只 `git pull`、**从不 commit**,Windows-only 操作封进 `just` target;③ARM Win11 跑 x64 emulator 性能 -20–40%,**不能跑资源 / 性能基准**,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 PC,ARM 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 的 URL;web 留空(相对、不变),desktop 构建设 `https://drop.commilitia.net`(WebView 内相对 `/api` 到不了后端)。
|
||||
- **登录桥**:login 路由检测 desktop(Wails 注入 `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 引用 web(npm workspace / `file:` 或 vite 指 `web/src`),保留 wailsjs 绑定;以能 `wails build` 为准、方式待落地时定。
|
||||
|
||||
### 10.2 剪贴板同步(D4)
|
||||
|
||||
- **上行**:darwin native Source(NSPasteboard changeCount 轮询,purego,clipprobe 已证可跑)→ 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 而非 purego:clang 编译期校验 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 模式下行有效。Sensitive(ConcealedType 等)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 加 mutex;oauth.go 加 IDToken 字段。desktop:app.go(登录桥 / 生命周期 / 剪贴板 / 设置绑定)、main.go(proxy + OnBeforeClose)、wails.json(frontend 指向 web 构建)。web:net/desktop.ts + DesktopSettings.tsx 新增;login.tsx / auth.ts / clipboard.ts / __root.tsx / settings.tsx / main.tsx 加 guarded 桌面分支。
|
||||
|
||||
**待真机 + 鉴权验证(本环境无 GUI、无法登录,以下不可本地达成):**
|
||||
|
||||
- [ ] 登录 e2e:loopback PKCE → token 入 store → 进首页(含设备名自动取主机名)
|
||||
- [ ] refresh:API 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-13,web 已部署 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-14,RDP 实测通过)
|
||||
|
||||
macOS 优先做完后接 Windows。业务逻辑全跨平台纯 Go,平台差异用 build tag 隔离(`_windows.go` / `_darwin.go` / `_other.go`)。
|
||||
|
||||
### 11.1 交叉编译:macOS 原生即可,无需 mingw
|
||||
|
||||
Windows 侧无 cgo——darwin 的 NSStatusBar/NSPasteboard CGO 走 build tag 排除,托盘/剪贴板/自启均纯 syscall,WebView2 是纯 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 scheme(WKWebView 流式正常,零改动)。
|
||||
|
||||
### 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 / 任务栏 / 终端)不带标志 → 正常显示窗口。
|
||||
|
||||
- macOS:LaunchAgent `open <bundle> --args --hidden`(裸 dev 二进制 `exe --hidden`)。
|
||||
- Windows:注册表值 `"exe" --hidden`。
|
||||
- **自启项自愈**:`startup` 时若自启已开,重写一次条目——旧版本(无 `--hidden`)或迁移过位置的 bundle 自动刷新到当前路径+标志,无需手动重勾。
|
||||
|
||||
**仍待办**:Windows 剪贴板敏感过滤;菜单栏/托盘标签 i18n(仍硬编码简体);Keychain(待 Developer-ID 签名);Windows 代码签名 / SmartScreen。
|
||||
@@ -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`.
|
||||
@@ -1,694 +0,0 @@
|
||||
# cdrop 桌面端 D1 前决策就绪简报
|
||||
|
||||
> 框架基线:Wails v2.12.0(Go 1.23 模块声明,本机 toolchain 1.26.3)+ 系统 WebView,瘦客户端,业务全走 `drop.commilitia.net`。平台优先级 macOS(Apple Silicon + Intel universal),Windows x64 兼容。
|
||||
> 已对四项关键论断做对抗式核验:托盘能力 **refuted**、热键库可用性 **supported(附 pin 条件)**、Casdoor/R1 **mixed(需一处后端改动 + 版本前提)**、签名/公证 entitlement **mixed(核心子句成立,捆绑表述需修正)**。
|
||||
|
||||
---
|
||||
|
||||
## R1:OAuth 后端 / Casdoor 协调(解锁 D1 的最小路径)
|
||||
|
||||
核验结论:**mixed**。「public client + PKCE + loopback」成立,但「后端零改动」为假——需且仅需一处后端 audience 改动,且依赖 Casdoor 版本 ≥ ~v3.3。
|
||||
|
||||
**已确定(源码级):**
|
||||
- Casdoor 支持 public client:PKCE(`CodeChallenge` 非空)时 `client_secret` 可空;传了则须正确(`token_oauth.go` / `token_oauth_util.go`)。
|
||||
- loopback 短路放行:`IsValidOrigin()`(`util/validation.go`)用 `url.Hostname()` 剥端口后比对白名单 `http://127.0.0.1` / `http://localhost` / ...,命中即放行,`http://127.0.0.1:<任意端口>/<任意路径>` 无需进 `RedirectUris` 列表。
|
||||
- aud 默认 = 签发 application 的 `client_id`(`token_jwt.go:544`)。
|
||||
- 本仓库已核实根因:`internal/jwtauth/middleware.go:150` 当前是单值 `expected.AnyAudience = jwt.Audience{a.cfg.OIDCAudience}`;`docker/compose.snippet.yaml:28` 把 `CDROP_OIDC_AUDIENCE` 设为 web `cdrop` client_id。桌面新 client 的 token `aud=<desktop client_id>` 会被这条按 audience 100% 拒(401)。
|
||||
- `internal/httpapi/auth.go:92-93`、`148-149` 已核实:`/api/auth/exchange` 在 `OIDCClientSecret` 非空时附 `client_secret`(confidential 代理)——桌面端**不应复用**。
|
||||
|
||||
**最小落地路径(解锁 D1):**
|
||||
1. Casdoor 新建独立 application `cdrop-desktop`:token format = JWT,`client_secret` 留空,**不可**勾选 shared application(否则 aud 被改写成 `clientId-org-owner`)。`RedirectUris` 建议登记一条 `http://127.0.0.1/callback` 作文档兜底(loopback 短路理论上登记与否都放行,待实测确认顺序)。
|
||||
2. 桌面端 Go 起临时 loopback server(**必须 bind IPv4 字面量 `127.0.0.1`**,白名单不含 IPv6 `[::1]`),直连 Casdoor 做 public PKCE 换 token,**绕过** `/api/auth/exchange`。Casdoor 专有路径:authorize = `/login/oauth/authorize`,token = `/api/login/oauth/access_token`(非标准 `/oauth/token`)。authorize 与 token 两步的 `redirect_uri` 须逐字一致(先定端口、全程同一 URL 串)。
|
||||
3. **后端唯一改动(方案 A,推荐)**:把 `CDROP_OIDC_AUDIENCE` 从单值扩成逗号分隔多值(web + desktop 两个 client_id),`middleware.go:150` 展开进 `AnyAudience`(go-jose 的 `AnyAudience` 已是「命中其一即可」语义)。issuer + JWKS + 签名校验与 web 完全一致,无需动。
|
||||
- 备选 B:桌面带 RFC 8707 `resource` 参数令 aud 收敛——依赖 Casdoor 较新版本且 authorize/token 两步须带同一 resource,不确定性更高,不推荐。
|
||||
- 备选 C:`CDROP_OIDC_AUDIENCE` 置空关 audience 校验——最弱,仅保留 issuer + 签名。
|
||||
|
||||
**版本前提:** loopback 短路 2026-04-05(commit `ea56cfec`)才接入 redirect 校验,首个含此改动的 release 约 v3.3.0(2026-04-12)。早于此的 Casdoor 会拒未注册的 `127.0.0.1:<port>`。prod 自建 Casdoor 版本未锁定,须先核实 ≥ v3.3(建议直接升近版,最新 v3.89.0 / 2026-06-13)。
|
||||
|
||||
---
|
||||
|
||||
## D2:menubar / tray + 窗口生命周期
|
||||
|
||||
核验结论:托盘论断 **refuted**——Wails v2.12.0 **无任何可用内建托盘 API**。已核实 `options.App` 无 `Tray` 字段,runtime 包无托盘函数,`pkg/menu/tray.go` 等是 2022 废弃分支遗留、仅被 devserver 引用的孤儿死代码(README 在 `onhold/` 目录)。Issue #4990 已被官方 closed as not planned。维护者明确「Systray will not be supported in v2」。
|
||||
|
||||
**决定:**
|
||||
- **窗口生命周期(v2 原生可做)**:`StartHidden:true` + `HideWindowOnClose:true` 实现「启动即驻留、关闭隐藏不退出」;或 `OnBeforeClose(ctx) bool` 内 `runtime.WindowHide(ctx)` 并 `return true`。**二者互斥**(`OnBeforeClose` 仅在 `HideWindowOnClose=false` 时触发),不可同设。
|
||||
- **托盘图标 + 菜单 + 点击回调(必须第三方/自写)**:macOS 首选先验证 `github.com/ra1phdd/systray-on-wails` 的非阻塞 `Register()`(让 Wails 接管主线程事件循环,规避 NSApplication 冲突);不达标则回退自写 CGO/ObjC `NSStatusBar+NSMenu` 绑定(最可控)。`getlantern/systray` 在 macOS+Wails 有 objc linking conflict,**排除**。Windows 侧用 `fyne.io/systray`(活跃维护 fork)。
|
||||
- **隐藏 dock 图标(accessory)**:`mac.Options.ActivationPolicy` 在 v2.12 仍被注释掉、不可用。已核实本仓库 `desktop/build/darwin/Info.plist` 有 `CFBundleIdentifier` 但**无 `LSUIElement`**——需手动加 `<key>LSUIElement</key><true/>`。accessory 有「启动瞬间 dock 图标闪现约 10ms」系统通病,无法消除。
|
||||
|
||||
**v3 决策闸:** v2 的 D2(托盘)+ D5(多窗口)需要堆 2-3 套 CGO workaround。Wails v3(alpha.92,API 已稳定、有生产案例)原生覆盖跨平台 systray + 多窗口 + ActivationPolicy。若 menubar + Quick Send 是产品核心体验,应把「直接上 v3-alpha」作为正式决策选项评估,而非在 v2 上累积 workaround。
|
||||
|
||||
---
|
||||
|
||||
## D3:系统通知(三按钮:复制到本机 / 忽略 / 打开)
|
||||
|
||||
核验结论:v2 **无内置通知 API**(所有 `runtime.RequestNotificationAuthorization` 等是 v3 特性)。按平台分实现、统一 Go 接口(build tag 隔离 `notify_darwin.go` / `notify_windows.go`)。
|
||||
|
||||
**决定:**
|
||||
- **macOS**:`UNUserNotificationCenter`(cgo + ObjC)。三按钮 = 注册 `UNNotificationCategory`(含 3 个 `UNNotificationAction`,「忽略」可设 `destructive`),发通知设 `categoryIdentifier`;点击经 `UNUserNotificationCenterDelegate didReceiveResponse` 回调,`response.actionIdentifier` 区分。**硬门槛**:app 须有 `CFBundleIdentifier` 且已签名(Developer ID 或 Apple Development),dev/未签名构建下 API 不工作,且须从 `.app` bundle 运行(裸二进制抛 `NSInternalInconsistencyException`)。最低 macOS 11.0。→ **D3 验收与 D6 签名强绑定**。
|
||||
- **Windows**:已在 go.mod 的 `git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3`(2025-01-17,仍维护)。`Notification.Actions` 加 3 个 `toast.Action{Type: Foreground, Content, Arguments}`,`toast.SetActivationCallback` 按 `Arguments` 区分。**坑**:须先 `wintoast.SetAppData{AppID, GUID, ...}` 注册 AUMID + stub CLSID,否则 COM 激活路径失效、回调收不到(回退 PowerShell 路径时 `SetActivationCallback` 完全失效)。
|
||||
- **不直接依赖 v3 notifications service**(绑 v3 `application.ServiceOptions` 生命周期 + alpha bug #4449);借鉴其 API 形态移植到 v2。
|
||||
- **落地节奏**:先实现「单击整条通知拉起主窗 + 打开文件」基础版打通主链路,三按钮作增量。
|
||||
|
||||
---
|
||||
|
||||
## D4:剪贴板自动同步
|
||||
|
||||
核验结论:两份调研一致——现成库都不做敏感内容过滤,会同步密码。
|
||||
|
||||
**决定:**
|
||||
- **统一底层 `golang.design/x/clipboard v0.8.0`**(2026-06-07,活跃;macOS 已 purego/objc **无 cgo**,Windows/Linux 纯 syscall)覆盖 macOS(changeCount 轮询)+ Windows(`GetClipboardSequenceNumber` 1s 轮询)。**纠正 PLAN.md L167**:该库 Windows 是**轮询**不是 `AddClipboardFormatListener` 事件;两端机制其实一致(diff 计数器轮询),轮询间隔写死 1s 不可配。
|
||||
- **外包一层 `ClipboardSource` 接口做 D4 业务逻辑**(库一概不替你做):
|
||||
1. **敏感内容过滤**:macOS 读 `-types` 比对 `org.nspasteboard.ConcealedType` / `TransientType` / `AutoGeneratedType` 及私有 UTI(`com.agilebits.onepassword` 等);Windows 读到内容后 `EnumClipboardFormats` 检查 `ExcludeClipboardContentFromMonitorProcessing` / `CanUploadToCloudClipboard`,命中则跳过上传。库的 `Watch` 不检查这些,直接用 = 同步密码。
|
||||
2. **回环防护**(PLAN R4):自写剪贴板会自增 changeCount/sequenceNumber 触发自己的 Watch 风暴。写入前记 hash + 自写时间窗/序号,Watch 收变更先比对跳过(两端都要拦)。
|
||||
3. **去重 + debounce**:复用已在 go.mod 的 `github.com/bep/debounce` + 内容 hash。
|
||||
- **写回本机剪贴板**优先用库的 `Write`(规避 Wails #4132:macOS LANG 为空时 `ClipboardSetText` 编码 bug)。
|
||||
- **go.mod 版本**:库 go.mod 要求 go 1.24,本仓库 `desktop/go.mod` 声明 go 1.23(本机 toolchain 1.26.3 满足)——引入需把模块声明升到 1.24+;若改走自写 purego 封装则只依赖 purego v0.10.x,版本要求更宽松。
|
||||
- `atotto/clipboard` **排除**(shell out pbcopy/pbpaste,无 Watch、无 changeCount)。
|
||||
|
||||
> **macOS 剪贴板隐私授权(2025-2026 平台级变化)**:macOS 15.4 预览、预计 macOS 26 (Tahoe) 正式启用——非用户 paste UI 触发的程序化读取会弹授权 Alert。Apple 新增 `detectPatterns/detectValues` + `accessBehavior` 可只探测类型不读内容、不触发弹窗,但 `golang.design v0.8.0` 尚未适配。这正中 cdrop「后台自动监听上传」要害,是 D4 最大不确定项,须真机实测。
|
||||
|
||||
---
|
||||
|
||||
## D5:全局热键 + Quick Send 浮窗
|
||||
|
||||
核验结论:热键库可用性 **supported**,但**必须 pin v0.4.1**——这是最关键的一字之差决策。
|
||||
|
||||
**决定(热键):**
|
||||
- **`golang.design/x/hotkey` pin 到 v0.4.1**(Carbon `RegisterEventHotKey`)。理由:v0.6.0(2026-06-06)已把 macOS 从 Carbon 换成 **CGEventTap**,导致**每个**全局热键都要求 App 获 Accessibility / Input Monitoring 授权,否则 `Register()` 直接返回 error(源码 `isAXTrusted`/`registerTap` 返回 NULL)。对「瘦客户端 + tray + Quick Send」产品开机弹授权是显著体验劣化。v0.4.1 的 Carbon 路径对 `Cmd+Shift+V` 这类组合键**零授权弹窗**(Electron/VS Code 同此路径)。
|
||||
- **附带收益**:v0.5.0+ 强制 go 1.24,v0.4.1 仅需 go 1.17,反而与现有 toolchain 兼容。
|
||||
- 用法:`hotkey.New([]hotkey.Modifier{hotkey.ModCmd, hotkey.ModShift}, hotkey.KeyV)`(Windows 侧 `ModCtrl`),`Cmd+Shift+V` / `Ctrl+Shift+V` 全支持。Windows 走 `RegisterHotKey`,零 cgo、无权限问题。
|
||||
- **不调 `mainthread.Init`**——Wails 已自带主线程 Cocoa run loop(同 Fyne/Ebiten/Gio 情形),调它会争夺主线程所有权。
|
||||
- **排除** `ZeronoFreya/go-hotkey`(README 自述「只适用于 windows」,非跨平台)。
|
||||
- 产品化时建议支持热键重绑(规避 `Cmd+Shift+V` 与其他 app 撞车)。
|
||||
|
||||
**决定(Quick Send 浮窗):**
|
||||
- **v2 架构上单 app 单原生窗口**(issue #4413/#1480),无法开第二个原生窗口。v2 内只能用「同窗 HTML 浮层/路由切换」或「另起独立 Wails 进程」近似,都不理想。
|
||||
- 无边框 + 置顶:`Frameless:true` + `AlwaysOnTop:true` + `runtime.WindowSetAlwaysOnTop()`;拖拽用 CSS `--wails-draggable:drag`。
|
||||
- **「失焦自隐」v2 无现成窗口失焦钩子**——前端 blur/visibilitychange 在 WebView 内对 OS 级窗口失焦不可靠,正解是自写 ObjC `NSWindowDelegate windowDidResignKey`(v2 路线最脏的一块)。
|
||||
- **这一条是上 v3 的最强理由**(v3 多窗口每窗一等对象 + 独立生命周期)。
|
||||
|
||||
---
|
||||
|
||||
## D6:codesign / notarize / 打包 + CI
|
||||
|
||||
核验结论:「无特殊 entitlement 公证障碍」**核心子句成立**,但捆绑表述 **mixed**,需修正两处。
|
||||
|
||||
**决定 / 修正:**
|
||||
- **公证只校验结构性条件**:Hardened Runtime + secure timestamp + Developer ID 全量签名。基于 entitlement 拒绝的**唯一**情形是 `com.apple.security.get-task-allow=true`(调试 entitlement)。accessibility / input-monitoring / 通知授权都不是签名期 entitlement,而是运行期 TCC 授权,公证扫描既不检查也不会因此失败。
|
||||
- **修正 1**:全局热键是否需「辅助功能弹窗」**取决于实现路径**,非必然——按 D5 选定的 v0.4.1(Carbon)路径**零权限弹窗**;只有 CGEventTap(v0.5.0+)才强制 Accessibility。
|
||||
- **修正 2**:macOS 通知有一道硬门槛被「仅需弹窗」框架略过——`UNUserNotificationCenter` 要求 app 有 `CFBundleIdentifier` 且已签名,dev/未签名不工作。这是对签名的**运行期依赖**(不是公证 entitlement 障碍),但意味着 D3 无法完全脱离 D6 独立验收。
|
||||
- **工具链**:用 `notarytool`(`gon`/`altool` 已退役,Wails #3290 的公证失败实为工具链而非 entitlement)。非沙箱 Developer ID 分发**不必**为通知/网络/剪贴板声明专门 entitlement——PLAN.md L180 把这些列入 entitlement 有过度声明之嫌,应收敛。
|
||||
- **跨平台纪律**:平台代码用 Go build tag,单 monorepo 单分支,Windows 只 pull 不 commit。
|
||||
|
||||
---
|
||||
|
||||
## 关键来源
|
||||
|
||||
- Casdoor loopback / PKCE / aud:`util/validation.go`(`IsValidOrigin`)、`object/application_util.go`(`IsRedirectUriValid`)、`object/token_jwt.go:544`、`object/token_oauth_util.go`;PR #3301、commit `ea56cfec`;RFC 8252 §7.3、RFC 8707。
|
||||
- 本仓库根因:`internal/jwtauth/middleware.go:150`、`internal/httpapi/auth.go:92`、`docker/compose.snippet.yaml:28`、`desktop/build/darwin/Info.plist`。
|
||||
- Wails 托盘/窗口:Discussion #4514 / #1438、Issue #4990(closed not planned)、#4413 / #1480;v3.wails.io systray/multiple-windows;本地 `wails/v2@v2.12.0/pkg/options/options.go`。
|
||||
- 热键:golang-design/hotkey releases(v0.6.0 CGEventTap、v0.5.0 Go 1.24)、`hotkey_darwin.go` @v0.4.1 vs @main、electrobun #334、dev.to「macOS Global Shortcut」。
|
||||
- 剪贴板:golang-design/clipboard `clipboard_windows.go` / `clipboard_darwin.go`、v0.8.0 release;nspasteboard.org;MS Learn `AddClipboardFormatListener` / Clipboard Formats;9to5Mac / MacRumors(macOS detect API);Wails #4132。
|
||||
- 通知:Wails PR #4098(macOS 须签名+bundle id)、Issue #4449;go-toast v2 / wintoast pkg.go.dev;Apple LSUIElement 论坛 thread/749689。
|
||||
- 公证:Apple Developer News id=09032019a、Eclectic Light「Notarization: the hardened runtime」、Apple 论坛 thread/735223、Wails #3290;Wails #4592 / commit 12b9f3a(macOS 26 Tahoe webview 崩溃修复)。
|
||||
|
||||
---
|
||||
|
||||
> 以下两节为 sweep 中两个研究 agent 瞬时 API 断连失败后的补跑产物(2026-06-13 补全),分别覆盖 D1 OAuth 的客户端实现机制与 D6 的具体打包命令。引号风格用弯引号,与上方合成简报的角括号略有出入,内容以本节为准。
|
||||
|
||||
## OAuth 原生回调通道——Wails 客户端机制(D1 补充)
|
||||
|
||||
本节只覆盖 Wails/Go 客户端侧的实现机制。前提已确定:public client + PKCE,回调用 `http://127.0.0.1:<临时端口>/callback`,Casdoor 对 loopback 任意端口放行,后端 audience 改多值校验。Casdoor 端点:authorize=`/login/oauth/authorize`,token=`/api/login/oauth/access_token`。
|
||||
|
||||
### 1. loopback vs 自定义 scheme `cdrop://` 的取舍(Wails 语境)
|
||||
|
||||
两条路线都符合 RFC 8252 对原生应用的要求(§7.1 私有 scheme、§7.3 loopback),但在 Wails 下实现成本差异明显。
|
||||
|
||||
**loopback(`http://127.0.0.1:<port>`):**
|
||||
|
||||
- 实现完全在 Go 进程内:起一个临时 `net/http` 监听、`runtime.BrowserOpenURL` 打开授权页、回调命中本地端口即拿到 `code`。无需任何 OS 级注册。
|
||||
- 不需要单实例锁。授权流全程在同一个已运行的进程里闭环,浏览器回跳打到的是本进程开的端口,不会拉起第二个 app 实例。
|
||||
- 跨平台零差异:macOS / Windows / Linux 代码一致,只依赖 `net/http`。
|
||||
- 必须用 IP 字面量 `127.0.0.1`(或 `[::1]`),不要用 `localhost`——RFC 8252 §8.3:用 `localhost` 可能因 DNS / hosts 解析意外监听到非回环接口。端口用 OS 分配的临时端口(`:0`),符合 RFC 8252 §7.3“服务器 MUST 允许请求时指定任意端口”。
|
||||
|
||||
**自定义 scheme(`cdrop://callback`):**
|
||||
|
||||
- 需要 OS 级登记:macOS 在 `Info.plist` 写 `CFBundleURLTypes` → `CFBundleURLSchemes`(值 `cdrop`);Windows 要在注册表 `HKEY_CLASSES_ROOT\cdrop`(或 per-user `HKCU\Software\Classes\cdrop`)写 `URL Protocol` 键 + `shell\open\command` 指向 exe;Linux 走 `.desktop` 的 `x-scheme-handler/cdrop`。
|
||||
- 必须配单实例锁。点 `cdrop://` 时 OS 会重新拉起 app 的“第二个实例”,深链作为命令行参数传入。需要 `options.App.SingleInstanceLock` 把这个 deep link 转发给首实例,否则 token 落在一个马上要退出的临时进程里,拿不到。
|
||||
- macOS 上单实例锁与深链协作有已知坑(见 wails issue #5089:v3 的 single instance lock 与 `OpenedWithURL` 不兼容;v2 也需自己从 `Args` 解析 URL 并 `WindowUnminimise` + `Show`)。
|
||||
|
||||
scheme 路线的 `SingleInstanceLock` 形态(作为对比,**本项目不采用**):
|
||||
|
||||
```go
|
||||
// 仅作对比:scheme 方案才需要这一层;loopback 方案不需要
|
||||
SingleInstanceLock: &options.SingleInstanceLock{
|
||||
UniqueId: "net.commilitia.cdrop",
|
||||
OnSecondInstanceLaunch: func(d options.SecondInstanceData) {
|
||||
// d.Args 里含被 OS 透传的 cdrop://callback?code=...&state=...
|
||||
for _, arg := range d.Args {
|
||||
if strings.HasPrefix(arg, "cdrop://") {
|
||||
runtime.WindowUnminimise(appCtx) // 回调不会自动聚焦窗口
|
||||
runtime.Show(appCtx)
|
||||
runtime.EventsEmit(appCtx, "oauth:callback", arg)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
**推荐:loopback。** 在 Wails 下它省掉了单实例锁、`Info.plist`/注册表登记、深链解析与跨平台分叉这一整套,授权流在单进程内自洽,代码量和真机调试面都小得多。`cdrop://` 的唯一优势是不占端口、回调 URL 更“原生”,但对瘦客户端不值这些成本。scheme 留作未来若需“浏览器里点 cdrop 链接唤起桌面端”的备选——那是另一个用例(深链唤起),与本次登录回调无关。
|
||||
|
||||
### 2. `runtime.BrowserOpenURL` 用法与注意
|
||||
|
||||
签名:
|
||||
|
||||
```go
|
||||
func BrowserOpenURL(ctx context.Context, url string)
|
||||
```
|
||||
|
||||
它调用系统默认浏览器打开 URL,本身不阻塞、无返回值(错误只在内部记日志,见 wails issue #3261,因此打开后要靠 loopback 回调或超时判定结果,不能依赖返回值)。`ctx` 用 Wails 在 `OnStartup` 注入并由你保存的 app context。
|
||||
|
||||
**为什么不能在 WebView 内用 `window.location = authorizeURL`:** Wails 的前端跑在系统 WebView 里,`window.location` 跳转会把 **WebView 自身**导航到 Casdoor 授权页,等于在嵌入式 user-agent 里做授权——这恰恰违反 RFC 8252 §8.12(原生应用 MUST NOT 用 embedded user-agent 做授权请求)与 §5(MUST 用 external user-agent)。后果实际可见:你的 React UI 整个被 authorize 页替换掉,回跳后 WebView 停在 `127.0.0.1` 而非应用界面,且拿不到系统浏览器里已有的 SSO 会话。正确做法是 `runtime.BrowserOpenURL`,把授权放到独立的系统浏览器,WebView 原地不动,靠 loopback 回调拿结果。
|
||||
|
||||
### 3. 完整 PKCE 桥接流程 + 代码骨架
|
||||
|
||||
数据流:
|
||||
|
||||
React 点登录 → 调 Wails 绑定的 Go 方法 `StartLogin()` → Go 生成 PKCE `verifier`/`challenge` 与随机 `state`,起 `127.0.0.1:0` 临时监听,拼 authorize URL → `runtime.BrowserOpenURL` 打开系统浏览器 → 用户授权 → 浏览器回跳 loopback,Go 捕获 `code` 并校验 `state` → **Go 侧**用 `verifier` 完成 token 交换 → `runtime.EventsEmit` 把 token 结果推回前端 → React `EventsOn` 收到后落 store。
|
||||
|
||||
**token 交换放 Go 侧(强烈建议)。** PKCE `verifier` 与换得的 token 全程不进 WebView/JS 上下文,避免 `verifier` 或 refresh token 暴露在前端可被注入脚本读取的内存里;Go 还能把 token 直接持久化进 OS keychain(后续里程碑)。前端只收一个“登录成功/失败 + 必要的展示信息”事件。
|
||||
|
||||
Go 端骨架(loopback server 起停、超时、state 校验):
|
||||
|
||||
```go
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
const (
|
||||
AUTHORIZE_ENDPOINT = "https://<casdoor-host>/login/oauth/authorize"
|
||||
TOKEN_ENDPOINT = "https://<casdoor-host>/api/login/oauth/access_token"
|
||||
DESKTOP_CLIENT_ID = "<desktop-client-id>"
|
||||
LOGIN_TIMEOUT = 3 * time.Minute
|
||||
)
|
||||
|
||||
type App struct
|
||||
{
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// OnStartup 时保存 ctx,供 runtime.* 调用
|
||||
func (a *App) OnStartup(ctx context.Context)
|
||||
{
|
||||
a.ctx = ctx
|
||||
}
|
||||
|
||||
// StartLogin 由前端通过 Wails 绑定调用;整个授权流在 goroutine 里跑,
|
||||
// 结果通过 EventsEmit 推回,不在此方法的返回值里阻塞前端。
|
||||
func (a *App) StartLogin()
|
||||
{
|
||||
go a.runLoginFlow()
|
||||
}
|
||||
|
||||
func (a *App) runLoginFlow()
|
||||
{
|
||||
verifier, challenge, err := newPKCE()
|
||||
if err != nil
|
||||
{
|
||||
a.emitErr("PKCE 生成失败", err)
|
||||
return
|
||||
}
|
||||
state, err := randString(24)
|
||||
if err != nil
|
||||
{
|
||||
a.emitErr("state 生成失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
// :0 让 OS 分配临时端口;只绑回环
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil
|
||||
{
|
||||
a.emitErr("无法监听回环端口", err)
|
||||
return
|
||||
}
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", port)
|
||||
|
||||
// 用 channel 把回调结果带出 HTTP handler
|
||||
type result struct
|
||||
{
|
||||
code string
|
||||
err error
|
||||
}
|
||||
resCh := make(chan result, 1)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request)
|
||||
{
|
||||
q := r.URL.Query()
|
||||
if e := q.Get("error"); e != ""
|
||||
{
|
||||
writeClosePage(w, false) // 见第 4 节
|
||||
resCh <- result{err: fmt.Errorf("授权被拒绝:%s", e)}
|
||||
return
|
||||
}
|
||||
if q.Get("state") != state
|
||||
{
|
||||
writeClosePage(w, false)
|
||||
resCh <- result{err: fmt.Errorf("state 不匹配,疑似 CSRF")}
|
||||
return
|
||||
}
|
||||
writeClosePage(w, true)
|
||||
resCh <- result{code: q.Get("code")}
|
||||
})
|
||||
|
||||
srv := &http.Server{Handler: mux}
|
||||
go srv.Serve(ln)
|
||||
defer srv.Close()
|
||||
|
||||
authURL := AUTHORIZE_ENDPOINT + "?" + url.Values{
|
||||
"response_type": {"code"},
|
||||
"client_id": {DESKTOP_CLIENT_ID},
|
||||
"redirect_uri": {redirectURI},
|
||||
"scope": {"openid profile groups"},
|
||||
"state": {state},
|
||||
"code_challenge": {challenge},
|
||||
"code_challenge_method": {"S256"},
|
||||
}.Encode()
|
||||
|
||||
runtime.BrowserOpenURL(a.ctx, authURL)
|
||||
|
||||
select
|
||||
{
|
||||
case res := <-resCh:
|
||||
{
|
||||
if res.err != nil
|
||||
{
|
||||
a.emitErr("授权失败", res.err)
|
||||
return
|
||||
}
|
||||
// token 交换在 Go 侧;verifier 不入 WebView
|
||||
tok, err := exchangeToken(res.code, verifier, redirectURI)
|
||||
if err != nil
|
||||
{
|
||||
a.emitErr("token 交换失败", err)
|
||||
return
|
||||
}
|
||||
// 持久化(keychain 在后续里程碑)后,只把展示所需信息推前端
|
||||
runtime.EventsEmit(a.ctx, "oauth:success", map[string]any{
|
||||
"expiresIn": tok.ExpiresIn,
|
||||
})
|
||||
}
|
||||
case <-time.After(LOGIN_TIMEOUT):
|
||||
{
|
||||
a.emitErr("登录超时", fmt.Errorf("%s 内未完成授权", LOGIN_TIMEOUT))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) emitErr(msg string, err error)
|
||||
{
|
||||
runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{
|
||||
"message": msg,
|
||||
"detail": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// --- PKCE 工具 ---
|
||||
|
||||
func newPKCE() (verifier, challenge string, err error)
|
||||
{
|
||||
verifier, err = randString(64) // RFC 7636:43-128 字符
|
||||
if err != nil
|
||||
{
|
||||
return "", "", err
|
||||
}
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
challenge = base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
return verifier, challenge, nil
|
||||
}
|
||||
|
||||
func randString(n int) (string, error)
|
||||
{
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil
|
||||
{
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)[:n], nil
|
||||
}
|
||||
```
|
||||
|
||||
前端桥骨架(`EventsOn` 落 store;`EventsOn` 返回取消函数,组件卸载时调用):
|
||||
|
||||
```ts
|
||||
import { EventsOn } from "../wailsjs/runtime/runtime";
|
||||
import { StartLogin } from "../wailsjs/go/backend/App";
|
||||
|
||||
// 在 app 初始化处注册一次;data 即 Go 端 EventsEmit 的 optionalData(JSON 反序列化后逐个作为参数)
|
||||
export function wireOAuthEvents(store: AuthStore): () => void
|
||||
{
|
||||
const offSuccess = EventsOn("oauth:success", (payload: { expiresIn: number }) =>
|
||||
{
|
||||
store.setLoggedIn(payload);
|
||||
});
|
||||
|
||||
const offError = EventsOn("oauth:error", (payload: { message: string; detail: string }) =>
|
||||
{
|
||||
store.setError(payload.message);
|
||||
});
|
||||
|
||||
// 返回组合取消函数
|
||||
return () =>
|
||||
{
|
||||
offSuccess();
|
||||
offError();
|
||||
};
|
||||
}
|
||||
|
||||
// 登录按钮 onClick
|
||||
export async function onLoginClick(): Promise<void>
|
||||
{
|
||||
await StartLogin(); // 仅触发流程;真正结果走上面的事件
|
||||
}
|
||||
```
|
||||
|
||||
要点:`EventsEmit(ctx, name, data...)` 的 `optionalData` 经 `JSON.stringify` 传输,前端回调以 `callback.apply(null, data)` 接收——发单个对象即对应回调首个参数。`wails dev` 下事件走 WebSocket、生产走原生 IPC,协议一致,无需区分。
|
||||
|
||||
### 4. loopback server 收尾(回浏览器页 + 关监听)
|
||||
|
||||
拿到 `code` 后给浏览器返回一段静态 HTML,提示已登录并尝试自关闭标签页(`window.close()` 仅对脚本打开的窗口可靠,授权跳转打开的标签页常关不掉,故同时给出可读文案兜底)。随后 server 凭 `defer srv.Close()` 关闭监听释放临时端口。
|
||||
|
||||
```go
|
||||
func writeClosePage(w http.ResponseWriter, ok bool)
|
||||
{
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
msg := "已登录,可关闭本页返回 cdrop。"
|
||||
if !ok
|
||||
{
|
||||
msg = "登录未完成,可关闭本页返回 cdrop 重试。"
|
||||
}
|
||||
fmt.Fprintf(w, `<!doctype html><html lang="zh-Hans"><head><meta charset="utf-8">`+
|
||||
`<title>cdrop</title></head><body style="font-family:sans-serif;text-align:center;margin-top:20vh">`+
|
||||
`<p>%s</p><script>setTimeout(function(){window.close();},800);</script>`+
|
||||
`</body></html>`, msg)
|
||||
}
|
||||
```
|
||||
|
||||
`srv.Close()` 立即断开监听与活动连接;因为响应已写完且 `code` 已通过 channel 送出,在 `select` 分支返回后由 `defer` 触发关闭即可,不必等浏览器读完。
|
||||
|
||||
### 已确定 vs 待真机实测(OAuth)
|
||||
|
||||
**已确定(来自官方 API 签名 / RFC):**
|
||||
|
||||
- `runtime.BrowserOpenURL(ctx, url)`、`runtime.EventsEmit(ctx, name, data...)`、前端 `EventsOn(name, cb)` 返回取消函数——签名稳定。
|
||||
- loopback 必须用 `127.0.0.1` 而非 `localhost`,端口用临时端口;public client 必须 PKCE;必须用系统浏览器而非 WebView——均为 RFC 8252 明文要求。
|
||||
- 选 loopback 即无需 `SingleInstanceLock` / `Info.plist` / 注册表。
|
||||
|
||||
**待真机实测:**
|
||||
|
||||
- Casdoor 对 `redirect_uri` 的 loopback 匹配是否真的“任意端口”放行(前提已声明,仍需端到端验一次端口变动下不报 `redirect_uri_mismatch`)。
|
||||
- `scope` 取值(示例写 `openid profile groups`)需与 Casdoor 实际 scope / groups claim 配置对齐。
|
||||
- token 交换接口 `/api/login/oauth/access_token` 的请求体格式(form vs JSON)与 `exchangeToken` 实现需按 Casdoor 实测确定。
|
||||
- macOS WebView(WKWebView)与 Windows WebView2 下 `BrowserOpenURL` 是否都正确落到“系统默认浏览器”而非内置浏览器,需各平台各验一次。
|
||||
- 浏览器标签页 `window.close()` 在 Chrome / Safari / Edge 上的实际可关闭性(多数授权跳转开的标签关不掉,文案兜底是否够友好)。
|
||||
|
||||
### 来源(OAuth)
|
||||
|
||||
- Wails Runtime(BrowserOpenURL / Events 签名):https://pkg.go.dev/github.com/wailsapp/wails/v2/pkg/runtime
|
||||
- Wails options(SingleInstanceLock / SecondInstanceData 结构):https://pkg.go.dev/github.com/wailsapp/wails/v2/pkg/options
|
||||
- Wails Single Instance Lock 指南:https://wails.io/docs/guides/single-instance-lock/
|
||||
- Wails Custom Protocol Schemes 指南:https://wails.io/docs/guides/custom-protocol-schemes/
|
||||
- Wails Browser 运行时参考:https://wails.io/docs/reference/runtime/browser/
|
||||
- macOS 单实例锁与深链不兼容 issue:https://github.com/wailsapp/wails/issues/5089
|
||||
- BrowserOpenURL 错误不记录 issue:https://github.com/wailsapp/wails/issues/3261
|
||||
- RFC 8252 OAuth 2.0 for Native Apps(§5/§6/§7.1/§7.3/§8.3/§8.12):https://datatracker.ietf.org/doc/html/rfc8252
|
||||
- RFC 7636 PKCE:https://datatracker.ietf.org/doc/html/rfc7636
|
||||
|
||||
---
|
||||
|
||||
## 打包·签名·公证操作手册(D6 补充)
|
||||
|
||||
本节只补**具体命令与 CI 配置**;entitlement/公证“会不会被拒”的判断已在前文完成。环境基准:Wails v2.12.0(Go),macOS universal 优先 + Windows x64,瘦客户端。
|
||||
|
||||
> 重要前提:Wails 官方 signing 指南至今仍推荐 `gon`,但该工具已停止维护、且依赖已废弃的 `altool` 语义。本手册一律改用 Apple 现行的 `codesign` + `notarytool` + `stapler` 链路,这是已确定的正确路径。
|
||||
|
||||
### 1. macOS
|
||||
|
||||
#### 1.1 构建产物与已知坑(已确定)
|
||||
|
||||
```bash
|
||||
wails build -platform darwin/universal -clean
|
||||
```
|
||||
|
||||
- 产物:`build/bin/<AppName>.app`(universal `.app` bundle,`lipo` 已合并 arm64 + amd64 两份 Go 二进制)。`<AppName>` 来自 `wails.json` 的 `outputfilename`/项目名。
|
||||
- 项目脚手架在 `build/darwin/` 下生成 `Info.plist`(模板 `Info.plist`/`Info.dev.plist`),这是 `.app` 内 `Contents/Info.plist` 的来源。改 bundle ID、版本号、`LSMinimumSystemVersion` 等都改这里。
|
||||
- 已知坑:
|
||||
- universal 构建要求**本机同时具备 arm64 与 amd64 的 CGO 工具链**。在 Apple Silicon 的 `macos-latest`(macos-14/15)runner 上原生满足;不要尝试从 Linux 交叉编译 darwin(CGO + macOS SDK 缺失,社区反复确认不可行)。
|
||||
- `wails build` 本身**不签名、不公证 macOS 产物**(与 Windows 的 `-nsis` 不同,没有内建签名参数)。签名/公证完全是构建后的独立步骤。
|
||||
- Wails 没有内建 DMG 封装,需自己做(见 1.4)。
|
||||
|
||||
#### 1.2 codesign(Developer ID Application,启用 hardened runtime)
|
||||
|
||||
公证的前置硬性要求:**hardened runtime(`--options runtime`)+ secure timestamp(`--timestamp`)+ Developer ID Application 证书**。
|
||||
|
||||
```bash
|
||||
APP="build/bin/YourApp.app"
|
||||
IDENTITY="Developer ID Application: Your Name (TEAMID1234)"
|
||||
|
||||
# 先确认本机/keychain 里的签名身份
|
||||
security find-identity -v -p codesigning
|
||||
|
||||
codesign --force --deep \
|
||||
--options runtime \
|
||||
--timestamp \
|
||||
--entitlements build/darwin/entitlements.plist \
|
||||
--sign "$IDENTITY" \
|
||||
"$APP"
|
||||
|
||||
# 校验
|
||||
codesign --verify --deep --strict --verbose=2 "$APP"
|
||||
codesign --display --entitlements - "$APP"
|
||||
```
|
||||
|
||||
`--deep` 取舍(已确定):瘦客户端的 `.app` 里只有一个主可执行文件,`--deep` 加不加都能签上;但 `--deep` 是 Apple 明确**不推荐**用于发布签名的(盲签所有嵌套项、掩盖结构问题)。当前瘦客户端单条命令即可;若将来加入嵌套二进制,改为从内到外逐个签。
|
||||
|
||||
#### 1.3 notarytool 提交 + stapler 装订(已确定)
|
||||
|
||||
`notarytool` **不接受裸 `.app`**,必须先打成 zip(`ditto`,保留 bundle 结构)或 DMG 再提交。
|
||||
|
||||
```bash
|
||||
# 1) 用 ditto 打 zip(--keepParent 保留 .app 顶层目录)
|
||||
ditto -c -k --keepParent "build/bin/YourApp.app" "YourApp.zip"
|
||||
|
||||
# 2A) Apple ID + app-specific password
|
||||
xcrun notarytool submit "YourApp.zip" \
|
||||
--apple-id "you@example.com" \
|
||||
--password "$APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "TEAMID1234" \
|
||||
--wait
|
||||
|
||||
# 2B) 或用 App Store Connect API key(CI 首选)
|
||||
xcrun notarytool submit "YourApp.zip" \
|
||||
--key "AuthKey_KEYID.p8" \
|
||||
--key-id "KEYID12345" \
|
||||
--issuer "ISSUER-UUID" \
|
||||
--wait
|
||||
|
||||
# 失败时拉日志
|
||||
xcrun notarytool log <submission-id> \
|
||||
--key "AuthKey_KEYID.p8" --key-id "KEYID12345" --issuer "ISSUER-UUID"
|
||||
|
||||
# 3) 公证通过后把 ticket 装订进 .app(staple 的是 .app,不是 zip)
|
||||
xcrun stapler staple "build/bin/YourApp.app"
|
||||
xcrun stapler validate "build/bin/YourApp.app"
|
||||
```
|
||||
|
||||
#### 1.4 DMG 封装(已确定)
|
||||
|
||||
最终分发媒介里**只放已公证 + 已装订的 `.app`**,再对 DMG 本身签名,然后对 DMG 单独走一遍公证 + staple(推荐)。
|
||||
|
||||
```bash
|
||||
# 方式 A:create-dmg(brew install create-dmg)
|
||||
create-dmg \
|
||||
--volname "YourApp" \
|
||||
--app-drop-link 450 190 \
|
||||
--codesign "Developer ID Application: Your Name (TEAMID1234)" \
|
||||
"YourApp.dmg" \
|
||||
"build/bin/" # 源目录,里面放已公证的 YourApp.app
|
||||
|
||||
# 方式 B:纯 hdiutil + codesign(无额外依赖)
|
||||
hdiutil create -volname "YourApp" -srcfolder "build/bin/YourApp.app" -ov -format UDZO "YourApp.dmg"
|
||||
codesign --force --timestamp --sign "Developer ID Application: Your Name (TEAMID1234)" "YourApp.dmg"
|
||||
|
||||
# 对 DMG 再公证 + staple
|
||||
xcrun notarytool submit "YourApp.dmg" --key ... --key-id ... --issuer ... --wait
|
||||
xcrun stapler staple "YourApp.dmg"
|
||||
```
|
||||
|
||||
#### 1.5 最小 entitlements.plist(已确定)
|
||||
|
||||
位置:`build/darwin/entitlements.plist`(Wails 约定路径,由 `--entitlements` 引用)。瘦客户端(Developer ID 渠道、**非** Mac App Store)的最小集合只需网络客户端:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
- **不要**加 `com.apple.security.app-sandbox`:App Sandbox 只有上架 Mac App Store 才强制;Developer ID 渠道开启沙盒只会平添文件访问麻烦。
|
||||
- **purego `dlopen` 系统 framework 在 hardened runtime 下:不需要任何额外 entitlement**。Library Validation 只拦“非 Apple 签名、且非同 Team ID 签名”的代码;系统 framework(Carbon/Cocoa/CoreFoundation 等)由 Apple 签名,`dlopen` 它们**不触发**拦截。因此**不需要** `com.apple.security.cs.disable-library-validation`,也**不需要** `allow-dyld-environment-variables`(这两个键只在加载第三方/未签名 dylib 时才需要,加了反而削弱 Gatekeeper 评估)。
|
||||
- **Carbon 全局热键(RegisterEventHotKey)不需要任何 entitlement**——普通系统 API,不涉及输入监控类隐私权限。
|
||||
|
||||
> 待真机实测:purego 在 hardened runtime 下 `dlopen` 系统 framework 应无碍,但请在“签名 + 公证后的 `.app`”上跑一次,确认热键与网络功能正常、没有因 `dlopen` 路径写法意外触发限制。
|
||||
|
||||
#### 1.6 Wails 对 darwin 签名的内建支持(已确定)
|
||||
|
||||
`wails build -platform darwin/universal` **无签名参数**,不做 codesign/notarize;签名、公证、DMG 全是构建后的外部步骤。`build/darwin/` 下有 `Info.plist`/`Info.dev.plist`;`entitlements.plist` 非自动生成,需手动放进该路径。universal 由 Wails 内部 `lipo` 合并,无需手动 `lipo`。
|
||||
|
||||
### 2. Windows
|
||||
|
||||
#### 2.1 构建产物(已确定)
|
||||
|
||||
```bash
|
||||
wails build -platform windows/amd64 -clean # 仅 exe
|
||||
wails build -platform windows/amd64 -nsis -clean # 附 NSIS 安装器
|
||||
```
|
||||
|
||||
NSIS 配置在 `build/windows/installer/`(`info.json` + `.nsi`),数据取自 `wails.json` 的 `Info` 段,安装器输出到 `build/bin/`。瘦客户端用内建 NSIS 已足够,不必引入 WiX。
|
||||
|
||||
#### 2.2 signtool 签名(已确定)
|
||||
|
||||
证书用标准代码签名证书即可(**不需要 EV**)。注:2023 年起 OV 证书私钥须存硬件 token/HSM 或云签名服务,CI 多走云签名(SSL.com eSigner/DigiCert KeyLocker);下面是本地 `.pfx` 经典流程。
|
||||
|
||||
```powershell
|
||||
# /fd 文件摘要; /tr RFC3161 时间戳(必须,证书过期后签名仍有效); /td 时间戳摘要
|
||||
signtool sign /fd sha256 /tr http://ts.ssl.com /td sha256 /f certificate.pfx /p "%CERT_PASSWORD%" .\build\bin\YourApp.exe
|
||||
```
|
||||
|
||||
**NSIS 的已知坑(已确定)**:`wails build -nsis` 只签**安装器和卸载器**,**不签**安装后落到 `Program Files` 的主程序 `.exe`。正确顺序:先 signtool 签主 `.exe` → 再生成安装器 → 再签安装器(或在 `project.nsi` 用 `!finalize`/`!uninstfinalize` 调 signtool)。
|
||||
|
||||
### 3. GitHub Actions
|
||||
|
||||
#### 3.1 macOS job 骨架(已确定)
|
||||
|
||||
`apple-actions/import-codesign-certs`(v7,把 base64 的 `.p12` 导入临时 keychain)+ App Store Connect API key 跑 notarytool。secrets:`APPLE_CERT_P12_BASE64`、`APPLE_CERT_PASSWORD`、`AC_API_KEY_P8`、`AC_API_KEY_ID`、`AC_API_ISSUER_ID`、`APPLE_SIGN_IDENTITY`。
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
macos:
|
||||
runs-on: macos-latest # Apple Silicon,原生支持 universal CGO
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { submodules: recursive } # cjk-autospace 等 submodule
|
||||
- uses: actions/setup-go@v5
|
||||
with: { go-version: '1.22' } # Wails v2.12 要求 Go >= 1.21
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: '20' }
|
||||
- run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0
|
||||
- run: wails build -platform darwin/universal -clean
|
||||
- uses: apple-actions/import-codesign-certs@v7
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.APPLE_CERT_P12_BASE64 }}
|
||||
p12-password: ${{ secrets.APPLE_CERT_PASSWORD }}
|
||||
- name: Codesign
|
||||
run: |
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements build/darwin/entitlements.plist \
|
||||
--sign "${{ secrets.APPLE_SIGN_IDENTITY }}" "build/bin/YourApp.app"
|
||||
codesign --verify --deep --strict --verbose=2 "build/bin/YourApp.app"
|
||||
- name: Notarize + staple
|
||||
env: { AC_API_KEY_P8: '${{ secrets.AC_API_KEY_P8 }}' }
|
||||
run: |
|
||||
echo "$AC_API_KEY_P8" > /tmp/AuthKey.p8
|
||||
ditto -c -k --keepParent "build/bin/YourApp.app" "/tmp/YourApp.zip"
|
||||
xcrun notarytool submit "/tmp/YourApp.zip" \
|
||||
--key /tmp/AuthKey.p8 --key-id "${{ secrets.AC_API_KEY_ID }}" \
|
||||
--issuer "${{ secrets.AC_API_ISSUER_ID }}" --wait
|
||||
xcrun stapler staple "build/bin/YourApp.app"
|
||||
rm /tmp/AuthKey.p8
|
||||
- name: Package DMG
|
||||
run: |
|
||||
brew install create-dmg
|
||||
create-dmg --volname "YourApp" --app-drop-link 450 190 \
|
||||
--codesign "${{ secrets.APPLE_SIGN_IDENTITY }}" "YourApp.dmg" "build/bin/"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with: { name: YourApp-macos, path: YourApp.dmg }
|
||||
```
|
||||
|
||||
#### 3.2 Windows job 骨架(已确定)
|
||||
|
||||
```yaml
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { submodules: recursive }
|
||||
- uses: actions/setup-go@v5
|
||||
with: { go-version: '1.22' }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: '20' }
|
||||
- run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0
|
||||
- run: wails build -platform windows/amd64 -clean
|
||||
- name: Restore signing certificate
|
||||
shell: pwsh
|
||||
run: |
|
||||
$bytes = [Convert]::FromBase64String("${{ secrets.WIN_SIGNING_CERT }}")
|
||||
[IO.File]::WriteAllBytes("$env:RUNNER_TEMP\cert.pfx", $bytes)
|
||||
- name: Sign main exe
|
||||
shell: pwsh
|
||||
run: |
|
||||
$signtool = (Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" `
|
||||
| Sort-Object FullName -Descending | Select-Object -First 1).FullName
|
||||
& $signtool sign /fd sha256 /tr http://ts.ssl.com /td sha256 `
|
||||
/f "$env:RUNNER_TEMP\cert.pfx" /p "${{ secrets.WIN_SIGNING_CERT_PASSWORD }}" .\build\bin\YourApp.exe
|
||||
- run: wails build -platform windows/amd64 -nsis -skipbindings
|
||||
- name: Sign installer
|
||||
shell: pwsh
|
||||
run: |
|
||||
$signtool = (Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" `
|
||||
| Sort-Object FullName -Descending | Select-Object -First 1).FullName
|
||||
& $signtool sign /fd sha256 /tr http://ts.ssl.com /td sha256 `
|
||||
/f "$env:RUNNER_TEMP\cert.pfx" /p "${{ secrets.WIN_SIGNING_CERT_PASSWORD }}" `
|
||||
.\build\bin\YourApp-amd64-installer.exe
|
||||
- uses: actions/upload-artifact@v4
|
||||
with: { name: YourApp-windows, path: build/bin/*installer.exe }
|
||||
```
|
||||
|
||||
### 已确定 vs 待真机实测(D6)
|
||||
|
||||
- **已确定**:所有命令标志语义、产物路径(`build/bin/<App>.app`、`build/darwin/entitlements.plist`、`build/windows/installer/`)、最小 entitlements(仅 `network.client`)、purego dlopen 系统 framework 不需 `disable-library-validation`、Carbon 热键不需 entitlement、notarytool 必须先 zip/DMG、NSIS 不签主 exe 的坑。
|
||||
- **待真机实测**:① 签名+公证后的 `.app` 在真机上 purego dlopen + 全局热键 + 网络全链路实跑;② Windows 安装器实际产物文件名;③ `signtool` 路径随 runner SDK 版本变化、通配是否仍命中。
|
||||
|
||||
### 来源(D6)
|
||||
|
||||
- Wails Code Signing 指南:https://wails.io/docs/guides/signing/
|
||||
- Wails Mac App Store 指南(entitlements/codesign --options=runtime、build/darwin 路径):https://wails.io/docs/guides/mac-appstore/
|
||||
- Wails NSIS installer 指南(build/windows/installer、-nsis):https://wails.io/docs/guides/windows-installer/
|
||||
- Wails NSIS 不签主 exe 的 issue #3716:https://github.com/wailsapp/wails/issues/3716
|
||||
- Wails Crossplatform build(GitHub Actions、darwin/universal 矩阵):https://wails.io/docs/guides/crossplatform-build/
|
||||
- notarytool man page:https://keith.github.io/xcode-man-pages/notarytool.1.html
|
||||
- Apple TN3147 迁移到新公证工具:https://developer.apple.com/documentation/technotes/tn3147-migrating-to-the-latest-notarization-tool
|
||||
- Apple Disable Library Validation entitlement:https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_security_cs_disable-library-validation
|
||||
- Apple 论坛「系统 framework 由 Apple 签名不触发 LV」:https://developer.apple.com/forums/thread/115451
|
||||
- apple-actions/import-codesign-certs:https://github.com/Apple-Actions/import-codesign-certs
|
||||
- create-dmg:https://github.com/create-dmg/create-dmg
|
||||
-424
@@ -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)
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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 |
@@ -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}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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("或厂商私有 UTI(com.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)
|
||||
}
|
||||
@@ -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>
|
||||
Generated
-679
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
f8412e249716e9530567e4bc92d8331c
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
Binary file not shown.
|
Before Width: | Height: | Size: 136 KiB |
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
`"`, """,
|
||||
"'", "'",
|
||||
)
|
||||
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, "&") || !strings.Contains(got, "<") || !strings.Contains(got, """) {
|
||||
t.Errorf("xmlEscape did not escape specials: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
//go:build !darwin
|
||||
|
||||
package platform
|
||||
|
||||
// TriggerLocalNetwork 仅 macOS 需要(本地网络隐私门)。Windows / Linux 无此门,空实现。
|
||||
func TriggerLocalNetwork() {}
|
||||
@@ -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 |
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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) // 43–128 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 (A–Z a–z 0–9 - _) 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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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://")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
});
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
# cdrop(drop.<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-Meta(cdrop 据它定位托管设备);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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 阶段跑在宿主原生 arch(macOS 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 编译期 env:dev 模式 token 必须随 image 烤进 bundle;prod 部署留空即可
|
||||
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"]
|
||||
@@ -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
|
||||
@@ -1,52 +0,0 @@
|
||||
# cdrop 在你的 docker-compose.yaml 中的服务条目片段。
|
||||
# NN 由你决定(与既有服务区分的编号 / 名称);your-proxy-network 换成你的反代所在网络。
|
||||
#
|
||||
# 不显式 ports —— 走内部网络,反代(如 Caddy)通过 NN-cdrop:8080 访问。
|
||||
# 持久化挂一个目录到 /data,里面只有 cdrop.db(SQLite 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 TTL(7 天)
|
||||
# CDROP_GUEST_ACCESS_TTL_SECONDS: "900" # guest 访客 access TTL
|
||||
# CDROP_GUEST_REFRESH_TTL_SECONDS: "86400" # guest refresh TTL(1 天)
|
||||
|
||||
# ---- 可选: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>
|
||||
@@ -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。
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
Reference in New Issue
Block a user