cdrop — 跨 OS 剪贴板与文件传输服务
Commilitia Drop:自托管的跨设备剪贴板同步与点对点文件传输。 - 后端 Go(chi / SQLite WAL / SSE Hub / WebRTC signaling + 状态机 / Relay ring buffer),编译进单个 distroless 镜像(前端 go:embed)。 - 前端 React + TanStack Router + Zustand,自实现 SSE + WebRTC P2P,NAT 受阻时回退服务端中继;聚珍(Juzhen)CJK 综合排版。 - 桌面端 Wails v2(macOS / Windows),瘦客户端复用 web。 - 鉴权 OIDC PKCE(自建 Casdoor 等),refresh_token 信封加密存系统密钥库;iOS Shortcut 用 HS256 scoped token。 架构文档与变更记录见 docs 分支(PROJECT_BRIEF / FRONTEND_DESIGN / CHANGELOG)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
build/bin
|
||||
node_modules
|
||||
frontend/dist
|
||||
+403
@@ -0,0 +1,403 @@
|
||||
# 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。
|
||||
@@ -0,0 +1,19 @@
|
||||
# README
|
||||
|
||||
## About
|
||||
|
||||
This is the official Wails Vanilla-TS template.
|
||||
|
||||
You can configure the project by editing `wails.json`. More information about the project settings can be found
|
||||
here: https://wails.io/docs/reference/project-config
|
||||
|
||||
## Live Development
|
||||
|
||||
To run in live development mode, run `wails dev` in the project directory. This will run a Vite development
|
||||
server that will provide very fast hot reload of your frontend changes. If you want to develop in a browser
|
||||
and have access to your Go methods, there is also a dev server that runs on http://localhost:34115. Connect
|
||||
to this in your browser, and you can call your Go code from devtools.
|
||||
|
||||
## Building
|
||||
|
||||
To build a redistributable, production mode package, use `wails build`.
|
||||
@@ -0,0 +1,694 @@
|
||||
# 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
|
||||
+372
@@ -0,0 +1,372 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/menu"
|
||||
"github.com/wailsapp/wails/v2/pkg/menu/keys"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"cdrop-desktop/platform"
|
||||
)
|
||||
|
||||
// loginResultFromToken decodes the identity out of the exchanged tokens and
|
||||
// assembles the payload the WebView store consumes. Shared by login + refresh.
|
||||
func loginResultFromToken(tok *platform.TokenResult) (*platform.LoginResult, error) {
|
||||
user, err := platform.UserFromToken(tok.IDToken, tok.AccessToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &platform.LoginResult{
|
||||
AccessToken: tok.AccessToken,
|
||||
RefreshToken: tok.RefreshToken,
|
||||
ExpiresIn: tok.ExpiresIn,
|
||||
User: user,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// App is the Wails application context bound to the WebView.
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
apiBase string
|
||||
token *platform.TokenResult // in-memory copy; refresh_token persists in the OS secret store (see session.go)
|
||||
quitting bool // set by the menu bar "Quit" so beforeClose allows the real exit
|
||||
startHidden bool // set by main when launched via autostart: come up to the menu bar, no window
|
||||
|
||||
clipSource platform.Source
|
||||
clipMonitor *platform.Monitor
|
||||
}
|
||||
|
||||
// NewApp creates a new App application struct.
|
||||
func NewApp() *App {
|
||||
return &App{apiBase: envOr("CDROP_API_BASE", "https://drop.commilitia.net")}
|
||||
}
|
||||
|
||||
// startup is called when the app starts. The context is saved so we can call
|
||||
// the runtime methods, and the menu bar item is installed (macOS; no-op
|
||||
// elsewhere). The app launches as a regular Dock app with the window visible.
|
||||
func (a *App) startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
// Load the persisted refresh_token into the Go process so refresh works after
|
||||
// a restart. The refresh_token stays here — it's never handed to the WebView.
|
||||
if s, err := platform.LoadSession(); err == nil && s != nil {
|
||||
a.token = &platform.TokenResult{
|
||||
AccessToken: s.AccessToken,
|
||||
RefreshToken: s.RefreshToken,
|
||||
ExpiresIn: s.ExpiresIn,
|
||||
}
|
||||
}
|
||||
// Keep the autostart entry in sync with this binary: when it's enabled,
|
||||
// re-apply so an entry written by an older build (no --hidden flag) or one
|
||||
// pointing at a since-moved bundle is refreshed to the current path + flags.
|
||||
// Idempotent and cheap — a single plist / registry write.
|
||||
if platform.IsLaunchAtLoginEnabled() {
|
||||
_ = platform.SetLaunchAtLogin(true)
|
||||
}
|
||||
a.startClipboardSync(ctx)
|
||||
platform.InstallStatusBar(
|
||||
platform.StatusBarMenu{
|
||||
Title: "cdrop",
|
||||
Show: "显示主窗口",
|
||||
Settings: "设置…",
|
||||
Quit: "退出 cdrop",
|
||||
},
|
||||
// The native menu-action callbacks fire on the AppKit main thread; calling
|
||||
// Wails runtime methods synchronously there can re-enter the main run loop
|
||||
// and crash. Hand off to a goroutine — Wails then dispatches to the main
|
||||
// thread safely.
|
||||
platform.StatusBarHandler{
|
||||
OnShowWindow: func() { go a.revealWindow() },
|
||||
OnOpenSettings: func() { go a.openSettings() },
|
||||
OnQuit: func() { go a.requestQuit() },
|
||||
},
|
||||
)
|
||||
// Autostart launch: the window already came up hidden (options.StartHidden).
|
||||
// On macOS also drop the Dock icon to accessory so only the menu bar item
|
||||
// remains — the same resting state as close-to-menu-bar. (Windows: this is a
|
||||
// no-op; the tray is installed above and the hidden window keeps the taskbar
|
||||
// clear. The menu bar item / tray is the way back in.)
|
||||
if a.startHidden {
|
||||
platform.SetDockVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
// buildMenu returns the macOS application menu. Without it the standard
|
||||
// shortcuts (⌘C/⌘V/⌘X/⌘A/⌘Z and ⌘W/⌘M) don't work.
|
||||
//
|
||||
// Quit is a custom ⌘Q item rather than the role AppMenu's, because Wails routes
|
||||
// both the window close button and ⌘Q through the same OnBeforeClose("Q")
|
||||
// channel — indistinguishable there. The custom item sets the quitting flag
|
||||
// first, so OnBeforeClose lets ⌘Q quit while the close button hides. ⌘W likewise
|
||||
// hides (close-to-menu-bar), matching the lifecycle.
|
||||
func (a *App) buildMenu() *menu.Menu {
|
||||
m := menu.NewMenu()
|
||||
|
||||
appSub := menu.NewMenu()
|
||||
appSub.Append(menu.Text("退出 cdrop", keys.CmdOrCtrl("q"), func(*menu.CallbackData) {
|
||||
a.requestQuit()
|
||||
}))
|
||||
m.Append(menu.SubMenu("cdrop", appSub)) // macOS renders the first menu as the app menu
|
||||
|
||||
m.Append(menu.EditMenu()) // Undo/Redo/Cut/Copy/Paste/SelectAll
|
||||
|
||||
window := menu.NewMenu()
|
||||
window.Append(menu.Text("最小化", keys.CmdOrCtrl("m"), func(*menu.CallbackData) {
|
||||
runtime.WindowMinimise(a.ctx)
|
||||
}))
|
||||
window.Append(menu.Text("关闭窗口", keys.CmdOrCtrl("w"), func(*menu.CallbackData) {
|
||||
a.hideWindow()
|
||||
}))
|
||||
m.Append(menu.SubMenu("窗口", window))
|
||||
return m
|
||||
}
|
||||
|
||||
// hideWindow tucks the window away to the menu bar (window hidden, Dock icon
|
||||
// gone). Shared by ⌘W and the window's close button (via beforeClose).
|
||||
func (a *App) hideWindow() {
|
||||
runtime.WindowHide(a.ctx)
|
||||
platform.SetDockVisible(false)
|
||||
}
|
||||
|
||||
// beforeClose intercepts the window close. Instead of quitting, it hides the
|
||||
// window and drops to accessory (Dock icon gone, menu bar item remains the way
|
||||
// back in). The real exit only happens after the menu bar "Quit" sets quitting.
|
||||
func (a *App) beforeClose(_ context.Context) bool {
|
||||
if a.quitting {
|
||||
return false // allow the quit to proceed
|
||||
}
|
||||
a.hideWindow()
|
||||
return true // prevent the close
|
||||
}
|
||||
|
||||
// revealWindow brings the main window back and restores the Dock icon. Bound to
|
||||
// the menu bar "Show" item.
|
||||
func (a *App) revealWindow() {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
}
|
||||
platform.SetDockVisible(true)
|
||||
runtime.WindowShow(a.ctx)
|
||||
runtime.WindowUnminimise(a.ctx)
|
||||
}
|
||||
|
||||
// openSettings reveals the window and asks the WebView to route to settings.
|
||||
func (a *App) openSettings() {
|
||||
a.revealWindow()
|
||||
runtime.EventsEmit(a.ctx, "menu:settings")
|
||||
}
|
||||
|
||||
// requestQuit flags an intentional quit so beforeClose lets it through, then
|
||||
// asks Wails to terminate.
|
||||
func (a *App) requestQuit() {
|
||||
a.quitting = true
|
||||
runtime.Quit(a.ctx)
|
||||
}
|
||||
|
||||
// startClipboardSync starts the native pasteboard watcher. Local copies are
|
||||
// emitted to the WebView as "clipboard:native"; the JS layer owns the upload
|
||||
// decision (auth + the user's sync toggle) and reuses the web app's existing
|
||||
// upload + dedup path. The download direction is driven by the WebView calling
|
||||
// ApplyRemoteClipboard. The shared Monitor enforces the loop guard across both
|
||||
// directions.
|
||||
func (a *App) startClipboardSync(ctx context.Context) {
|
||||
a.clipSource = platform.NewClipboardSource()
|
||||
a.clipMonitor = platform.NewMonitor(func(text string) {
|
||||
runtime.EventsEmit(a.ctx, "clipboard:native", text)
|
||||
})
|
||||
go platform.Run(ctx, a.clipSource, a.clipMonitor)
|
||||
}
|
||||
|
||||
// ApplyRemoteClipboard is bound to the WebView. It writes a remote clipboard
|
||||
// update to the local pasteboard (download direction). The Monitor arms its loop
|
||||
// guard before the write so the resulting change isn't re-uploaded, and skips
|
||||
// this device's own echoes. content is plain text at this stage.
|
||||
func (a *App) ApplyRemoteClipboard(content, sourceDevice string) {
|
||||
if a.clipMonitor == nil || a.clipSource == nil {
|
||||
return
|
||||
}
|
||||
a.clipMonitor.ApplyRemote(a.clipSource, platform.ClipboardContent{
|
||||
Content: content,
|
||||
ContentType: "text/plain",
|
||||
SourceDevice: sourceDevice,
|
||||
}, a.DeviceName())
|
||||
}
|
||||
|
||||
// StartLogin is bound to the WebView. It runs the loopback PKCE flow in the
|
||||
// background and reports the outcome via Wails events ("oauth:success" /
|
||||
// "oauth:error"). On success it persists the full session (with refresh_token)
|
||||
// Go-side and emits only the refresh-free SessionView to JS — the refresh_token
|
||||
// never enters the WebView (see the credential policy in session.go).
|
||||
func (a *App) StartLogin() {
|
||||
go func() {
|
||||
cfg, err := a.resolveOAuthConfig()
|
||||
if err != nil {
|
||||
runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
flow := platform.NewFlow(cfg, func(u string) {
|
||||
runtime.BrowserOpenURL(a.ctx, u)
|
||||
})
|
||||
tok, err := flow.Login(a.ctx)
|
||||
if err != nil {
|
||||
runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
res, err := loginResultFromToken(tok)
|
||||
if err != nil {
|
||||
runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
a.token = tok
|
||||
if err := platform.SaveSession(*res); err != nil {
|
||||
runtime.LogWarningf(a.ctx, "persist session failed: %v", err)
|
||||
}
|
||||
view := res.View()
|
||||
runtime.EventsEmit(a.ctx, "oauth:success", &view)
|
||||
}()
|
||||
}
|
||||
|
||||
// Refresh is bound to the WebView and takes NO argument: the refresh_token never
|
||||
// crosses into JS, so Go uses its own in-memory copy. It mints a fresh
|
||||
// access_token (public-client loopback flow, no client_secret), persists the
|
||||
// rotated session, and returns only the refresh-free SessionView. The WebView
|
||||
// calls this when an API request comes back 401.
|
||||
func (a *App) Refresh() (*platform.SessionView, error) {
|
||||
if a.token == nil || a.token.RefreshToken == "" {
|
||||
return nil, errors.New("no refresh token")
|
||||
}
|
||||
cfg, err := a.resolveOAuthConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
flow := platform.NewFlow(cfg, func(string) {})
|
||||
tok, err := flow.Refresh(a.ctx, a.token.RefreshToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tok.RefreshToken == "" {
|
||||
tok.RefreshToken = a.token.RefreshToken // providers that don't rotate: keep the old one
|
||||
}
|
||||
res, err := loginResultFromToken(tok)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.token = tok
|
||||
if err := platform.SaveSession(*res); err != nil {
|
||||
runtime.LogWarningf(a.ctx, "persist refreshed session failed: %v", err)
|
||||
}
|
||||
view := res.View()
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
// ClearSession is bound to the WebView; called on logout to drop the persisted
|
||||
// session file (and the Go-side token copy) so the next launch starts logged out.
|
||||
func (a *App) ClearSession() error {
|
||||
a.token = nil
|
||||
return platform.ClearSession()
|
||||
}
|
||||
|
||||
// GetSettings is bound to the WebView. It returns the persisted desktop config,
|
||||
// with LaunchAtLogin reflecting the actual LaunchAgent state (the plist is the
|
||||
// source of truth — the user may have toggled it elsewhere).
|
||||
func (a *App) GetSettings() platform.DesktopConfig {
|
||||
cfg, _ := platform.LoadConfig()
|
||||
cfg.LaunchAtLogin = platform.IsLaunchAtLoginEnabled()
|
||||
return cfg
|
||||
}
|
||||
|
||||
// SaveSettings is bound to the WebView. It persists the settings the page owns
|
||||
// and applies the launch-at-login side effect (install / remove the LaunchAgent).
|
||||
//
|
||||
// The page's DesktopConfig has no device_name field, so the incoming cfg always
|
||||
// carries DeviceName="". We merge onto the current config (preserving DeviceName,
|
||||
// which is owned by SetDeviceName) instead of overwriting — otherwise saving any
|
||||
// setting would wipe the persisted device name. Config is written before the
|
||||
// launch-agent side effect so its failure doesn't lose the other preferences.
|
||||
func (a *App) SaveSettings(cfg platform.DesktopConfig) error {
|
||||
cur, _ := platform.LoadConfig()
|
||||
cur.ClipboardSyncEnabled = cfg.ClipboardSyncEnabled
|
||||
cur.LaunchAtLogin = cfg.LaunchAtLogin
|
||||
cur.DownloadDir = cfg.DownloadDir
|
||||
if err := platform.SaveConfig(cur); err != nil {
|
||||
return err
|
||||
}
|
||||
return platform.SetLaunchAtLogin(cur.LaunchAtLogin)
|
||||
}
|
||||
|
||||
// DeviceName is bound to the WebView. Returns the persisted device name, or the
|
||||
// hostname when the user hasn't renamed it — so the desktop skips the per-device
|
||||
// naming step the browser shows.
|
||||
func (a *App) DeviceName() string {
|
||||
return platform.ResolveDeviceName()
|
||||
}
|
||||
|
||||
// SetDeviceName is bound to the WebView; called when the user renames this device
|
||||
// so the name survives restart (the WebView's localStorage doesn't, on wails://).
|
||||
func (a *App) SetDeviceName(name string) error {
|
||||
cfg, _ := platform.LoadConfig()
|
||||
cfg.DeviceName = name
|
||||
return platform.SaveConfig(cfg)
|
||||
}
|
||||
|
||||
// SaveDownload is bound to the WebView. The transfer pipeline hands a received
|
||||
// file's bytes (base64 over the bridge) to Go, which writes them into the user's
|
||||
// configured download directory (default ~/Downloads) without overwriting. The
|
||||
// absolute path is returned so the page can tell the user where it landed.
|
||||
func (a *App) SaveDownload(name string, data []byte) (string, error) {
|
||||
return platform.SaveDownload(platform.ResolveDownloadDir(), name, data)
|
||||
}
|
||||
|
||||
// ChooseDownloadDir opens a native directory picker (title localised by the
|
||||
// caller) and returns the chosen path, or "" when the user cancels. The page
|
||||
// persists the result via SaveSettings.
|
||||
func (a *App) ChooseDownloadDir(title string) (string, error) {
|
||||
return runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{Title: title})
|
||||
}
|
||||
|
||||
// EffectiveDownloadDir returns the directory downloads currently land in — the
|
||||
// configured override or the system default — for display in the settings page.
|
||||
func (a *App) EffectiveDownloadDir() string {
|
||||
return platform.ResolveDownloadDir()
|
||||
}
|
||||
|
||||
// resolveOAuthConfig prefers explicit env overrides (dev / a pinned client);
|
||||
// otherwise it fetches /api/auth/config from the backend and reuses the web
|
||||
// app's client_id for the desktop's loopback PKCE flow (see desktop/PLAN.md §3).
|
||||
func (a *App) resolveOAuthConfig() (platform.OAuthConfig, error) {
|
||||
if env := oauthConfigFromEnv(); env.ClientID != "" {
|
||||
return env, nil
|
||||
}
|
||||
return platform.FetchOAuthConfig(a.ctx, a.apiBase)
|
||||
}
|
||||
|
||||
func oauthConfigFromEnv() platform.OAuthConfig {
|
||||
return platform.OAuthConfig{
|
||||
AuthorizeURL: os.Getenv("CDROP_OAUTH_AUTHORIZE_URL"),
|
||||
TokenURL: os.Getenv("CDROP_OAUTH_TOKEN_URL"),
|
||||
ClientID: os.Getenv("CDROP_OAUTH_CLIENT_ID"),
|
||||
Scopes: os.Getenv("CDROP_OAUTH_SCOPES"),
|
||||
}
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// LoggedIn reports whether a token is currently held; bound so the WebView can
|
||||
// query auth state on startup.
|
||||
func (a *App) LoggedIn() bool {
|
||||
return a.token != nil
|
||||
}
|
||||
|
||||
// Greet returns a greeting for the given name (scaffold demo, kept until the
|
||||
// shared web UI replaces the default frontend).
|
||||
func (a *App) Greet(name string) string {
|
||||
return fmt.Sprintf("Hello %s, It's show time!", name)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# Build Directory
|
||||
|
||||
The build directory is used to house all the build files and assets for your application.
|
||||
|
||||
The structure is:
|
||||
|
||||
* bin - Output directory
|
||||
* darwin - macOS specific files
|
||||
* windows - Windows specific files
|
||||
|
||||
## Mac
|
||||
|
||||
The `darwin` directory holds files specific to Mac builds.
|
||||
These may be customised and used as part of the build. To return these files to the default state, simply delete them
|
||||
and
|
||||
build with `wails build`.
|
||||
|
||||
The directory contains the following files:
|
||||
|
||||
- `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`.
|
||||
- `Info.dev.plist` - same as the main plist file but used when building using `wails dev`.
|
||||
|
||||
## Windows
|
||||
|
||||
The `windows` directory contains the manifest and rc files used when building with `wails build`.
|
||||
These may be customised for your application. To return these files to the default state, simply delete them and
|
||||
build with `wails build`.
|
||||
|
||||
- `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to
|
||||
use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file
|
||||
will be created using the `appicon.png` file in the build directory.
|
||||
- `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`.
|
||||
- `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer,
|
||||
as well as the application itself (right click the exe -> properties -> details)
|
||||
- `wails.exe.manifest` - The main application manifest file.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>{{.Info.ProductName}}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>{{.OutputFilename}}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>net.commilitia.cdrop</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>{{.Info.Comments}}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>iconfile</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>{{.Info.Copyright}}</string>
|
||||
{{if .Info.FileAssociations}}
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
{{range .Info.FileAssociations}}
|
||||
<dict>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<string>{{.Ext}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>{{.Name}}</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
<key>CFBundleTypeIconFile</key>
|
||||
<string>{{.IconName}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
{{if .Info.Protocols}}
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
{{range .Info.Protocols}}
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.wails.{{.Scheme}}</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>{{.Scheme}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>{{.Info.ProductName}}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>{{.OutputFilename}}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>net.commilitia.cdrop</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>{{.Info.Comments}}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{{.Info.ProductVersion}}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>iconfile</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>true</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>{{.Info.Copyright}}</string>
|
||||
{{if .Info.FileAssociations}}
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
{{range .Info.FileAssociations}}
|
||||
<dict>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<string>{{.Ext}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>{{.Name}}</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
<key>CFBundleTypeIconFile</key>
|
||||
<string>{{.IconName}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
{{if .Info.Protocols}}
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
{{range .Info.Protocols}}
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.wails.{{.Scheme}}</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>{{.Scheme}}</string>
|
||||
</array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>{{.Role}}</string>
|
||||
</dict>
|
||||
{{end}}
|
||||
</array>
|
||||
{{end}}
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"fixed": {
|
||||
"file_version": "{{.Info.ProductVersion}}"
|
||||
},
|
||||
"info": {
|
||||
"0000": {
|
||||
"ProductVersion": "{{.Info.ProductVersion}}",
|
||||
"CompanyName": "{{.Info.CompanyName}}",
|
||||
"FileDescription": "{{.Info.ProductName}}",
|
||||
"LegalCopyright": "{{.Info.Copyright}}",
|
||||
"ProductName": "{{.Info.ProductName}}",
|
||||
"Comments": "{{.Info.Comments}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
Unicode true
|
||||
|
||||
####
|
||||
## Please note: Template replacements don't work in this file. They are provided with default defines like
|
||||
## mentioned underneath.
|
||||
## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo.
|
||||
## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually
|
||||
## from outside of Wails for debugging and development of the installer.
|
||||
##
|
||||
## For development first make a wails nsis build to populate the "wails_tools.nsh":
|
||||
## > wails build --target windows/amd64 --nsis
|
||||
## Then you can call makensis on this file with specifying the path to your binary:
|
||||
## For a AMD64 only installer:
|
||||
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
|
||||
## For a ARM64 only installer:
|
||||
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
|
||||
## For a installer with both architectures:
|
||||
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
|
||||
####
|
||||
## The following information is taken from the ProjectInfo file, but they can be overwritten here.
|
||||
####
|
||||
## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}"
|
||||
## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}"
|
||||
## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}"
|
||||
## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}"
|
||||
## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}"
|
||||
###
|
||||
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
|
||||
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
|
||||
####
|
||||
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
|
||||
####
|
||||
## Include the wails tools
|
||||
####
|
||||
!include "wails_tools.nsh"
|
||||
|
||||
# The version information for this two must consist of 4 parts
|
||||
VIProductVersion "${INFO_PRODUCTVERSION}.0"
|
||||
VIFileVersion "${INFO_PRODUCTVERSION}.0"
|
||||
|
||||
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
|
||||
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
|
||||
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
|
||||
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
|
||||
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
|
||||
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
|
||||
|
||||
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
|
||||
ManifestDPIAware true
|
||||
|
||||
!include "MUI.nsh"
|
||||
|
||||
!define MUI_ICON "..\icon.ico"
|
||||
!define MUI_UNICON "..\icon.ico"
|
||||
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
|
||||
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
|
||||
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
|
||||
|
||||
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
|
||||
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
|
||||
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
|
||||
!insertmacro MUI_PAGE_INSTFILES # Installing page.
|
||||
!insertmacro MUI_PAGE_FINISH # Finished installation page.
|
||||
|
||||
!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page
|
||||
|
||||
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
|
||||
|
||||
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
|
||||
#!uninstfinalize 'signtool --file "%1"'
|
||||
#!finalize 'signtool --file "%1"'
|
||||
|
||||
Name "${INFO_PRODUCTNAME}"
|
||||
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
|
||||
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
|
||||
ShowInstDetails show # This will always show the installation details.
|
||||
|
||||
Function .onInit
|
||||
!insertmacro wails.checkArchitecture
|
||||
FunctionEnd
|
||||
|
||||
Section
|
||||
!insertmacro wails.setShellContext
|
||||
|
||||
!insertmacro wails.webview2runtime
|
||||
|
||||
SetOutPath $INSTDIR
|
||||
|
||||
!insertmacro wails.files
|
||||
|
||||
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
|
||||
!insertmacro wails.associateFiles
|
||||
!insertmacro wails.associateCustomProtocols
|
||||
|
||||
!insertmacro wails.writeUninstaller
|
||||
SectionEnd
|
||||
|
||||
Section "uninstall"
|
||||
!insertmacro wails.setShellContext
|
||||
|
||||
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
|
||||
|
||||
RMDir /r $INSTDIR
|
||||
|
||||
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
|
||||
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
|
||||
|
||||
!insertmacro wails.unassociateFiles
|
||||
!insertmacro wails.unassociateCustomProtocols
|
||||
|
||||
!insertmacro wails.deleteUninstaller
|
||||
SectionEnd
|
||||
@@ -0,0 +1,249 @@
|
||||
# DO NOT EDIT - Generated automatically by `wails build`
|
||||
|
||||
!include "x64.nsh"
|
||||
!include "WinVer.nsh"
|
||||
!include "FileFunc.nsh"
|
||||
|
||||
!ifndef INFO_PROJECTNAME
|
||||
!define INFO_PROJECTNAME "{{.Name}}"
|
||||
!endif
|
||||
!ifndef INFO_COMPANYNAME
|
||||
!define INFO_COMPANYNAME "{{.Info.CompanyName}}"
|
||||
!endif
|
||||
!ifndef INFO_PRODUCTNAME
|
||||
!define INFO_PRODUCTNAME "{{.Info.ProductName}}"
|
||||
!endif
|
||||
!ifndef INFO_PRODUCTVERSION
|
||||
!define INFO_PRODUCTVERSION "{{.Info.ProductVersion}}"
|
||||
!endif
|
||||
!ifndef INFO_COPYRIGHT
|
||||
!define INFO_COPYRIGHT "{{.Info.Copyright}}"
|
||||
!endif
|
||||
!ifndef PRODUCT_EXECUTABLE
|
||||
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
|
||||
!endif
|
||||
!ifndef UNINST_KEY_NAME
|
||||
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
|
||||
!endif
|
||||
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
|
||||
|
||||
!ifndef REQUEST_EXECUTION_LEVEL
|
||||
!define REQUEST_EXECUTION_LEVEL "admin"
|
||||
!endif
|
||||
|
||||
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
|
||||
|
||||
!ifdef ARG_WAILS_AMD64_BINARY
|
||||
!define SUPPORTS_AMD64
|
||||
!endif
|
||||
|
||||
!ifdef ARG_WAILS_ARM64_BINARY
|
||||
!define SUPPORTS_ARM64
|
||||
!endif
|
||||
|
||||
!ifdef SUPPORTS_AMD64
|
||||
!ifdef SUPPORTS_ARM64
|
||||
!define ARCH "amd64_arm64"
|
||||
!else
|
||||
!define ARCH "amd64"
|
||||
!endif
|
||||
!else
|
||||
!ifdef SUPPORTS_ARM64
|
||||
!define ARCH "arm64"
|
||||
!else
|
||||
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
|
||||
!endif
|
||||
!endif
|
||||
|
||||
!macro wails.checkArchitecture
|
||||
!ifndef WAILS_WIN10_REQUIRED
|
||||
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
|
||||
!endif
|
||||
|
||||
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
|
||||
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
|
||||
!endif
|
||||
|
||||
${If} ${AtLeastWin10}
|
||||
!ifdef SUPPORTS_AMD64
|
||||
${if} ${IsNativeAMD64}
|
||||
Goto ok
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
!ifdef SUPPORTS_ARM64
|
||||
${if} ${IsNativeARM64}
|
||||
Goto ok
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
IfSilent silentArch notSilentArch
|
||||
silentArch:
|
||||
SetErrorLevel 65
|
||||
Abort
|
||||
notSilentArch:
|
||||
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
|
||||
Quit
|
||||
${else}
|
||||
IfSilent silentWin notSilentWin
|
||||
silentWin:
|
||||
SetErrorLevel 64
|
||||
Abort
|
||||
notSilentWin:
|
||||
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
|
||||
Quit
|
||||
${EndIf}
|
||||
|
||||
ok:
|
||||
!macroend
|
||||
|
||||
!macro wails.files
|
||||
!ifdef SUPPORTS_AMD64
|
||||
${if} ${IsNativeAMD64}
|
||||
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
!ifdef SUPPORTS_ARM64
|
||||
${if} ${IsNativeARM64}
|
||||
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
|
||||
${EndIf}
|
||||
!endif
|
||||
!macroend
|
||||
|
||||
!macro wails.writeUninstaller
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
|
||||
SetRegView 64
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
|
||||
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
|
||||
|
||||
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
|
||||
IntFmt $0 "0x%08X" $0
|
||||
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
|
||||
!macroend
|
||||
|
||||
!macro wails.deleteUninstaller
|
||||
Delete "$INSTDIR\uninstall.exe"
|
||||
|
||||
SetRegView 64
|
||||
DeleteRegKey HKLM "${UNINST_KEY}"
|
||||
!macroend
|
||||
|
||||
!macro wails.setShellContext
|
||||
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
|
||||
SetShellVarContext all
|
||||
${else}
|
||||
SetShellVarContext current
|
||||
${EndIf}
|
||||
!macroend
|
||||
|
||||
# Install webview2 by launching the bootstrapper
|
||||
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
|
||||
!macro wails.webview2runtime
|
||||
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
|
||||
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
|
||||
!endif
|
||||
|
||||
SetRegView 64
|
||||
# If the admin key exists and is not empty then webview2 is already installed
|
||||
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||
${If} $0 != ""
|
||||
Goto ok
|
||||
${EndIf}
|
||||
|
||||
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
|
||||
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
|
||||
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||
${If} $0 != ""
|
||||
Goto ok
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
|
||||
SetDetailsPrint both
|
||||
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
|
||||
SetDetailsPrint listonly
|
||||
|
||||
InitPluginsDir
|
||||
CreateDirectory "$pluginsdir\webview2bootstrapper"
|
||||
SetOutPath "$pluginsdir\webview2bootstrapper"
|
||||
File "tmp\MicrosoftEdgeWebview2Setup.exe"
|
||||
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
|
||||
|
||||
SetDetailsPrint both
|
||||
ok:
|
||||
!macroend
|
||||
|
||||
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
|
||||
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
|
||||
; Backup the previously associated file class
|
||||
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
|
||||
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
|
||||
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
|
||||
!macroend
|
||||
|
||||
!macro APP_UNASSOCIATE EXT FILECLASS
|
||||
; Backup the previously associated file class
|
||||
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
|
||||
|
||||
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
|
||||
!macroend
|
||||
|
||||
!macro wails.associateFiles
|
||||
; Create file associations
|
||||
{{range .Info.FileAssociations}}
|
||||
!insertmacro APP_ASSOCIATE "{{.Ext}}" "{{.Name}}" "{{.Description}}" "$INSTDIR\{{.IconName}}.ico" "Open with ${INFO_PRODUCTNAME}" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
|
||||
|
||||
File "..\{{.IconName}}.ico"
|
||||
{{end}}
|
||||
!macroend
|
||||
|
||||
!macro wails.unassociateFiles
|
||||
; Delete app associations
|
||||
{{range .Info.FileAssociations}}
|
||||
!insertmacro APP_UNASSOCIATE "{{.Ext}}" "{{.Name}}"
|
||||
|
||||
Delete "$INSTDIR\{{.IconName}}.ico"
|
||||
{{end}}
|
||||
!macroend
|
||||
|
||||
!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
|
||||
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
|
||||
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
|
||||
!macroend
|
||||
|
||||
!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
|
||||
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
|
||||
!macroend
|
||||
|
||||
!macro wails.associateCustomProtocols
|
||||
; Create custom protocols associations
|
||||
{{range .Info.Protocols}}
|
||||
!insertmacro CUSTOM_PROTOCOL_ASSOCIATE "{{.Scheme}}" "{{.Description}}" "$INSTDIR\${PRODUCT_EXECUTABLE},0" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
|
||||
|
||||
{{end}}
|
||||
!macroend
|
||||
|
||||
!macro wails.unassociateCustomProtocols
|
||||
; Delete app custom protocol associations
|
||||
{{range .Info.Protocols}}
|
||||
!insertmacro CUSTOM_PROTOCOL_UNASSOCIATE "{{.Scheme}}"
|
||||
{{end}}
|
||||
!macroend
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
<asmv3:application>
|
||||
<asmv3:windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
|
||||
</asmv3:windowsSettings>
|
||||
</asmv3:application>
|
||||
</assembly>
|
||||
@@ -0,0 +1,101 @@
|
||||
//go:build darwin
|
||||
|
||||
// clipprobe 是一个只读诊断工具:监听本机剪贴板变化,打印每次变化时
|
||||
// NSPasteboard 上挂的全部类型(UTI)。它不读取也不上传任何剪贴板内容,
|
||||
// 只看「类型」,用于在真机上经验性地确定各应用(尤其密码管理器)复制时
|
||||
// 打哪些 UTI,从而让 D4 的敏感内容过滤基于实测数据而非猜测。
|
||||
//
|
||||
// 用法:go run ./cmd/clipprobe(或 go build 后运行二进制),然后依次从
|
||||
// 普通文本编辑器 / 1Password / Bitwarden / 浏览器密码自动填充 等复制,观察
|
||||
// 各自打印的 UTI。含 org.nspasteboard.ConcealedType / TransientType /
|
||||
// AutoGeneratedType 或厂商私有 UTI(如 com.agilebits.onepassword)者,即为
|
||||
// D4 应跳过上传的敏感内容。
|
||||
//
|
||||
// 实现用 ebitengine/purego + objc 运行时直调 AppKit,无 cgo、可随 Wails
|
||||
// 交叉编译;调用模式照搬 golang.design/x/clipboard 的 darwin 实现。
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/ebitengine/purego"
|
||||
"github.com/ebitengine/purego/objc"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// objc 调用必须钉在同一 OS 线程上(autorelease pool 的语义要求)。
|
||||
runtime.LockOSThread()
|
||||
|
||||
// 加载 AppKit / Foundation,使 NSPasteboard / NSAutoreleasePool 等类可解析。
|
||||
for _, fw := range []string{
|
||||
"/System/Library/Frameworks/AppKit.framework/AppKit",
|
||||
"/System/Library/Frameworks/Foundation.framework/Foundation",
|
||||
} {
|
||||
if _, err := purego.Dlopen(fw, purego.RTLD_NOW|purego.RTLD_GLOBAL); err != nil {
|
||||
panic(fmt.Sprintf("clipprobe: 无法加载 %s: %v", fw, err))
|
||||
}
|
||||
}
|
||||
|
||||
pbClass := objc.GetClass("NSPasteboard")
|
||||
poolClass := objc.GetClass("NSAutoreleasePool")
|
||||
var (
|
||||
selGeneral = objc.RegisterName("generalPasteboard")
|
||||
selChangeCount = objc.RegisterName("changeCount")
|
||||
selTypes = objc.RegisterName("types")
|
||||
selCount = objc.RegisterName("count")
|
||||
selObjectAt = objc.RegisterName("objectAtIndex:")
|
||||
selUTF8 = objc.RegisterName("UTF8String")
|
||||
selAlloc = objc.RegisterName("alloc")
|
||||
selInit = objc.RegisterName("init")
|
||||
selRelease = objc.RegisterName("release")
|
||||
)
|
||||
|
||||
fmt.Println("clipprobe:监听剪贴板,打印每次变化的 pasteboard 类型(UTI)。Ctrl+C 退出。")
|
||||
fmt.Println("依次从 普通文本 / 1Password / Bitwarden / 浏览器密码自动填充 复制,观察各自的 UTI。")
|
||||
fmt.Println("含 org.nspasteboard.ConcealedType / TransientType / AutoGeneratedType")
|
||||
fmt.Println("或厂商私有 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)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-Hans">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>cdrop-desktop</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="./src/main.ts" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+679
@@ -0,0 +1,679 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^4.5.4",
|
||||
"vite": "^3.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.15.18",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.18.tgz",
|
||||
"integrity": "sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.15.18",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.15.18.tgz",
|
||||
"integrity": "sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.15.18",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.18.tgz",
|
||||
"integrity": "sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/android-arm": "0.15.18",
|
||||
"@esbuild/linux-loong64": "0.15.18",
|
||||
"esbuild-android-64": "0.15.18",
|
||||
"esbuild-android-arm64": "0.15.18",
|
||||
"esbuild-darwin-64": "0.15.18",
|
||||
"esbuild-darwin-arm64": "0.15.18",
|
||||
"esbuild-freebsd-64": "0.15.18",
|
||||
"esbuild-freebsd-arm64": "0.15.18",
|
||||
"esbuild-linux-32": "0.15.18",
|
||||
"esbuild-linux-64": "0.15.18",
|
||||
"esbuild-linux-arm": "0.15.18",
|
||||
"esbuild-linux-arm64": "0.15.18",
|
||||
"esbuild-linux-mips64le": "0.15.18",
|
||||
"esbuild-linux-ppc64le": "0.15.18",
|
||||
"esbuild-linux-riscv64": "0.15.18",
|
||||
"esbuild-linux-s390x": "0.15.18",
|
||||
"esbuild-netbsd-64": "0.15.18",
|
||||
"esbuild-openbsd-64": "0.15.18",
|
||||
"esbuild-sunos-64": "0.15.18",
|
||||
"esbuild-windows-32": "0.15.18",
|
||||
"esbuild-windows-64": "0.15.18",
|
||||
"esbuild-windows-arm64": "0.15.18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-android-64": {
|
||||
"version": "0.15.18",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.15.18.tgz",
|
||||
"integrity": "sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-android-arm64": {
|
||||
"version": "0.15.18",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.15.18.tgz",
|
||||
"integrity": "sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-darwin-64": {
|
||||
"version": "0.15.18",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.15.18.tgz",
|
||||
"integrity": "sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-darwin-arm64": {
|
||||
"version": "0.15.18",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.18.tgz",
|
||||
"integrity": "sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-freebsd-64": {
|
||||
"version": "0.15.18",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.18.tgz",
|
||||
"integrity": "sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-freebsd-arm64": {
|
||||
"version": "0.15.18",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.18.tgz",
|
||||
"integrity": "sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-linux-32": {
|
||||
"version": "0.15.18",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.15.18.tgz",
|
||||
"integrity": "sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-linux-64": {
|
||||
"version": "0.15.18",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.15.18.tgz",
|
||||
"integrity": "sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-linux-arm": {
|
||||
"version": "0.15.18",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.15.18.tgz",
|
||||
"integrity": "sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-linux-arm64": {
|
||||
"version": "0.15.18",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.18.tgz",
|
||||
"integrity": "sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-linux-mips64le": {
|
||||
"version": "0.15.18",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.18.tgz",
|
||||
"integrity": "sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-linux-ppc64le": {
|
||||
"version": "0.15.18",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.18.tgz",
|
||||
"integrity": "sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-linux-riscv64": {
|
||||
"version": "0.15.18",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.18.tgz",
|
||||
"integrity": "sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-linux-s390x": {
|
||||
"version": "0.15.18",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.18.tgz",
|
||||
"integrity": "sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-netbsd-64": {
|
||||
"version": "0.15.18",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.18.tgz",
|
||||
"integrity": "sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-openbsd-64": {
|
||||
"version": "0.15.18",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.18.tgz",
|
||||
"integrity": "sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-sunos-64": {
|
||||
"version": "0.15.18",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.15.18.tgz",
|
||||
"integrity": "sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-windows-32": {
|
||||
"version": "0.15.18",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.15.18.tgz",
|
||||
"integrity": "sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-windows-64": {
|
||||
"version": "0.15.18",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.15.18.tgz",
|
||||
"integrity": "sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-windows-arm64": {
|
||||
"version": "0.15.18",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.18.tgz",
|
||||
"integrity": "sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/is-core-module": {
|
||||
"version": "2.16.2",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
|
||||
"integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hasown": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"is-core-module": "^2.16.1",
|
||||
"path-parse": "^1.0.7",
|
||||
"supports-preserve-symlinks-flag": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"resolve": "bin/resolve"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "2.80.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz",
|
||||
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-preserve-symlinks-flag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "4.9.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
||||
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "3.2.11",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-3.2.11.tgz",
|
||||
"integrity": "sha512-K/jGKL/PgbIgKCiJo5QbASQhFiV02X9Jh+Qq0AKCRCRKZtOTVi4t6wh75FDpGf2N9rYOnzH87OEFQNaFy6pdxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.15.9",
|
||||
"postcss": "^8.4.18",
|
||||
"resolve": "^1.22.1",
|
||||
"rollup": "^2.79.1"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">= 14",
|
||||
"less": "*",
|
||||
"sass": "*",
|
||||
"stylus": "*",
|
||||
"sugarss": "*",
|
||||
"terser": "^5.4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"less": {
|
||||
"optional": true
|
||||
},
|
||||
"sass": {
|
||||
"optional": true
|
||||
},
|
||||
"stylus": {
|
||||
"optional": true
|
||||
},
|
||||
"sugarss": {
|
||||
"optional": true
|
||||
},
|
||||
"terser": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^4.5.4",
|
||||
"vite": "^3.0.7"
|
||||
}
|
||||
}
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
f8412e249716e9530567e4bc92d8331c
|
||||
@@ -0,0 +1,54 @@
|
||||
#logo {
|
||||
display: block;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
margin: auto;
|
||||
padding: 10% 0 0;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100%;
|
||||
background-origin: content-box;
|
||||
}
|
||||
|
||||
.result {
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
margin: 1.5rem auto;
|
||||
}
|
||||
|
||||
.input-box .btn {
|
||||
width: 60px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
margin: 0 0 0 20px;
|
||||
padding: 0 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.input-box .btn:hover {
|
||||
background-image: linear-gradient(to top, #cfd9df 0%, #e2ebf0 100%);
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.input-box .input {
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
padding: 0 10px;
|
||||
background-color: rgba(240, 240, 240, 1);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.input-box .input:hover {
|
||||
border: none;
|
||||
background-color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
.input-box .input:focus {
|
||||
border: none;
|
||||
background-color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
@@ -0,0 +1,50 @@
|
||||
import "./style.css";
|
||||
import "./app.css";
|
||||
import { StartLogin, LoggedIn } from "../wailsjs/go/main/App";
|
||||
import { EventsOn } from "../wailsjs/runtime/runtime";
|
||||
|
||||
// 临时登录验证 UI:在共享 web 界面接入(A2)前,用它端到端验证 D1
|
||||
// loopback OAuth 流程。点登录 → Go 起 loopback + 开系统浏览器 → 授权回跳 →
|
||||
// Go 换 token → oauth:success / oauth:error 事件回到这里。
|
||||
document.querySelector("#app")!.innerHTML = `
|
||||
<main class="login-demo">
|
||||
<h1>cdrop 桌面端</h1>
|
||||
<p class="status" id="status">检查登录状态……</p>
|
||||
<button class="btn" id="loginBtn" disabled>登录 cdrop</button>
|
||||
<p class="hint">登录会在系统浏览器中打开授权页,完成后自动返回。</p>
|
||||
</main>
|
||||
`;
|
||||
|
||||
const statusEl = document.getElementById("status") as HTMLElement;
|
||||
const loginBtn = document.getElementById("loginBtn") as HTMLButtonElement;
|
||||
|
||||
function setStatus(text: string): void {
|
||||
statusEl.innerText = text;
|
||||
}
|
||||
|
||||
loginBtn.addEventListener("click", () => {
|
||||
setStatus("已在系统浏览器中打开授权页,请完成登录……");
|
||||
loginBtn.disabled = true;
|
||||
StartLogin();
|
||||
});
|
||||
|
||||
EventsOn("oauth:success", (payload: { expiresIn: number }) => {
|
||||
setStatus(`登录成功,access token 有效期约 ${payload.expiresIn} 秒。`);
|
||||
loginBtn.innerText = "重新登录";
|
||||
loginBtn.disabled = false;
|
||||
});
|
||||
|
||||
EventsOn("oauth:error", (payload: { detail: string }) => {
|
||||
setStatus(`登录失败:${payload.detail}`);
|
||||
loginBtn.disabled = false;
|
||||
});
|
||||
|
||||
LoggedIn()
|
||||
.then((yes) => {
|
||||
setStatus(yes ? "已登录。" : "尚未登录。");
|
||||
loginBtn.disabled = false;
|
||||
})
|
||||
.catch(() => {
|
||||
setStatus("尚未登录。");
|
||||
loginBtn.disabled = false;
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
html {
|
||||
background-color: rgba(27, 38, 54, 1);
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: white;
|
||||
font-family: "Nunito", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
|
||||
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Nunito";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: local(""),
|
||||
url("assets/fonts/nunito-v16-latin-regular.woff2") format("woff2");
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
text-align: center;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ESNext",
|
||||
"DOM"
|
||||
],
|
||||
"moduleResolution": "Node",
|
||||
"strict": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"noEmit": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
module cdrop-desktop
|
||||
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
fyne.io/systray v1.12.2
|
||||
github.com/ebitengine/purego v0.10.1
|
||||
github.com/wailsapp/wails/v2 v2.12.0
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.design/x/clipboard v0.8.0
|
||||
golang.org/x/sys v0.33.0
|
||||
)
|
||||
|
||||
require (
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/samber/lo v1.49.1 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
golang.design/x/x11 v0.2.0 // indirect
|
||||
golang.org/x/crypto v0.33.0 // indirect
|
||||
golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 // indirect
|
||||
golang.org/x/image v0.28.0 // indirect
|
||||
golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
)
|
||||
|
||||
// replace github.com/wailsapp/wails/v2 v2.12.0 => /Users/commilitia/go/pkg/mod
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
|
||||
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
|
||||
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
|
||||
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
golang.design/x/clipboard v0.8.0 h1:6VEcH28wwcSgKc+vnxHHDWiRjrakTQIAJnPUrt3aOgg=
|
||||
golang.design/x/clipboard v0.8.0/go.mod h1:s0pwrtA3Q9fgnVtGDmP5ZK/pp55cQKB23esKsjwWhWM=
|
||||
golang.design/x/x11 v0.2.0 h1:Uiwu2guGihsJX/ZCzpoDPFz5gR/Qntm08mvoBCmRydo=
|
||||
golang.design/x/x11 v0.2.0/go.mod h1:/5q1mFkdc1rL8mvB7DsQFi6as4tIkBv4FXjcP07mrkE=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 h1:Wdx0vgH5Wgsw+lF//LJKmWOJBLWX6nprsMqnf99rYDE=
|
||||
golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
|
||||
golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
|
||||
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
|
||||
golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f h1:/n+PL2HlfqeSiDCuhdBbRNlGS/g2fM4OHufalHaTVG8=
|
||||
golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f/go.mod h1:ESkJ836Z6LpG6mTVAhA48LpfW/8fNR0ifStlH2axyfg=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
|
||||
"cdrop-desktop/platform"
|
||||
)
|
||||
|
||||
//go:embed all:frontend/dist
|
||||
var assets embed.FS
|
||||
|
||||
func main() {
|
||||
// Create an instance of the app structure
|
||||
app := NewApp()
|
||||
|
||||
// Reverse-proxy /api/* to the backend so the embedded web UI can keep its
|
||||
// same-origin relative URLs (see platform.NewAPIProxy). SSE streams through.
|
||||
apiProxy, err := platform.NewAPIProxy(app.apiBase)
|
||||
if err != nil {
|
||||
println("Error:", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Windows: Wails' custom-scheme AssetServer buffers a handler's response until
|
||||
// it returns, so the long-lived SSE never reaches the WebView (device list
|
||||
// never loads, status stuck on "connecting"). Serve /api from a loopback HTTP
|
||||
// origin instead — WebView2's normal network stack streams it — and point the
|
||||
// page there via the injected api_base. macOS streams the custom scheme fine,
|
||||
// so it keeps api_base="" (same-origin relative /api through apiProxy).
|
||||
webAPIBase := ""
|
||||
if runtime.GOOS == "windows" {
|
||||
if base, lerr := platform.StartLocalAPIServer(app.apiBase); lerr == nil {
|
||||
webAPIBase = base
|
||||
} else {
|
||||
println("local api server:", lerr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Tell the backend this is a native desktop client, not a browser, so the
|
||||
// device list shows the right kind. The page forwards it as X-Device-Type.
|
||||
deviceType := ""
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
deviceType = "macos"
|
||||
case "windows":
|
||||
deviceType = "windows"
|
||||
case "linux":
|
||||
deviceType = "linux"
|
||||
}
|
||||
|
||||
// Restore the persisted login + device name by injecting them into index.html
|
||||
// at load — the wails:// scheme has no persistent WebView storage (see
|
||||
// session.go).
|
||||
session, _ := platform.LoadSession()
|
||||
|
||||
// When the OS starts us at login, the autostart entry appends a --hidden flag
|
||||
// (see SetLaunchAtLogin). Come up straight to the menu bar / tray with no
|
||||
// window — the user didn't open the app, so don't steal focus. The frontend
|
||||
// still loads, so clipboard sync runs in the background. A manual launch has
|
||||
// no flag and shows the window as usual.
|
||||
hidden := launchedHidden()
|
||||
app.startHidden = hidden
|
||||
|
||||
// Create application with options
|
||||
err = wails.Run(&options.App{
|
||||
Title: "cdrop",
|
||||
Width: 1024,
|
||||
Height: 768,
|
||||
StartHidden: hidden,
|
||||
AssetServer: &assetserver.Options{
|
||||
Assets: assets,
|
||||
Handler: apiProxy,
|
||||
Middleware: platform.SessionInjectMiddleware(session, platform.ResolveDeviceName(), webAPIBase, deviceType),
|
||||
},
|
||||
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
|
||||
Menu: app.buildMenu(),
|
||||
OnStartup: app.startup,
|
||||
OnBeforeClose: app.beforeClose,
|
||||
Bind: []interface{}{
|
||||
app,
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
println("Error:", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// launchedHidden reports whether this process was started by the login autostart
|
||||
// entry, which appends a --hidden flag to the launch command (see
|
||||
// SetLaunchAtLogin on each platform). Manual launches never carry it.
|
||||
func launchedHidden() bool {
|
||||
for _, arg := range os.Args[1:] {
|
||||
if arg == "--hidden" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ClipboardMaxBytes mirrors the backend's CDROP_CLIPBOARD_MAX_BYTES (default
|
||||
// 64 KiB). Uploads larger than this are rejected client-side, matching the web.
|
||||
const ClipboardMaxBytes = 64 * 1024
|
||||
|
||||
// Client is an authenticated client for the cdrop backend API. The access token
|
||||
// is held here in Go (never in the WebView); deviceName identifies this device
|
||||
// as the source of clipboard writes via the X-Device-Name header.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
deviceName string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewClient builds a Client. baseURL is the backend origin, e.g.
|
||||
// https://drop.commilitia.net.
|
||||
func NewClient(baseURL, accessToken, deviceName string) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: accessToken,
|
||||
deviceName: deviceName,
|
||||
http: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// ClipboardContent is the cloud clipboard state. UpdatedAt is the server's
|
||||
// authoritative timestamp (also the ETag); 0 means never written.
|
||||
type ClipboardContent struct {
|
||||
Content string `json:"content"`
|
||||
ContentType string `json:"content_type"`
|
||||
SourceDevice string `json:"source_device"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (c *Client) authReq(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if c.deviceName != "" {
|
||||
req.Header.Set("X-Device-Name", c.deviceName)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// Clipboard fetches the current cloud clipboard content.
|
||||
func (c *Client) Clipboard(ctx context.Context) (ClipboardContent, error) {
|
||||
req, err := c.authReq(ctx, http.MethodGet, "/api/clipboard", nil)
|
||||
if err != nil {
|
||||
return ClipboardContent{}, fmt.Errorf("api: build clipboard request: %w", err)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return ClipboardContent{}, fmt.Errorf("api: clipboard request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return ClipboardContent{}, fmt.Errorf("api: GET clipboard status %d", resp.StatusCode)
|
||||
}
|
||||
var cc ClipboardContent
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cc); err != nil {
|
||||
return ClipboardContent{}, fmt.Errorf("api: decode clipboard: %w", err)
|
||||
}
|
||||
return cc, nil
|
||||
}
|
||||
|
||||
// PutClipboard uploads plain text to the cloud clipboard.
|
||||
func (c *Client) PutClipboard(ctx context.Context, text string) error {
|
||||
if len(text) > ClipboardMaxBytes {
|
||||
return fmt.Errorf("api: clipboard content %d bytes exceeds limit %d", len(text), ClipboardMaxBytes)
|
||||
}
|
||||
payload, err := json.Marshal(map[string]string{
|
||||
"content": text,
|
||||
"content_type": "text/plain",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("api: marshal clipboard: %w", err)
|
||||
}
|
||||
req, err := c.authReq(ctx, http.MethodPut, "/api/clipboard", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("api: build clipboard request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("api: clipboard upload: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("api: PUT clipboard status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPutClipboard(t *testing.T) {
|
||||
var gotAuth, gotDevice, gotCT string
|
||||
var gotBody map[string]string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut || r.URL.Path != "/api/clipboard" {
|
||||
t.Errorf("method/path = %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotDevice = r.Header.Get("X-Device-Name")
|
||||
gotCT = r.Header.Get("Content-Type")
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "tok-abc", "my-mac")
|
||||
if err := c.PutClipboard(context.Background(), "hello world"); err != nil {
|
||||
t.Fatalf("PutClipboard: %v", err)
|
||||
}
|
||||
if gotAuth != "Bearer tok-abc" {
|
||||
t.Errorf("Authorization = %q, want Bearer tok-abc", gotAuth)
|
||||
}
|
||||
if gotDevice != "my-mac" {
|
||||
t.Errorf("X-Device-Name = %q, want my-mac", gotDevice)
|
||||
}
|
||||
if gotCT != "application/json" {
|
||||
t.Errorf("Content-Type = %q", gotCT)
|
||||
}
|
||||
if gotBody["content"] != "hello world" || gotBody["content_type"] != "text/plain" {
|
||||
t.Errorf("body = %v", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutClipboard_TooLarge(t *testing.T) {
|
||||
c := NewClient("https://example", "tok", "dev")
|
||||
big := strings.Repeat("x", ClipboardMaxBytes+1)
|
||||
if err := c.PutClipboard(context.Background(), big); err == nil {
|
||||
t.Fatal("want error for oversized content, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClipboard_Get(t *testing.T) {
|
||||
var gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(ClipboardContent{
|
||||
Content: "from cloud",
|
||||
ContentType: "text/plain",
|
||||
SourceDevice: "other-device",
|
||||
UpdatedAt: 1700000000,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "tok-xyz", "my-mac")
|
||||
cc, err := c.Clipboard(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Clipboard: %v", err)
|
||||
}
|
||||
if gotAuth != "Bearer tok-xyz" {
|
||||
t.Errorf("Authorization = %q", gotAuth)
|
||||
}
|
||||
if cc.Content != "from cloud" || cc.SourceDevice != "other-device" || cc.UpdatedAt != 1700000000 {
|
||||
t.Errorf("clipboard = %+v", cc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClipboard_Unauthorized(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "bad", "dev")
|
||||
if _, err := c.Clipboard(context.Background()); err == nil {
|
||||
t.Fatal("want error for 401, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// DesktopConfig is the desktop-only preference set, persisted as JSON under the
|
||||
// user config dir. json tags are the contract with the WebView settings page.
|
||||
type DesktopConfig struct {
|
||||
ClipboardSyncEnabled bool `json:"clipboard_sync_enabled"`
|
||||
LaunchAtLogin bool `json:"launch_at_login"`
|
||||
DeviceName string `json:"device_name"` // empty = use the hostname
|
||||
DownloadDir string `json:"download_dir"` // empty = system Downloads dir
|
||||
}
|
||||
|
||||
// ResolveDeviceName returns the persisted device name, or the hostname when the
|
||||
// user hasn't renamed it. Persisting it Go-side is required because the WebView's
|
||||
// localStorage doesn't survive restart on the wails:// scheme (see session.go).
|
||||
func ResolveDeviceName() string {
|
||||
cfg, _ := LoadConfig()
|
||||
if cfg.DeviceName != "" {
|
||||
return cfg.DeviceName
|
||||
}
|
||||
if h, err := os.Hostname(); err == nil && h != "" {
|
||||
return h
|
||||
}
|
||||
return "cdrop-desktop"
|
||||
}
|
||||
|
||||
// DefaultConfig is what a fresh install gets: clipboard sync on, no autostart.
|
||||
func DefaultConfig() DesktopConfig {
|
||||
return DesktopConfig{ClipboardSyncEnabled: true, LaunchAtLogin: false}
|
||||
}
|
||||
|
||||
// ResolveDownloadDir returns the directory received files land in: the user's
|
||||
// configured override, or the system Downloads dir when unset.
|
||||
func ResolveDownloadDir() string {
|
||||
cfg, _ := LoadConfig()
|
||||
if cfg.DownloadDir != "" {
|
||||
return cfg.DownloadDir
|
||||
}
|
||||
return DefaultDownloadDir()
|
||||
}
|
||||
|
||||
// DefaultDownloadDir is ~/Downloads (the OS default download folder; its path is
|
||||
// stable across locales on macOS/Windows). Falls back to the cwd if the home
|
||||
// dir can't be resolved.
|
||||
func DefaultDownloadDir() string {
|
||||
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||
return filepath.Join(home, "Downloads")
|
||||
}
|
||||
return "."
|
||||
}
|
||||
|
||||
// configPath is ~/Library/Application Support/cdrop/config.json on macOS
|
||||
// (os.UserConfigDir resolves the per-OS base).
|
||||
func configPath() (string, error) {
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "cdrop", "config.json"), nil
|
||||
}
|
||||
|
||||
// LoadConfig reads the persisted config, falling back to defaults when the file
|
||||
// is absent or unreadable so the app always has a usable preference set.
|
||||
func LoadConfig() (DesktopConfig, error) {
|
||||
p, err := configPath()
|
||||
if err != nil {
|
||||
return DefaultConfig(), err
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return DefaultConfig(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return DefaultConfig(), err
|
||||
}
|
||||
cfg := DefaultConfig()
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return DefaultConfig(), err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SaveConfig writes the config atomically-ish (mkdir + write) under the user
|
||||
// config dir.
|
||||
func SaveConfig(cfg DesktopConfig) error {
|
||||
p, err := configPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(p, data, 0o644)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigRoundTrip(t *testing.T) {
|
||||
// Redirect the user config dir into a temp HOME so the test never touches
|
||||
// the real ~/Library/Application Support.
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
// Absent file → defaults.
|
||||
got, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig (absent): %v", err)
|
||||
}
|
||||
if got != DefaultConfig() {
|
||||
t.Errorf("absent config should be defaults, got %+v", got)
|
||||
}
|
||||
|
||||
want := DesktopConfig{ClipboardSyncEnabled: false, LaunchAtLogin: true}
|
||||
if err := SaveConfig(want); err != nil {
|
||||
t.Fatalf("SaveConfig: %v", err)
|
||||
}
|
||||
got, err = LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("round-trip: got %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigToleratesGarbage(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
p, err := configPath()
|
||||
if err != nil {
|
||||
t.Fatalf("configPath: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte("{not json"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := LoadConfig()
|
||||
if err == nil {
|
||||
t.Error("expected an error for malformed json")
|
||||
}
|
||||
if got != DefaultConfig() {
|
||||
t.Errorf("malformed config should fall back to defaults, got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ClipboardEvent is one observed change of the local clipboard.
|
||||
type ClipboardEvent struct {
|
||||
Text string
|
||||
// Sensitive is set by the platform Source when the pasteboard carries a
|
||||
// privacy marker — on macOS org.nspasteboard.ConcealedType / TransientType /
|
||||
// AutoGeneratedType or a known password-manager UTI; on Windows the
|
||||
// ExcludeClipboardContentFromMonitorProcessing / CanUploadToCloudClipboard
|
||||
// formats. Sensitive events are never uploaded.
|
||||
Sensitive bool
|
||||
}
|
||||
|
||||
// Sink receives clipboard text that the policy decided should be uploaded.
|
||||
type Sink func(text string)
|
||||
|
||||
// Source is the platform-specific clipboard backend: it watches for local
|
||||
// changes and can write text back. Implementations live in clipboard_darwin.go
|
||||
// / clipboard_windows.go. The upstream golang.design/x/clipboard library only
|
||||
// surfaces the changed text (no change-count, no pasteboard types), so the
|
||||
// Sensitive flag and the upload policy below are this package's responsibility,
|
||||
// not the library's.
|
||||
type Source interface {
|
||||
// Watch emits an event on every observed local clipboard change until ctx
|
||||
// is cancelled, then closes the channel. The implementation sets Sensitive
|
||||
// from the pasteboard's privacy markers.
|
||||
Watch(ctx context.Context) <-chan ClipboardEvent
|
||||
// Write replaces the local clipboard text.
|
||||
Write(text string) error
|
||||
}
|
||||
|
||||
// Monitor applies cdrop's upload policy on top of a raw clipboard event stream:
|
||||
// it drops sensitive content, de-duplicates repeats, and suppresses the echo of
|
||||
// our own writes (the loop guard, PLAN.md R4). Everything here is pure and
|
||||
// host-independent, so it is unit-tested without a real clipboard; the
|
||||
// platform Source produces the raw events and performs the OS writes.
|
||||
//
|
||||
// Debounce of rapid successive copies is intentionally left to the upload
|
||||
// callback (wrap it with github.com/bep/debounce in the app wiring) so the
|
||||
// policy stays free of timers and trivially testable.
|
||||
type Monitor struct {
|
||||
upload Sink
|
||||
|
||||
mu sync.Mutex
|
||||
lastHash string // hash of the last text we accepted (dedup)
|
||||
selfHash string // hash of the text we most recently wrote ourselves (loop guard)
|
||||
}
|
||||
|
||||
// NewMonitor builds a Monitor that forwards accepted text to upload.
|
||||
func NewMonitor(upload Sink) *Monitor {
|
||||
return &Monitor{upload: upload}
|
||||
}
|
||||
|
||||
// Observe runs the policy for one raw event. It returns true when the event was
|
||||
// accepted and forwarded to the sink, false when it was filtered out. Safe to
|
||||
// call concurrently with ApplyRemote — the watch goroutine and the download
|
||||
// path share one Monitor.
|
||||
func (m *Monitor) Observe(ev ClipboardEvent) bool {
|
||||
if ev.Sensitive || ev.Text == "" {
|
||||
return false
|
||||
}
|
||||
h := hashText(ev.Text)
|
||||
|
||||
m.mu.Lock()
|
||||
if h == m.selfHash {
|
||||
// Echo of our own write: consume it once (don't re-upload) and disarm,
|
||||
// so a genuine later re-copy of the same text still passes.
|
||||
m.selfHash = ""
|
||||
m.lastHash = h
|
||||
m.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
if h == m.lastHash {
|
||||
m.mu.Unlock()
|
||||
return false // unchanged content re-observed
|
||||
}
|
||||
m.lastHash = h
|
||||
m.mu.Unlock()
|
||||
|
||||
m.upload(ev.Text) // outside the lock: the sink may block (network / IPC)
|
||||
return true
|
||||
}
|
||||
|
||||
// WillWrite must be called immediately before the Source writes text to the
|
||||
// local clipboard, so the resulting change notification (carrying the same
|
||||
// text) is recognised as our own echo and not re-uploaded.
|
||||
func (m *Monitor) WillWrite(text string) {
|
||||
m.mu.Lock()
|
||||
m.selfHash = hashText(text)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// ApplyRemote writes a remote clipboard change to the local clipboard (the
|
||||
// download direction), skipping our own uploads echoed back by the server and
|
||||
// content we already hold. It arms the loop guard before writing, so the
|
||||
// Source's resulting change event is not re-uploaded. Returns true if it wrote.
|
||||
func (m *Monitor) ApplyRemote(src Source, cc ClipboardContent, selfDevice string) bool {
|
||||
if cc.Content == "" {
|
||||
return false
|
||||
}
|
||||
if selfDevice != "" && cc.SourceDevice == selfDevice {
|
||||
return false // our own upload, echoed back by the server
|
||||
}
|
||||
h := hashText(cc.Content)
|
||||
|
||||
m.mu.Lock()
|
||||
if h == m.lastHash {
|
||||
m.mu.Unlock()
|
||||
return false // already the current local content
|
||||
}
|
||||
m.selfHash = h // arm loop guard inline (avoid re-locking via WillWrite)
|
||||
m.mu.Unlock()
|
||||
|
||||
if err := src.Write(cc.Content); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.lastHash = h
|
||||
m.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
// Run drives src's events through m until ctx is cancelled or the source closes.
|
||||
// It is the glue the app uses; all policy decisions live in Monitor.
|
||||
func Run(ctx context.Context, src Source, m *Monitor) {
|
||||
ch := src.Watch(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case ev, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
m.Observe(ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hashText(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//go:build darwin
|
||||
|
||||
package platform
|
||||
|
||||
/*
|
||||
#cgo darwin CFLAGS: -x objective-c -fobjc-arc
|
||||
#cgo darwin LDFLAGS: -framework Cocoa
|
||||
#include <stdlib.h>
|
||||
|
||||
long cdropPasteboardChangeCount(void);
|
||||
char *cdropPasteboardReadText(int *sensitive);
|
||||
void cdropPasteboardWriteText(const char *text);
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// pasteboardPollInterval is how often the watcher samples the change counter.
|
||||
// 400ms keeps copies feeling instant without busy-polling.
|
||||
const pasteboardPollInterval = 400 * time.Millisecond
|
||||
|
||||
// darwinClipboard is the macOS Source: it polls NSPasteboard's changeCount for
|
||||
// local copies and writes plain text back. NSPasteboard read/write off the main
|
||||
// thread is the same approach the common clipboard libraries take.
|
||||
type darwinClipboard struct {
|
||||
poll time.Duration
|
||||
}
|
||||
|
||||
// NewClipboardSource returns the platform clipboard Source (macOS).
|
||||
func NewClipboardSource() Source {
|
||||
return &darwinClipboard{poll: pasteboardPollInterval}
|
||||
}
|
||||
|
||||
func (d *darwinClipboard) Watch(ctx context.Context) <-chan ClipboardEvent {
|
||||
ch := make(chan ClipboardEvent)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
last := C.cdropPasteboardChangeCount()
|
||||
ticker := time.NewTicker(d.poll)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
cc := C.cdropPasteboardChangeCount()
|
||||
if cc == last {
|
||||
continue
|
||||
}
|
||||
last = cc
|
||||
|
||||
var sensitive C.int
|
||||
cstr := C.cdropPasteboardReadText(&sensitive)
|
||||
if cstr == nil {
|
||||
continue // non-text payload (image / file) — ignore
|
||||
}
|
||||
text := C.GoString(cstr)
|
||||
C.free(unsafe.Pointer(cstr))
|
||||
|
||||
select {
|
||||
case ch <- ClipboardEvent{Text: text, Sensitive: sensitive != 0}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (d *darwinClipboard) Write(text string) error {
|
||||
c := C.CString(text)
|
||||
defer C.free(unsafe.Pointer(c))
|
||||
C.cdropPasteboardWriteText(c)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
// cdropPasteboardChangeCount returns the general pasteboard's monotonic change
|
||||
// counter. Polling it is the cheap way to detect a copy without a callback API.
|
||||
long cdropPasteboardChangeCount(void) {
|
||||
return (long)[[NSPasteboard generalPasteboard] changeCount];
|
||||
}
|
||||
|
||||
// cdropPasteboardReadText returns a malloc'd UTF-8 copy of the pasteboard's
|
||||
// plain-text payload (caller frees), or NULL when there is no text (image /
|
||||
// file only). It sets *sensitive to 1 when a privacy marker type is present —
|
||||
// org.nspasteboard.{Concealed,Transient,AutoGenerated}Type — so the upload
|
||||
// policy can skip it. (Most password managers set no marker; this is a
|
||||
// best-effort secondary guard behind the server's short TTL.)
|
||||
char *cdropPasteboardReadText(int *sensitive) {
|
||||
NSPasteboard *pb = [NSPasteboard generalPasteboard];
|
||||
*sensitive = 0;
|
||||
for (NSString *type in pb.types) {
|
||||
if ([type isEqualToString:@"org.nspasteboard.ConcealedType"] ||
|
||||
[type isEqualToString:@"org.nspasteboard.TransientType"] ||
|
||||
[type isEqualToString:@"org.nspasteboard.AutoGeneratedType"]) {
|
||||
*sensitive = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
NSString *s = [pb stringForType:NSPasteboardTypeString];
|
||||
if (s == nil) { return NULL; }
|
||||
const char *utf8 = [s UTF8String];
|
||||
if (utf8 == NULL) { return NULL; }
|
||||
return strdup(utf8);
|
||||
}
|
||||
|
||||
// cdropPasteboardWriteText replaces the pasteboard's contents with plain text.
|
||||
void cdropPasteboardWriteText(const char *text) {
|
||||
NSString *s = [NSString stringWithUTF8String:text];
|
||||
if (s == nil) { return; }
|
||||
NSPasteboard *pb = [NSPasteboard generalPasteboard];
|
||||
[pb clearContents];
|
||||
[pb setString:s forType:NSPasteboardTypeString];
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package platform
|
||||
|
||||
import "context"
|
||||
|
||||
// noopClipboard is the non-darwin Source: it never emits and silently drops
|
||||
// writes. Windows/Linux native clipboard backends land when those platforms are
|
||||
// targeted; until then the rest of the sync wiring compiles and stays inert.
|
||||
type noopClipboard struct{}
|
||||
|
||||
// NewClipboardSource returns an inert Source outside macOS.
|
||||
func NewClipboardSource() Source { return noopClipboard{} }
|
||||
|
||||
func (noopClipboard) Watch(ctx context.Context) <-chan ClipboardEvent {
|
||||
ch := make(chan ClipboardEvent)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
close(ch)
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (noopClipboard) Write(string) error { return nil }
|
||||
@@ -0,0 +1,199 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// collect records everything the monitor decides to upload.
|
||||
func newCollector() (*[]string, Sink) {
|
||||
var got []string
|
||||
return &got, func(text string) { got = append(got, text) }
|
||||
}
|
||||
|
||||
func TestMonitor_UploadsNewText(t *testing.T) {
|
||||
got, sink := newCollector()
|
||||
m := NewMonitor(sink)
|
||||
|
||||
if !m.Observe(ClipboardEvent{Text: "hello"}) {
|
||||
t.Error("first new text should be accepted")
|
||||
}
|
||||
if len(*got) != 1 || (*got)[0] != "hello" {
|
||||
t.Errorf("uploads = %v, want [hello]", *got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitor_DropsSensitive(t *testing.T) {
|
||||
got, sink := newCollector()
|
||||
m := NewMonitor(sink)
|
||||
|
||||
if m.Observe(ClipboardEvent{Text: "hunter2", Sensitive: true}) {
|
||||
t.Error("sensitive content must not be accepted")
|
||||
}
|
||||
if len(*got) != 0 {
|
||||
t.Errorf("uploads = %v, want none", *got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitor_DropsEmpty(t *testing.T) {
|
||||
got, sink := newCollector()
|
||||
m := NewMonitor(sink)
|
||||
if m.Observe(ClipboardEvent{Text: ""}) {
|
||||
t.Error("empty text must not be accepted")
|
||||
}
|
||||
if len(*got) != 0 {
|
||||
t.Errorf("uploads = %v, want none", *got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitor_DedupsRepeats(t *testing.T) {
|
||||
got, sink := newCollector()
|
||||
m := NewMonitor(sink)
|
||||
|
||||
m.Observe(ClipboardEvent{Text: "same"})
|
||||
if m.Observe(ClipboardEvent{Text: "same"}) {
|
||||
t.Error("identical repeat should be dropped")
|
||||
}
|
||||
if len(*got) != 1 {
|
||||
t.Errorf("uploads = %v, want one", *got)
|
||||
}
|
||||
// a different value re-arms uploads
|
||||
if !m.Observe(ClipboardEvent{Text: "other"}) {
|
||||
t.Error("changed text should be accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitor_LoopGuardSuppressesOwnEcho(t *testing.T) {
|
||||
got, sink := newCollector()
|
||||
m := NewMonitor(sink)
|
||||
|
||||
// We write "from-cloud" locally, then the OS reports that same change.
|
||||
m.WillWrite("from-cloud")
|
||||
if m.Observe(ClipboardEvent{Text: "from-cloud"}) {
|
||||
t.Error("our own write echo must not be re-uploaded")
|
||||
}
|
||||
if len(*got) != 0 {
|
||||
t.Errorf("uploads = %v, want none (echo suppressed)", *got)
|
||||
}
|
||||
|
||||
// A genuinely new copy afterwards still uploads.
|
||||
if !m.Observe(ClipboardEvent{Text: "typed-by-user"}) {
|
||||
t.Error("subsequent real change should be accepted")
|
||||
}
|
||||
if len(*got) != 1 || (*got)[0] != "typed-by-user" {
|
||||
t.Errorf("uploads = %v, want [typed-by-user]", *got)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeSource feeds a fixed list of events into Watch then closes.
|
||||
type fakeSource struct {
|
||||
events []ClipboardEvent
|
||||
}
|
||||
|
||||
func (f *fakeSource) Watch(ctx context.Context) <-chan ClipboardEvent {
|
||||
ch := make(chan ClipboardEvent)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
for _, ev := range f.events {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case ch <- ev:
|
||||
}
|
||||
}
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (f *fakeSource) Write(string) error { return nil }
|
||||
|
||||
func TestRun_DrivesSourceThroughMonitor(t *testing.T) {
|
||||
got, sink := newCollector()
|
||||
m := NewMonitor(sink)
|
||||
src := &fakeSource{events: []ClipboardEvent{
|
||||
{Text: "a"},
|
||||
{Text: "secret", Sensitive: true},
|
||||
{Text: "a"}, // dedup
|
||||
{Text: "b"},
|
||||
}}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
Run(ctx, src, m)
|
||||
|
||||
want := []string{"a", "b"}
|
||||
if len(*got) != len(want) || (*got)[0] != "a" || (*got)[1] != "b" {
|
||||
t.Errorf("uploads = %v, want %v", *got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// recordingSource records Write calls; Watch is unused for the download tests.
|
||||
type recordingSource struct {
|
||||
writes []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *recordingSource) Watch(context.Context) <-chan ClipboardEvent {
|
||||
ch := make(chan ClipboardEvent)
|
||||
close(ch)
|
||||
return ch
|
||||
}
|
||||
|
||||
func (r *recordingSource) Write(text string) error {
|
||||
if r.err != nil {
|
||||
return r.err
|
||||
}
|
||||
r.writes = append(r.writes, text)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestApplyRemote_WritesNewContent(t *testing.T) {
|
||||
src := &recordingSource{}
|
||||
m := NewMonitor(func(string) {})
|
||||
if !m.ApplyRemote(src, ClipboardContent{Content: "hi", SourceDevice: "other"}, "me") {
|
||||
t.Error("new remote content should be written locally")
|
||||
}
|
||||
if len(src.writes) != 1 || src.writes[0] != "hi" {
|
||||
t.Errorf("writes = %v, want [hi]", src.writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRemote_SkipsSelfSource(t *testing.T) {
|
||||
src := &recordingSource{}
|
||||
m := NewMonitor(func(string) {})
|
||||
if m.ApplyRemote(src, ClipboardContent{Content: "hi", SourceDevice: "me"}, "me") {
|
||||
t.Error("our own echoed upload must be skipped")
|
||||
}
|
||||
if len(src.writes) != 0 {
|
||||
t.Errorf("writes = %v, want none", src.writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRemote_SkipsEmptyAndCurrent(t *testing.T) {
|
||||
src := &recordingSource{}
|
||||
m := NewMonitor(func(string) {})
|
||||
if m.ApplyRemote(src, ClipboardContent{Content: "", SourceDevice: "other"}, "me") {
|
||||
t.Error("empty remote content must be skipped")
|
||||
}
|
||||
m.Observe(ClipboardEvent{Text: "X"}) // lastHash now = hash(X)
|
||||
if m.ApplyRemote(src, ClipboardContent{Content: "X", SourceDevice: "other"}, "me") {
|
||||
t.Error("content equal to current must be skipped")
|
||||
}
|
||||
}
|
||||
|
||||
// The full loop guard across both directions: apply a remote change, then the
|
||||
// Source's resulting local change event must not be re-uploaded.
|
||||
func TestApplyRemote_LoopGuardAcrossObserve(t *testing.T) {
|
||||
src := &recordingSource{}
|
||||
var uploads []string
|
||||
m := NewMonitor(func(text string) { uploads = append(uploads, text) })
|
||||
|
||||
m.ApplyRemote(src, ClipboardContent{Content: "cloud", SourceDevice: "other"}, "me")
|
||||
if m.Observe(ClipboardEvent{Text: "cloud"}) {
|
||||
t.Error("echo of an applied-remote write must be suppressed")
|
||||
}
|
||||
if len(uploads) != 0 {
|
||||
t.Errorf("uploads = %v, want none", uploads)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//go:build windows
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"golang.design/x/clipboard"
|
||||
)
|
||||
|
||||
// windowsClipboard is the Windows Source backed by golang.design/x/clipboard
|
||||
// (pure syscall, no cgo): it watches the clipboard for local copies via
|
||||
// GetClipboardSequenceNumber polling and writes plain text back.
|
||||
type windowsClipboard struct {
|
||||
ready bool
|
||||
}
|
||||
|
||||
// NewClipboardSource returns the platform clipboard Source (Windows). If the
|
||||
// clipboard can't be initialised it degrades to an inert source so the rest of
|
||||
// the sync wiring still runs.
|
||||
func NewClipboardSource() Source {
|
||||
return &windowsClipboard{ready: clipboard.Init() == nil}
|
||||
}
|
||||
|
||||
func (c *windowsClipboard) Watch(ctx context.Context) <-chan ClipboardEvent {
|
||||
out := make(chan ClipboardEvent)
|
||||
go func() {
|
||||
defer close(out)
|
||||
if !c.ready {
|
||||
<-ctx.Done()
|
||||
return
|
||||
}
|
||||
changes := clipboard.Watch(ctx, clipboard.FmtText)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case data, ok := <-changes:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if data.Format != clipboard.FmtText || len(data.Bytes) == 0 {
|
||||
continue
|
||||
}
|
||||
// golang.design/x/clipboard surfaces only the text — the Windows
|
||||
// privacy formats (ExcludeClipboardContentFromMonitorProcessing /
|
||||
// CanUploadToCloudClipboard) aren't exposed, so Sensitive stays
|
||||
// false and the server's 5-min TTL is the backstop. A raw
|
||||
// EnumClipboardFormats check is a possible follow-up.
|
||||
select {
|
||||
case out <- ClipboardEvent{Text: string(data.Bytes)}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *windowsClipboard) Write(text string) error {
|
||||
if !c.ready {
|
||||
return nil
|
||||
}
|
||||
clipboard.Write(clipboard.FmtText, []byte(text))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FetchOAuthConfig pulls the provider coordinates from the cdrop backend's
|
||||
// /api/auth/config. The desktop reuses the same OAuth client as the web app
|
||||
// (the client_id published there) and runs its own loopback PKCE flow against
|
||||
// the provider, instead of the browser's in-page redirect + /api/auth/exchange
|
||||
// proxy. apiBase is the backend origin, e.g. https://drop.commilitia.net.
|
||||
func FetchOAuthConfig(ctx context.Context, apiBase string) (OAuthConfig, error) {
|
||||
endpoint := strings.TrimRight(apiBase, "/") + "/api/auth/config"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return OAuthConfig{}, fmt.Errorf("oauth: build config request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return OAuthConfig{}, fmt.Errorf("oauth: fetch config: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return OAuthConfig{}, fmt.Errorf("oauth: config endpoint status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var c struct {
|
||||
AuthorizeURL string `json:"authorize_url"`
|
||||
TokenURL string `json:"token_url"`
|
||||
ClientID string `json:"client_id"`
|
||||
Scopes string `json:"scopes"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&c); err != nil {
|
||||
return OAuthConfig{}, fmt.Errorf("oauth: decode config: %w", err)
|
||||
}
|
||||
|
||||
cfg := OAuthConfig{
|
||||
AuthorizeURL: c.AuthorizeURL,
|
||||
TokenURL: c.TokenURL,
|
||||
ClientID: c.ClientID,
|
||||
Scopes: c.Scopes,
|
||||
}
|
||||
if cfg.AuthorizeURL == "" || cfg.TokenURL == "" || cfg.ClientID == "" {
|
||||
return OAuthConfig{}, errors.New("oauth: backend config missing authorize_url / token_url / client_id")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFetchOAuthConfig_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/auth/config" {
|
||||
t.Errorf("path = %s, want /api/auth/config", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"auth_mode": "prod",
|
||||
"authorize_url": "https://casdoor.example/login/oauth/authorize",
|
||||
"token_url": "https://casdoor.example/api/login/oauth/access_token",
|
||||
"client_id": "web-client-id",
|
||||
"redirect_uri": "https://drop.example/oauth/callback",
|
||||
"scopes": "openid profile groups",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// trailing slash on apiBase must be tolerated
|
||||
cfg, err := FetchOAuthConfig(context.Background(), srv.URL+"/")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchOAuthConfig: %v", err)
|
||||
}
|
||||
if cfg.ClientID != "web-client-id" {
|
||||
t.Errorf("client_id = %q, want web-client-id (reused web client)", cfg.ClientID)
|
||||
}
|
||||
if cfg.TokenURL != "https://casdoor.example/api/login/oauth/access_token" {
|
||||
t.Errorf("token_url = %q", cfg.TokenURL)
|
||||
}
|
||||
if cfg.Scopes != "openid profile groups" {
|
||||
t.Errorf("scopes = %q", cfg.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchOAuthConfig_Incomplete(t *testing.T) {
|
||||
// backend missing token_url / client_id (e.g. dev mode) must be rejected.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"auth_mode": "dev",
|
||||
"authorize_url": "https://casdoor.example/login/oauth/authorize",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := FetchOAuthConfig(context.Background(), srv.URL); err == nil {
|
||||
t.Fatal("want error for incomplete backend config, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchOAuthConfig_BadStatus(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := FetchOAuthConfig(context.Background(), srv.URL); err == nil {
|
||||
t.Fatal("want error for 500 status, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SaveDownload writes a received file's bytes into dir (empty → system Downloads),
|
||||
// returning the absolute path written. The name comes from a transfer peer (U2,
|
||||
// untrusted), so it is sanitized to a bare filename before use — no path
|
||||
// components survive, so a crafted name like "../../x" cannot escape dir. On a
|
||||
// name collision a " (n)" suffix is appended rather than overwriting. After a
|
||||
// successful write the file is best-effort revealed in the OS file manager.
|
||||
//
|
||||
// If the configured dir is unwritable (deleted, no permission, read-only), the
|
||||
// write is retried into the system Downloads dir before giving up — the WebView
|
||||
// has no usable download fallback (WKWebView ignores blob a[download]), so a
|
||||
// received file must never be silently lost to a bad setting. The returned path
|
||||
// reflects where the file actually landed, so the UI can show it.
|
||||
func SaveDownload(dir, name string, data []byte) (string, error) {
|
||||
if dir == "" {
|
||||
dir = DefaultDownloadDir()
|
||||
}
|
||||
path, err := writeInto(dir, name, data)
|
||||
if err == nil {
|
||||
return path, nil
|
||||
}
|
||||
if def := DefaultDownloadDir(); def != dir {
|
||||
if p, ferr := writeInto(def, name, data); ferr == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return "", err // report the original (configured-dir) failure
|
||||
}
|
||||
|
||||
// writeInto sanitizes the name, resolves a non-colliding path under dir, writes
|
||||
// the bytes, and reveals the result. Returns the absolute path written.
|
||||
func writeInto(dir, name string, data []byte) (string, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
safe := sanitizeFileName(name)
|
||||
if safe == "" {
|
||||
safe = "cdrop-download"
|
||||
}
|
||||
target := uniquePath(dir, safe)
|
||||
if err := os.WriteFile(target, data, 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
revealInFileManager(target)
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// sanitizeFileName reduces a peer-supplied name to a single safe filename:
|
||||
// directory components are dropped (path-traversal defense), control chars are
|
||||
// removed, and characters reserved by common filesystems are replaced with "_".
|
||||
func sanitizeFileName(name string) string {
|
||||
name = filepath.Base(filepath.FromSlash(name)) // strip any directory parts
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "." || name == ".." {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r < 0x20:
|
||||
continue // control characters
|
||||
case strings.ContainsRune(`/\:*?"<>|`, r):
|
||||
b.WriteRune('_') // filesystem-reserved
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
out := strings.TrimSpace(b.String())
|
||||
if len(out) > 200 {
|
||||
out = strings.TrimSpace(out[:200])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// uniquePath returns dir/name, or dir/"name (n).ext" with the lowest n that does
|
||||
// not yet exist, so a repeated transfer never silently overwrites a prior file.
|
||||
func uniquePath(dir, name string) string {
|
||||
target := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(target); os.IsNotExist(err) {
|
||||
return target
|
||||
}
|
||||
ext := filepath.Ext(name)
|
||||
stem := strings.TrimSuffix(name, ext)
|
||||
for i := 1; ; i += 1 {
|
||||
cand := filepath.Join(dir, fmt.Sprintf("%s (%d)%s", stem, i, ext))
|
||||
if _, err := os.Stat(cand); os.IsNotExist(err) {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// revealInFileManager opens the OS file manager with the saved file selected.
|
||||
// Best-effort: the save already succeeded, so any error here is ignored.
|
||||
func revealInFileManager(path string) {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
_ = exec.Command("open", "-R", path).Start()
|
||||
case "windows":
|
||||
_ = exec.Command("explorer", "/select,"+path).Start()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// UserInfo is the display identity the WebView store needs (mirrors the web
|
||||
// app's User shape: id / name / avatar).
|
||||
type UserInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
// LoginResult is the full result Go keeps internally: tokens + identity. The
|
||||
// refresh_token never crosses into the WebView — see SessionView and the
|
||||
// credential policy in session.go.
|
||||
type LoginResult struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
User UserInfo `json:"user"`
|
||||
}
|
||||
|
||||
// SessionView is the refresh-token-free projection handed to the WebView (login
|
||||
// emit, refresh return, boot injection). The refresh_token — the long-lived
|
||||
// secret — is deliberately omitted: it lives only in the Go process and the
|
||||
// on-disk session, never in JS or the injected HTML.
|
||||
type SessionView struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
User UserInfo `json:"user"`
|
||||
}
|
||||
|
||||
// View projects a LoginResult to the refresh-free SessionView.
|
||||
func (r LoginResult) View() SessionView {
|
||||
return SessionView{AccessToken: r.AccessToken, ExpiresIn: r.ExpiresIn, User: r.User}
|
||||
}
|
||||
|
||||
// UserFromToken extracts the display identity from OIDC tokens WITHOUT verifying
|
||||
// the signature. This mirrors the backend's extractUser (internal/httpapi/auth.go)
|
||||
// exactly: prefer the id_token's richer claims, fall back to the access_token;
|
||||
// name priority preferred_username → name → email → sub; avatar avatar → picture.
|
||||
//
|
||||
// Skipping verification is safe for the same reason it is on the backend: the
|
||||
// auth middleware verifies the access_token on every API call via JWKS, so any
|
||||
// tamper surfaces there. These claims only drive local display.
|
||||
func UserFromToken(idToken, accessToken string) (UserInfo, error) {
|
||||
pick := idToken
|
||||
if pick == "" {
|
||||
pick = accessToken
|
||||
}
|
||||
claims, err := decodeJWTClaims(pick)
|
||||
if err != nil {
|
||||
return UserInfo{}, err
|
||||
}
|
||||
|
||||
u := UserInfo{ID: claimString(claims, "sub")}
|
||||
for _, k := range []string{"preferred_username", "name", "email"} {
|
||||
if v := claimString(claims, k); v != "" {
|
||||
u.Name = v
|
||||
break
|
||||
}
|
||||
}
|
||||
if u.Name == "" {
|
||||
u.Name = u.ID
|
||||
}
|
||||
for _, k := range []string{"avatar", "picture"} {
|
||||
if v := claimString(claims, k); v != "" {
|
||||
u.Avatar = v
|
||||
break
|
||||
}
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// decodeJWTClaims base64url-decodes the JWT payload segment and unmarshals it.
|
||||
// No signature check (see UserFromToken).
|
||||
func decodeJWTClaims(token string) (map[string]any, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) < 2 {
|
||||
return nil, errors.New("oauth: token is not a JWT")
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oauth: decode jwt payload: %w", err)
|
||||
}
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return nil, fmt.Errorf("oauth: parse jwt claims: %w", err)
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func claimString(m map[string]any, key string) string {
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// makeJWT builds a syntactically valid (unsigned) JWT carrying the given claims.
|
||||
// UserFromToken never verifies the signature, so a dummy header + sig suffice.
|
||||
func makeJWT(claims map[string]any) string {
|
||||
payload, _ := json.Marshal(claims)
|
||||
seg := base64.RawURLEncoding.EncodeToString(payload)
|
||||
return "eyJhbGciOiJSUzI1NiJ9." + seg + ".sig"
|
||||
}
|
||||
|
||||
func TestUserFromTokenPrefersIDToken(t *testing.T) {
|
||||
idTok := makeJWT(map[string]any{"sub": "u-1", "preferred_username": "alice"})
|
||||
accessTok := makeJWT(map[string]any{"sub": "u-1", "name": "bob"})
|
||||
|
||||
u, err := UserFromToken(idTok, accessTok)
|
||||
if err != nil {
|
||||
t.Fatalf("UserFromToken: %v", err)
|
||||
}
|
||||
if u.ID != "u-1" {
|
||||
t.Errorf("id: got %q, want u-1", u.ID)
|
||||
}
|
||||
if u.Name != "alice" {
|
||||
t.Errorf("name should come from id_token preferred_username, got %q", u.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserFromTokenNamePriority(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
claims map[string]any
|
||||
want string
|
||||
}{
|
||||
{"preferred_username wins", map[string]any{"sub": "s", "preferred_username": "p", "name": "n", "email": "e"}, "p"},
|
||||
{"name when no preferred", map[string]any{"sub": "s", "name": "n", "email": "e"}, "n"},
|
||||
{"email when no name", map[string]any{"sub": "s", "email": "e@x"}, "e@x"},
|
||||
{"sub as last resort", map[string]any{"sub": "s"}, "s"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
u, err := UserFromToken(makeJWT(c.claims), "")
|
||||
if err != nil {
|
||||
t.Fatalf("UserFromToken: %v", err)
|
||||
}
|
||||
if u.Name != c.want {
|
||||
t.Errorf("name: got %q, want %q", u.Name, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserFromTokenAvatarPriority(t *testing.T) {
|
||||
u, _ := UserFromToken(makeJWT(map[string]any{
|
||||
"sub": "s", "avatar": "a.png", "picture": "p.png",
|
||||
}), "")
|
||||
if u.Avatar != "a.png" {
|
||||
t.Errorf("avatar should prefer 'avatar' claim, got %q", u.Avatar)
|
||||
}
|
||||
|
||||
u2, _ := UserFromToken(makeJWT(map[string]any{"sub": "s", "picture": "p.png"}), "")
|
||||
if u2.Avatar != "p.png" {
|
||||
t.Errorf("avatar should fall back to 'picture', got %q", u2.Avatar)
|
||||
}
|
||||
|
||||
u3, _ := UserFromToken(makeJWT(map[string]any{"sub": "s"}), "")
|
||||
if u3.Avatar != "" {
|
||||
t.Errorf("avatar should be empty when no claim, got %q", u3.Avatar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserFromTokenFallsBackToAccessToken(t *testing.T) {
|
||||
accessTok := makeJWT(map[string]any{"sub": "u-9", "name": "carol"})
|
||||
u, err := UserFromToken("", accessTok)
|
||||
if err != nil {
|
||||
t.Fatalf("UserFromToken: %v", err)
|
||||
}
|
||||
if u.ID != "u-9" || u.Name != "carol" {
|
||||
t.Errorf("fallback to access_token failed: %+v", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserFromTokenRejectsGarbage(t *testing.T) {
|
||||
if _, err := UserFromToken("not-a-jwt", ""); err == nil {
|
||||
t.Error("expected error for non-JWT token")
|
||||
}
|
||||
if _, err := UserFromToken("a.!!!notbase64!!!.c", ""); err == nil {
|
||||
t.Error("expected error for undecodable payload")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//go:build darwin
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// launchAgentLabel is the reverse-DNS label + plist filename for the per-user
|
||||
// LaunchAgent that starts cdrop at login.
|
||||
const launchAgentLabel = "net.commilitia.cdrop"
|
||||
|
||||
func launchAgentPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, "Library", "LaunchAgents", launchAgentLabel+".plist"), nil
|
||||
}
|
||||
|
||||
// launchTarget returns the program the LaunchAgent should run. For a packaged
|
||||
// .app it launches the bundle via `open` (proper LaunchServices activation);
|
||||
// in a bare dev binary it runs the executable directly. Either way it appends
|
||||
// --hidden so a login launch comes up to the menu bar with no window (see
|
||||
// launchedHidden in main); `open --args` forwards the rest as the app's argv.
|
||||
func launchTarget() ([]string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
|
||||
exe = resolved
|
||||
}
|
||||
if bundle := appBundlePath(exe); bundle != "" {
|
||||
return []string{"/usr/bin/open", bundle, "--args", "--hidden"}, nil
|
||||
}
|
||||
return []string{exe, "--hidden"}, nil
|
||||
}
|
||||
|
||||
// appBundlePath returns the enclosing .app path if exe lives inside one
|
||||
// (…/Foo.app/Contents/MacOS/binary), else "".
|
||||
func appBundlePath(exe string) string {
|
||||
const marker = ".app/Contents/MacOS/"
|
||||
if i := strings.LastIndex(exe, marker); i != -1 {
|
||||
return exe[:i+len(".app")]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SetLaunchAtLogin installs or removes the LaunchAgent plist. Removing a missing
|
||||
// agent is a no-op success.
|
||||
func SetLaunchAtLogin(enabled bool) error {
|
||||
p, err := launchAgentPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !enabled {
|
||||
if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
args, err := launchTarget()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(p, []byte(buildLaunchAgentPlist(launchAgentLabel, args)), 0o644)
|
||||
}
|
||||
|
||||
// IsLaunchAtLoginEnabled reports whether the LaunchAgent plist is present.
|
||||
func IsLaunchAtLoginEnabled() bool {
|
||||
p, err := launchAgentPath()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, err = os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func buildLaunchAgentPlist(label string, programArgs []string) string {
|
||||
var args strings.Builder
|
||||
for _, a := range programArgs {
|
||||
fmt.Fprintf(&args, "\n <string>%s</string>", xmlEscape(a))
|
||||
}
|
||||
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>%s</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>%s
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
`, xmlEscape(label), args.String())
|
||||
}
|
||||
|
||||
func xmlEscape(s string) string {
|
||||
r := strings.NewReplacer(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
`"`, """,
|
||||
"'", "'",
|
||||
)
|
||||
return r.Replace(s)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//go:build darwin
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetLaunchAtLoginInstallsAndRemoves(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
if IsLaunchAtLoginEnabled() {
|
||||
t.Fatal("should start disabled")
|
||||
}
|
||||
|
||||
if err := SetLaunchAtLogin(true); err != nil {
|
||||
t.Fatalf("enable: %v", err)
|
||||
}
|
||||
if !IsLaunchAtLoginEnabled() {
|
||||
t.Error("should be enabled after install")
|
||||
}
|
||||
|
||||
p, _ := launchAgentPath()
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatalf("read plist: %v", err)
|
||||
}
|
||||
plist := string(data)
|
||||
if !strings.Contains(plist, "<key>Label</key>") ||
|
||||
!strings.Contains(plist, launchAgentLabel) ||
|
||||
!strings.Contains(plist, "<key>RunAtLoad</key>") {
|
||||
t.Errorf("plist missing expected keys:\n%s", plist)
|
||||
}
|
||||
|
||||
// Disabling removes the file; removing again is a no-op success.
|
||||
if err := SetLaunchAtLogin(false); err != nil {
|
||||
t.Fatalf("disable: %v", err)
|
||||
}
|
||||
if IsLaunchAtLoginEnabled() {
|
||||
t.Error("should be disabled after removal")
|
||||
}
|
||||
if err := SetLaunchAtLogin(false); err != nil {
|
||||
t.Errorf("removing an absent agent should be a no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppBundlePath(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/Apps/cdrop.app/Contents/MacOS/desktop": "/Apps/cdrop.app",
|
||||
"/usr/local/bin/desktop": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := appBundlePath(in); got != want {
|
||||
t.Errorf("appBundlePath(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestXMLEscape(t *testing.T) {
|
||||
got := xmlEscape(`a&b<c>"d"`)
|
||||
if !strings.Contains(got, "&") || !strings.Contains(got, "<") || !strings.Contains(got, """) {
|
||||
t.Errorf("xmlEscape did not escape specials: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package platform
|
||||
|
||||
// Launch-at-login on Windows/Linux uses different mechanisms (registry Run key /
|
||||
// XDG autostart .desktop) and lands with those platform targets. Stubbed so the
|
||||
// settings wiring compiles everywhere.
|
||||
|
||||
// SetLaunchAtLogin is a no-op outside macOS.
|
||||
func SetLaunchAtLogin(bool) error { return nil }
|
||||
|
||||
// IsLaunchAtLoginEnabled always reports false outside macOS.
|
||||
func IsLaunchAtLoginEnabled() bool { return false }
|
||||
@@ -0,0 +1,53 @@
|
||||
//go:build windows
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
const (
|
||||
// runKeyPath is the per-user autostart key — entries here launch at sign-in
|
||||
// without elevation (HKLM would need admin).
|
||||
runKeyPath = `Software\Microsoft\Windows\CurrentVersion\Run`
|
||||
runValueName = "Commilitia Drop"
|
||||
)
|
||||
|
||||
// SetLaunchAtLogin adds or removes the per-user Run registry entry that starts
|
||||
// the app at sign-in. The value is the quoted executable path (so a Program
|
||||
// Files path with spaces stays one argument) followed by --hidden, so a login
|
||||
// launch comes up to the tray with no window (see launchedHidden in main).
|
||||
func SetLaunchAtLogin(enabled bool) error {
|
||||
k, _, err := registry.CreateKey(registry.CURRENT_USER, runKeyPath, registry.SET_VALUE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer k.Close()
|
||||
|
||||
if !enabled {
|
||||
if err := k.DeleteValue(runValueName); err != nil && !errors.Is(err, registry.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return k.SetStringValue(runValueName, `"`+exe+`" --hidden`)
|
||||
}
|
||||
|
||||
// IsLaunchAtLoginEnabled reports whether the Run entry is present.
|
||||
func IsLaunchAtLoginEnabled() bool {
|
||||
k, err := registry.OpenKey(registry.CURRENT_USER, runKeyPath, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer k.Close()
|
||||
_, _, err = k.GetStringValue(runValueName)
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// StartLocalAPIServer runs the /api reverse proxy on a loopback HTTP listener and
|
||||
// returns its base URL (e.g. http://127.0.0.1:54321).
|
||||
//
|
||||
// Why: on Windows, Wails' custom-scheme AssetServer buffers a handler's response
|
||||
// until the handler RETURNS, so a long-lived SSE (/api/hub/events, whose handler
|
||||
// never returns) never reaches the WebView — the device list never loads and the
|
||||
// status sticks on "connecting". A real loopback HTTP origin is served by
|
||||
// WebView2's normal network stack, which streams response bodies incrementally.
|
||||
//
|
||||
// The page reaches this via the injected api_base; since its origin differs from
|
||||
// the listener, CORS is added. No credentials/cookies are used (auth is a Bearer
|
||||
// header), so a permissive policy is safe. macOS (WKWebView) streams the custom
|
||||
// scheme fine and doesn't use this.
|
||||
func StartLocalAPIServer(apiBase string) (string, error) {
|
||||
proxy, err := NewAPIProxy(apiBase)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("local api: listen: %w", err)
|
||||
}
|
||||
srv := &http.Server{Handler: corsWrap(proxy)}
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
return "http://" + ln.Addr().String(), nil
|
||||
}
|
||||
|
||||
// corsWrap adds permissive CORS so the WebView page (a different origin than this
|
||||
// loopback listener) can fetch /api, and answers the preflight. Includes the
|
||||
// Private Network Access ack since the page origin is also loopback.
|
||||
func corsWrap(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
origin = "*"
|
||||
}
|
||||
h := w.Header()
|
||||
h.Set("Access-Control-Allow-Origin", origin)
|
||||
h.Add("Vary", "Origin")
|
||||
h.Add("Vary", "Access-Control-Request-Headers")
|
||||
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
// Echo the requested headers so every client header is allowed without
|
||||
// maintaining a list — the app sends Authorization, X-Device-Name,
|
||||
// X-Device-Type, and conditional headers like If-None-Match (clipboard
|
||||
// caching). Missing one fails the preflight as a "Failed to fetch".
|
||||
if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" {
|
||||
h.Set("Access-Control-Allow-Headers", reqHeaders)
|
||||
} else {
|
||||
h.Set("Access-Control-Allow-Headers",
|
||||
"Authorization, Content-Type, X-Device-Name, X-Device-Type, X-Dev-User, If-None-Match")
|
||||
}
|
||||
h.Set("Access-Control-Allow-Private-Network", "true")
|
||||
h.Set("Access-Control-Max-Age", "600")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
@@ -0,0 +1,264 @@
|
||||
// Package platform implements cdrop desktop's native capabilities — the Go
|
||||
// shell that the shared web UI drives over Wails bindings.
|
||||
//
|
||||
// This file owns the OAuth login flow: an RFC 8252 loopback redirect against
|
||||
// Casdoor with PKCE as a public client (no secret). The authorization code
|
||||
// exchange happens here in Go on purpose, so the PKCE verifier and the
|
||||
// resulting tokens never enter the WebView / JS context.
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OAuthConfig carries the provider coordinates. For Casdoor the token endpoint
|
||||
// is /api/login/oauth/access_token (non-standard) and authorize is
|
||||
// /login/oauth/authorize; the desktop registers as its own public client.
|
||||
type OAuthConfig struct {
|
||||
AuthorizeURL string
|
||||
TokenURL string
|
||||
ClientID string
|
||||
Scopes string // space-separated; empty defaults to "openid profile groups"
|
||||
}
|
||||
|
||||
// TokenResult is the subset of the token response the shell keeps. IDToken is
|
||||
// captured so the identity can be decoded from the richer id_token claims (it's
|
||||
// present when the openid scope is granted — our default scopes include it).
|
||||
type TokenResult struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
|
||||
// Flow runs one interactive login. openURL is injected so production uses
|
||||
// Wails' runtime.BrowserOpenURL while tests substitute a fake user-agent.
|
||||
type Flow struct {
|
||||
cfg OAuthConfig
|
||||
openURL func(string)
|
||||
client *http.Client
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// NewFlow builds a Flow with sane defaults: a 30s HTTP client for the token
|
||||
// exchange and a 3-minute ceiling on the whole interactive login.
|
||||
func NewFlow(cfg OAuthConfig, openURL func(string)) *Flow {
|
||||
return &Flow{
|
||||
cfg: cfg,
|
||||
openURL: openURL,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
timeout: 3 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// Login performs the loopback PKCE flow and returns the exchanged tokens.
|
||||
//
|
||||
// Steps: generate verifier/challenge/state → bind an ephemeral 127.0.0.1
|
||||
// listener → open the system browser at the authorize URL → wait for the
|
||||
// browser to hit the loopback /callback with the code → validate state →
|
||||
// exchange code+verifier at the token endpoint. The verifier never leaves Go.
|
||||
func (f *Flow) Login(ctx context.Context) (*TokenResult, error) {
|
||||
if f.cfg.AuthorizeURL == "" || f.cfg.TokenURL == "" || f.cfg.ClientID == "" {
|
||||
return nil, errors.New("oauth: incomplete config (authorize_url / token_url / client_id)")
|
||||
}
|
||||
|
||||
verifier, challenge, err := newPKCE()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oauth: pkce: %w", err)
|
||||
}
|
||||
state, err := randString(24)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oauth: state: %w", err)
|
||||
}
|
||||
|
||||
// RFC 8252 §7.3: bind the IPv4 loopback literal (not "localhost") on an
|
||||
// OS-assigned ephemeral port.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oauth: loopback listen: %w", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", ln.Addr().(*net.TCPAddr).Port)
|
||||
|
||||
type cbResult struct {
|
||||
code string
|
||||
err error
|
||||
}
|
||||
resCh := make(chan cbResult, 1)
|
||||
send := func(r cbResult) {
|
||||
// non-blocking: only the first callback wins, later ones are dropped.
|
||||
select {
|
||||
case resCh <- r:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
if e := q.Get("error"); e != "" {
|
||||
writeClosePage(w, false)
|
||||
send(cbResult{err: fmt.Errorf("oauth: authorization denied: %s", e)})
|
||||
return
|
||||
}
|
||||
if q.Get("state") != state {
|
||||
writeClosePage(w, false)
|
||||
send(cbResult{err: errors.New("oauth: state mismatch (possible CSRF)")})
|
||||
return
|
||||
}
|
||||
code := q.Get("code")
|
||||
if code == "" {
|
||||
writeClosePage(w, false)
|
||||
send(cbResult{err: errors.New("oauth: callback missing code")})
|
||||
return
|
||||
}
|
||||
writeClosePage(w, true)
|
||||
send(cbResult{code: code})
|
||||
})
|
||||
|
||||
srv := &http.Server{Handler: mux}
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
defer srv.Close()
|
||||
|
||||
authURL := f.cfg.AuthorizeURL + "?" + url.Values{
|
||||
"response_type": {"code"},
|
||||
"client_id": {f.cfg.ClientID},
|
||||
"redirect_uri": {redirectURI},
|
||||
"scope": {f.scopes()},
|
||||
"state": {state},
|
||||
"code_challenge": {challenge},
|
||||
"code_challenge_method": {"S256"},
|
||||
}.Encode()
|
||||
f.openURL(authURL)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(f.timeout):
|
||||
return nil, errors.New("oauth: login timed out")
|
||||
case res := <-resCh:
|
||||
if res.err != nil {
|
||||
return nil, res.err
|
||||
}
|
||||
return f.exchange(ctx, res.code, verifier, redirectURI)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Flow) scopes() string {
|
||||
if s := strings.TrimSpace(f.cfg.Scopes); s != "" {
|
||||
return s
|
||||
}
|
||||
return "openid profile groups"
|
||||
}
|
||||
|
||||
// exchange trades the authorization code + PKCE verifier for tokens. Casdoor
|
||||
// accepts form-encoded public-client requests (no client_secret). authorize and
|
||||
// token must carry the identical redirect_uri, so we pass the same value.
|
||||
func (f *Flow) exchange(ctx context.Context, code, verifier, redirectURI string) (*TokenResult, error) {
|
||||
return f.postToken(ctx, url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"code_verifier": {verifier},
|
||||
"client_id": {f.cfg.ClientID},
|
||||
"redirect_uri": {redirectURI},
|
||||
})
|
||||
}
|
||||
|
||||
// Refresh exchanges a refresh_token for a fresh token (public client, no
|
||||
// secret). Call it when the access token nears expiry; the access token never
|
||||
// leaves Go, so refresh is driven from the shell, not the WebView.
|
||||
func (f *Flow) Refresh(ctx context.Context, refreshToken string) (*TokenResult, error) {
|
||||
if f.cfg.TokenURL == "" || f.cfg.ClientID == "" {
|
||||
return nil, errors.New("oauth: incomplete config (token_url / client_id)")
|
||||
}
|
||||
if refreshToken == "" {
|
||||
return nil, errors.New("oauth: empty refresh token")
|
||||
}
|
||||
return f.postToken(ctx, url.Values{
|
||||
"grant_type": {"refresh_token"},
|
||||
"refresh_token": {refreshToken},
|
||||
"client_id": {f.cfg.ClientID},
|
||||
"scope": {f.scopes()},
|
||||
})
|
||||
}
|
||||
|
||||
// postToken POSTs a form-encoded request to the token endpoint and decodes the
|
||||
// response. Shared by the authorization-code exchange and refresh.
|
||||
func (f *Flow) postToken(ctx context.Context, form url.Values) (*TokenResult, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, f.cfg.TokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oauth: build token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := f.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oauth: token request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("oauth: token endpoint status %d", resp.StatusCode)
|
||||
}
|
||||
var tok TokenResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil {
|
||||
return nil, fmt.Errorf("oauth: decode token: %w", err)
|
||||
}
|
||||
if tok.AccessToken == "" {
|
||||
return nil, errors.New("oauth: token response missing access_token")
|
||||
}
|
||||
return &tok, nil
|
||||
}
|
||||
|
||||
// --- PKCE (RFC 7636) ---
|
||||
|
||||
func newPKCE() (verifier, challenge string, err error) {
|
||||
verifier, err = randString(64) // 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)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewPKCE(t *testing.T) {
|
||||
v, c, err := newPKCE()
|
||||
if err != nil {
|
||||
t.Fatalf("newPKCE: %v", err)
|
||||
}
|
||||
if len(v) != 64 {
|
||||
t.Errorf("verifier length = %d, want 64", len(v))
|
||||
}
|
||||
sum := sha256.Sum256([]byte(v))
|
||||
want := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
if c != want {
|
||||
t.Errorf("challenge = %q, want S256(verifier) = %q", c, want)
|
||||
}
|
||||
// two calls must differ
|
||||
v2, _, _ := newPKCE()
|
||||
if v == v2 {
|
||||
t.Error("two verifiers collided")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogin_Success drives the whole loopback flow with a fake Casdoor token
|
||||
// endpoint and a fake browser, with no real network or prod dependency.
|
||||
func TestLogin_Success(t *testing.T) {
|
||||
var gotForm url.Values
|
||||
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Errorf("token endpoint ParseForm: %v", err)
|
||||
}
|
||||
gotForm = r.PostForm
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "at-123",
|
||||
"refresh_token": "rt-456",
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
}))
|
||||
defer tokenSrv.Close()
|
||||
|
||||
cfg := OAuthConfig{
|
||||
AuthorizeURL: "https://casdoor.example/login/oauth/authorize",
|
||||
TokenURL: tokenSrv.URL,
|
||||
ClientID: "cdrop-desktop",
|
||||
}
|
||||
|
||||
// Fake browser: parse the authorize URL, assert it carries PKCE, then GET
|
||||
// the loopback redirect with a code + the same state (authorized).
|
||||
openURL := func(authURL string) {
|
||||
u, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
t.Errorf("parse authURL: %v", err)
|
||||
return
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" {
|
||||
t.Errorf("authorize request missing PKCE challenge: %v", q)
|
||||
}
|
||||
if q.Get("client_id") != "cdrop-desktop" {
|
||||
t.Errorf("authorize client_id = %q", q.Get("client_id"))
|
||||
}
|
||||
cb := q.Get("redirect_uri") + "?code=auth-code-xyz&state=" + url.QueryEscape(q.Get("state"))
|
||||
resp, err := http.Get(cb)
|
||||
if err != nil {
|
||||
t.Errorf("callback GET: %v", err)
|
||||
return
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
tok, err := NewFlow(cfg, openURL).Login(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Login: %v", err)
|
||||
}
|
||||
if tok.AccessToken != "at-123" {
|
||||
t.Errorf("access_token = %q, want at-123", tok.AccessToken)
|
||||
}
|
||||
if tok.RefreshToken != "rt-456" {
|
||||
t.Errorf("refresh_token = %q, want rt-456", tok.RefreshToken)
|
||||
}
|
||||
if tok.ExpiresIn != 3600 {
|
||||
t.Errorf("expires_in = %d, want 3600", tok.ExpiresIn)
|
||||
}
|
||||
|
||||
// The exchange must carry the code + PKCE verifier and, as a public client,
|
||||
// no client_secret.
|
||||
if gotForm.Get("grant_type") != "authorization_code" {
|
||||
t.Errorf("grant_type = %q", gotForm.Get("grant_type"))
|
||||
}
|
||||
if gotForm.Get("code") != "auth-code-xyz" {
|
||||
t.Errorf("code = %q", gotForm.Get("code"))
|
||||
}
|
||||
if gotForm.Get("code_verifier") == "" {
|
||||
t.Error("token exchange missing code_verifier")
|
||||
}
|
||||
if gotForm.Get("client_secret") != "" {
|
||||
t.Error("public client must not send client_secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogin_StateMismatch(t *testing.T) {
|
||||
cfg := OAuthConfig{
|
||||
AuthorizeURL: "https://casdoor.example/login/oauth/authorize",
|
||||
TokenURL: "https://casdoor.example/token",
|
||||
ClientID: "cdrop-desktop",
|
||||
}
|
||||
openURL := func(authURL string) {
|
||||
u, _ := url.Parse(authURL)
|
||||
redirect := u.Query().Get("redirect_uri")
|
||||
resp, err := http.Get(redirect + "?code=c&state=WRONG-STATE")
|
||||
if err == nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
}
|
||||
_, err := NewFlow(cfg, openURL).Login(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "state mismatch") {
|
||||
t.Fatalf("want state mismatch error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogin_IncompleteConfig(t *testing.T) {
|
||||
_, err := NewFlow(OAuthConfig{ClientID: "only-id"}, func(string) {}).Login(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("want error for incomplete config, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresh_Success(t *testing.T) {
|
||||
var gotForm url.Values
|
||||
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
gotForm = r.PostForm
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "new-at",
|
||||
"refresh_token": "new-rt",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
}))
|
||||
defer tokenSrv.Close()
|
||||
|
||||
cfg := OAuthConfig{
|
||||
AuthorizeURL: "https://casdoor.example/login/oauth/authorize",
|
||||
TokenURL: tokenSrv.URL,
|
||||
ClientID: "cdrop-desktop",
|
||||
}
|
||||
tok, err := NewFlow(cfg, func(string) {}).Refresh(context.Background(), "old-rt")
|
||||
if err != nil {
|
||||
t.Fatalf("Refresh: %v", err)
|
||||
}
|
||||
if tok.AccessToken != "new-at" {
|
||||
t.Errorf("access_token = %q, want new-at", tok.AccessToken)
|
||||
}
|
||||
if gotForm.Get("grant_type") != "refresh_token" {
|
||||
t.Errorf("grant_type = %q", gotForm.Get("grant_type"))
|
||||
}
|
||||
if gotForm.Get("refresh_token") != "old-rt" {
|
||||
t.Errorf("refresh_token = %q", gotForm.Get("refresh_token"))
|
||||
}
|
||||
if gotForm.Get("client_secret") != "" {
|
||||
t.Error("public client must not send client_secret on refresh")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresh_EmptyToken(t *testing.T) {
|
||||
cfg := OAuthConfig{
|
||||
AuthorizeURL: "https://x/authorize",
|
||||
TokenURL: "https://x/token",
|
||||
ClientID: "c",
|
||||
}
|
||||
if _, err := NewFlow(cfg, func(string) {}).Refresh(context.Background(), ""); err == nil {
|
||||
t.Fatal("want error for empty refresh token")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NewAPIProxy returns an http.Handler suitable for Wails' AssetServer.Handler.
|
||||
// It reverse-proxies every /api/* request to the remote cdrop backend so the
|
||||
// embedded web UI keeps using same-origin relative URLs — no CORS, no API-base
|
||||
// rewrite, no backend change.
|
||||
//
|
||||
// Why a proxy instead of cross-origin fetch: the WebView serves the bundle from
|
||||
// a custom scheme, so a relative /api request never reaches the backend, and a
|
||||
// cross-origin fetch to https://drop.commilitia.net would need server-side CORS
|
||||
// the backend doesn't send. Proxying keeps the request same-origin end to end.
|
||||
//
|
||||
// SSE (/api/hub/events) streams correctly: FlushInterval=-1 flushes each chunk,
|
||||
// and the darwin WebView ResponseWriter forwards every Write to the
|
||||
// WKURLSchemeTask immediately (didReceiveData per write), so frames arrive live.
|
||||
//
|
||||
// apiBase is the backend origin, e.g. https://drop.commilitia.net. Requests
|
||||
// that are not under /api/ get a 404 — desktop navigation is client-side, so the
|
||||
// asset FS already answers "/" and deep-link misses don't happen in normal use.
|
||||
func NewAPIProxy(apiBase string) (http.Handler, error) {
|
||||
target, err := url.Parse(apiBase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("proxy: parse api base %q: %w", apiBase, err)
|
||||
}
|
||||
if target.Scheme == "" || target.Host == "" {
|
||||
return nil, fmt.Errorf("proxy: api base %q missing scheme or host", apiBase)
|
||||
}
|
||||
|
||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||
base := proxy.Director
|
||||
proxy.Director = func(r *http.Request) {
|
||||
base(r)
|
||||
// The backend is vhosted behind Caddy; the Host header drives TLS SNI
|
||||
// and request routing, so it must be the backend host, not the WebView's
|
||||
// synthetic origin.
|
||||
r.Host = target.Host
|
||||
}
|
||||
proxy.FlushInterval = -1 // immediate flush — SSE frames / streamed relay bodies
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
proxy.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}), nil
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestProxyForwardsAPIWithBackendHost(t *testing.T) {
|
||||
var gotPath, gotHost, gotAuth string
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotHost = r.Host
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, "clip-body")
|
||||
}))
|
||||
defer backend.Close()
|
||||
|
||||
proxy, err := NewAPIProxy(backend.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAPIProxy: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/clipboard", nil)
|
||||
req.Header.Set("Authorization", "Bearer tok-123")
|
||||
rec := httptest.NewRecorder()
|
||||
proxy.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want 200", rec.Code)
|
||||
}
|
||||
if gotPath != "/api/clipboard" {
|
||||
t.Errorf("backend path: got %q", gotPath)
|
||||
}
|
||||
// Host must be rewritten to the backend (TLS SNI + vhost routing), not the
|
||||
// WebView's synthetic origin.
|
||||
if gotHost != backendHost(backend.URL) {
|
||||
t.Errorf("backend Host header: got %q, want %q", gotHost, backendHost(backend.URL))
|
||||
}
|
||||
if gotAuth != "Bearer tok-123" {
|
||||
t.Errorf("Authorization not forwarded: got %q", gotAuth)
|
||||
}
|
||||
if body := rec.Body.String(); body != "clip-body" {
|
||||
t.Errorf("body: got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyNonAPIReturns404(t *testing.T) {
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
t.Error("backend should not be hit for non-/api path")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer backend.Close()
|
||||
|
||||
proxy, err := NewAPIProxy(backend.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAPIProxy: %v", err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
proxy.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/settings", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("non-/api status: got %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyForwardsPUT(t *testing.T) {
|
||||
var gotMethod, gotBody string
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod = r.Method
|
||||
buf := make([]byte, r.ContentLength)
|
||||
_, _ = r.Body.Read(buf)
|
||||
gotBody = string(buf)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer backend.Close()
|
||||
|
||||
proxy, _ := NewAPIProxy(backend.URL)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/clipboard", strings.NewReader("hello"))
|
||||
rec := httptest.NewRecorder()
|
||||
proxy.ServeHTTP(rec, req)
|
||||
|
||||
if gotMethod != http.MethodPut {
|
||||
t.Errorf("method: got %q", gotMethod)
|
||||
}
|
||||
if gotBody != "hello" {
|
||||
t.Errorf("body: got %q", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyStreamsSSE checks that the proxy relays an event-stream incrementally
|
||||
// rather than buffering until the backend closes the connection. The backend
|
||||
// holds the connection open after the first frame; the client must see that
|
||||
// frame before the deadline.
|
||||
func TestProxyStreamsSSE(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, "event: clipboard:update\ndata: {}\n\n")
|
||||
w.(http.Flusher).Flush()
|
||||
<-release // keep the stream open like a real SSE channel
|
||||
}))
|
||||
defer backend.Close()
|
||||
defer close(release)
|
||||
|
||||
proxy, _ := NewAPIProxy(backend.URL)
|
||||
srv := httptest.NewServer(proxy)
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/api/hub/events", nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET sse: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
line, _ := bufio.NewReader(resp.Body).ReadString('\n')
|
||||
done <- line
|
||||
}()
|
||||
|
||||
select {
|
||||
case line := <-done:
|
||||
if !strings.Contains(line, "clipboard:update") {
|
||||
t.Errorf("first streamed frame: got %q", line)
|
||||
}
|
||||
case <-time.After(1500 * time.Millisecond):
|
||||
t.Fatal("SSE frame did not arrive incrementally (proxy buffered the stream)")
|
||||
}
|
||||
}
|
||||
|
||||
func backendHost(rawURL string) string {
|
||||
// httptest URLs are http://host:port — strip the scheme.
|
||||
return strings.TrimPrefix(rawURL, "http://")
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
keyring "github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
// Session persistence lives in a Go-owned file, NOT WebView storage: Wails serves
|
||||
// the UI from the wails:// custom scheme, and WKWebView does not persist
|
||||
// localStorage / sessionStorage for custom-scheme origins, so any in-page store
|
||||
// is wiped on every restart. The persisted session is injected back into the
|
||||
// page at load time (see SessionInjectMiddleware) so it's available synchronously
|
||||
// without a binding round-trip.
|
||||
//
|
||||
// Credential policy (consistent with the browser): the refresh_token is the
|
||||
// long-lived secret and is never exposed to the WebView/JS context. It lives only
|
||||
// in the Go process and at rest; the WebView only ever receives the short-lived
|
||||
// access_token (via SessionView). Refresh is Go-internal — the WebView calls
|
||||
// Refresh() with no argument and Go uses its own copy.
|
||||
//
|
||||
// At rest the refresh_token is encrypted, not plaintext (R2). It is the one
|
||||
// credential that lets a holder mint fresh access tokens indefinitely, so
|
||||
// same-user malware must not be able to read it off disk. We can't store it in
|
||||
// the OS secret store directly: Casdoor issues JWT refresh_tokens that run to
|
||||
// ~15 KB, far past go-keyring's per-item limits (macOS ~3 KB command, Windows
|
||||
// 2560 B). Instead we keep a 32-byte AES key in the secret store (tiny, no size
|
||||
// issue on any platform) and write the AES-256-GCM ciphertext to the 0600 file.
|
||||
// Reading the file yields only ciphertext; decrypting it needs the key, which is
|
||||
// protected exactly as a directly-stored token would be — same posture, no size
|
||||
// cap. The key goes through go-keyring's system tooling rather than this app's
|
||||
// code signature, so an ad-hoc / per-build re-sign doesn't invalidate it.
|
||||
//
|
||||
// The short-lived access_token and display identity stay as plaintext in the
|
||||
// file: the access_token is already handed to the WebView and expires fast, so
|
||||
// it is not the asset R2 protects.
|
||||
|
||||
const (
|
||||
keyringService = "cdrop"
|
||||
// keyringKeyAccount holds the 32-byte AES key (base64) that encrypts the
|
||||
// refresh_token at rest — NOT the token itself.
|
||||
keyringKeyAccount = "session_key"
|
||||
)
|
||||
|
||||
// persistedSession is the on-disk shape. RefreshTokenEnc holds
|
||||
// base64(nonce||ciphertext) under AES-256-GCM. RefreshToken is only ever set on
|
||||
// a legacy file (written before R2) or as a degraded fallback when the secret
|
||||
// store is unavailable; the happy path leaves it empty.
|
||||
type persistedSession struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshTokenEnc string `json:"refresh_token_enc,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
User UserInfo `json:"user"`
|
||||
}
|
||||
|
||||
// sessionPath is ~/Library/Application Support/cdrop/session.json on macOS.
|
||||
func sessionPath() (string, error) {
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "cdrop", "session.json"), nil
|
||||
}
|
||||
|
||||
// loadOrCreateKey returns the AES-256 key from the OS secret store, minting and
|
||||
// storing a fresh random one on first use.
|
||||
func loadOrCreateKey() ([]byte, error) {
|
||||
if enc, err := keyring.Get(keyringService, keyringKeyAccount); err == nil {
|
||||
if key, derr := base64.StdEncoding.DecodeString(enc); derr == nil && len(key) == 32 {
|
||||
return key, nil
|
||||
}
|
||||
// A corrupt / wrong-sized value is unusable; fall through and regenerate.
|
||||
// (Any ciphertext encrypted under the old value becomes undecryptable —
|
||||
// the user re-logs in, which is acceptable for a corrupt store.)
|
||||
} else if !errors.Is(err, keyring.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := keyring.Set(keyringService, keyringKeyAccount, base64.StdEncoding.EncodeToString(key)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func newGCM(key []byte) (cipher.AEAD, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
|
||||
// encryptToken seals the refresh_token under the secret-store key, returning
|
||||
// base64(nonce||ciphertext).
|
||||
func encryptToken(plaintext string) (string, error) {
|
||||
key, err := loadOrCreateKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return base64.StdEncoding.EncodeToString(sealed), nil
|
||||
}
|
||||
|
||||
// decryptToken opens a refresh_token ciphertext using the secret-store key. It
|
||||
// never mints a key: a missing key means the ciphertext is unrecoverable (the
|
||||
// caller falls back to requiring a fresh login).
|
||||
func decryptToken(enc string) (string, error) {
|
||||
keyB64, err := keyring.Get(keyringService, keyringKeyAccount)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key, err := base64.StdEncoding.DecodeString(keyB64)
|
||||
if err != nil || len(key) != 32 {
|
||||
return "", errors.New("session: malformed key in secret store")
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(enc)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(raw) < gcm.NonceSize() {
|
||||
return "", errors.New("session: ciphertext too short")
|
||||
}
|
||||
nonce, ct := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
|
||||
plaintext, err := gcm.Open(nil, nonce, ct, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
// SaveSession persists the login session. The refresh_token is encrypted at rest
|
||||
// under a key held in the OS secret store; everything else (short-lived
|
||||
// access_token + identity) is plaintext in the 0600 file. If the secret store is
|
||||
// unavailable (e.g. a headless Linux box with no Secret Service), we fall back to
|
||||
// writing the refresh_token in plaintext so login still works — logging the
|
||||
// downgrade rather than failing auth outright.
|
||||
func SaveSession(res LoginResult) error {
|
||||
p, err := sessionPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rec := persistedSession{
|
||||
AccessToken: res.AccessToken,
|
||||
ExpiresIn: res.ExpiresIn,
|
||||
User: res.User,
|
||||
}
|
||||
if res.RefreshToken != "" {
|
||||
if enc, err := encryptToken(res.RefreshToken); err == nil {
|
||||
rec.RefreshTokenEnc = enc
|
||||
} else {
|
||||
slog.Warn("cdrop: refresh_token kept in session file; OS secret store unavailable", "err", err)
|
||||
rec.RefreshToken = res.RefreshToken
|
||||
}
|
||||
}
|
||||
|
||||
data, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(p, data, 0o600)
|
||||
}
|
||||
|
||||
// LoadSession returns the persisted session, or nil when none is stored / the
|
||||
// file is unreadable or empty. The refresh_token is decrypted from the file
|
||||
// using the secret-store key. A legacy file that still embeds the plaintext
|
||||
// refresh_token (written before R2, or by the fallback above) is re-encrypted on
|
||||
// first load and stripped of its plaintext.
|
||||
func LoadSession() (*LoginResult, error) {
|
||||
p, err := sessionPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rec persistedSession
|
||||
if err := json.Unmarshal(data, &rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rec.AccessToken == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
res := &LoginResult{
|
||||
AccessToken: rec.AccessToken,
|
||||
ExpiresIn: rec.ExpiresIn,
|
||||
User: rec.User,
|
||||
}
|
||||
switch {
|
||||
case rec.RefreshToken != "":
|
||||
// Plaintext on disk (legacy or fallback): keep it in memory and re-save,
|
||||
// which encrypts it and strips the plaintext (no-ops if the store is
|
||||
// still unavailable, leaving the file as-is).
|
||||
res.RefreshToken = rec.RefreshToken
|
||||
if err := SaveSession(*res); err != nil {
|
||||
slog.Warn("cdrop: refresh_token migration to secret store failed", "err", err)
|
||||
}
|
||||
case rec.RefreshTokenEnc != "":
|
||||
if rt, err := decryptToken(rec.RefreshTokenEnc); err == nil {
|
||||
res.RefreshToken = rt
|
||||
} else {
|
||||
// Key gone / ciphertext corrupt: drop to an access-token-only session;
|
||||
// the app will require a fresh login once the access_token lapses.
|
||||
slog.Warn("cdrop: decrypt refresh_token failed; re-login will be required", "err", err)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ClearSession removes the persisted session (logout): both the file and the
|
||||
// encryption key held in the OS secret store. Absent entries are a no-op.
|
||||
func ClearSession() error {
|
||||
p, err := sessionPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err := keyring.Delete(keyringService, keyringKeyAccount); err != nil &&
|
||||
!errors.Is(err, keyring.ErrNotFound) {
|
||||
slog.Warn("cdrop: clear session key from secret store failed", "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
keyring "github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
// withTempSession isolates session storage: an in-memory keyring mock (so tests
|
||||
// never touch the real Keychain / Credential Manager) plus a temp HOME so
|
||||
// sessionPath resolves under t.TempDir().
|
||||
func withTempSession(t *testing.T) {
|
||||
t.Helper()
|
||||
keyring.MockInit()
|
||||
dir := t.TempDir()
|
||||
// os.UserConfigDir honours XDG_CONFIG_HOME on Linux and HOME on macOS.
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
t.Setenv("HOME", dir)
|
||||
}
|
||||
|
||||
// readSessionFile returns the parsed on-disk record plus its raw bytes.
|
||||
func readSessionFile(t *testing.T) (persistedSession, []byte) {
|
||||
t.Helper()
|
||||
p, err := sessionPath()
|
||||
if err != nil {
|
||||
t.Fatalf("sessionPath: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return persistedSession{}, nil
|
||||
}
|
||||
t.Fatalf("read session file: %v", err)
|
||||
}
|
||||
var rec persistedSession
|
||||
if err := json.Unmarshal(data, &rec); err != nil {
|
||||
t.Fatalf("unmarshal session file: %v", err)
|
||||
}
|
||||
return rec, data
|
||||
}
|
||||
|
||||
// The refresh_token must be encrypted at rest (R2): the plaintext never appears
|
||||
// in the file, the keyring holds only the small AES key, and the round-trip
|
||||
// returns the token in memory.
|
||||
func TestSaveSession_RefreshTokenEncryptedAtRest(t *testing.T) {
|
||||
withTempSession(t)
|
||||
|
||||
res := LoginResult{
|
||||
AccessToken: "access-abc",
|
||||
RefreshToken: "refresh-xyz-secret",
|
||||
ExpiresIn: 3600,
|
||||
User: UserInfo{ID: "u1", Name: "Tester"},
|
||||
}
|
||||
if err := SaveSession(res); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
rec, raw := readSessionFile(t)
|
||||
if rec.RefreshToken != "" {
|
||||
t.Errorf("plaintext refresh_token must not be in the file, got %q", rec.RefreshToken)
|
||||
}
|
||||
if rec.RefreshTokenEnc == "" {
|
||||
t.Error("refresh_token_enc must be present")
|
||||
}
|
||||
if strings.Contains(string(raw), "refresh-xyz-secret") {
|
||||
t.Error("the plaintext token must not appear anywhere in the file bytes")
|
||||
}
|
||||
// The keyring holds the AES key, not the token.
|
||||
if k, err := keyring.Get(keyringService, keyringKeyAccount); err != nil || k == "" {
|
||||
t.Errorf("keyring must hold the session key: %q err %v", k, err)
|
||||
}
|
||||
|
||||
loaded, err := LoadSession()
|
||||
if err != nil || loaded == nil {
|
||||
t.Fatalf("load: %v (nil=%v)", err, loaded == nil)
|
||||
}
|
||||
if loaded.RefreshToken != "refresh-xyz-secret" || loaded.AccessToken != "access-abc" {
|
||||
t.Errorf("loaded tokens: rt=%q at=%q", loaded.RefreshToken, loaded.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
// A large JWT refresh_token (Casdoor issues ~15 KB) must round-trip — this is
|
||||
// exactly the case go-keyring can't store directly (its per-item limit is a few
|
||||
// KB), and the reason the token is encrypted into the file instead.
|
||||
func TestSaveSession_LargeTokenRoundTrips(t *testing.T) {
|
||||
withTempSession(t)
|
||||
|
||||
big := strings.Repeat("a", 16000)
|
||||
res := LoginResult{AccessToken: "acc", RefreshToken: big, ExpiresIn: 3600, User: UserInfo{ID: "u"}}
|
||||
if err := SaveSession(res); err != nil {
|
||||
t.Fatalf("save large token: %v", err)
|
||||
}
|
||||
rec, raw := readSessionFile(t)
|
||||
if rec.RefreshToken != "" {
|
||||
t.Error("large token must not be stored in plaintext")
|
||||
}
|
||||
if strings.Contains(string(raw), big) {
|
||||
t.Error("large plaintext token must not appear in the file bytes")
|
||||
}
|
||||
loaded, err := LoadSession()
|
||||
if err != nil || loaded == nil || loaded.RefreshToken != big {
|
||||
t.Fatalf("large token did not round-trip: err %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A legacy file with a plaintext refresh_token (written before R2) must be
|
||||
// re-encrypted on first load and stripped of its plaintext.
|
||||
func TestLoadSession_MigratesLegacyPlaintext(t *testing.T) {
|
||||
withTempSession(t)
|
||||
|
||||
p, _ := sessionPath()
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
// Seed a legacy file: plaintext refresh_token, no ciphertext field.
|
||||
data, _ := json.Marshal(persistedSession{
|
||||
AccessToken: "access-abc",
|
||||
RefreshToken: "legacy-plain",
|
||||
ExpiresIn: 3600,
|
||||
User: UserInfo{ID: "u1"},
|
||||
})
|
||||
if err := os.WriteFile(p, data, 0o600); err != nil {
|
||||
t.Fatalf("seed legacy file: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadSession()
|
||||
if err != nil || loaded == nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if loaded.RefreshToken != "legacy-plain" {
|
||||
t.Errorf("migrated session must carry the token in memory, got %q", loaded.RefreshToken)
|
||||
}
|
||||
rec, raw := readSessionFile(t)
|
||||
if rec.RefreshToken != "" {
|
||||
t.Error("plaintext must be stripped from disk after migration")
|
||||
}
|
||||
if rec.RefreshTokenEnc == "" || strings.Contains(string(raw), "legacy-plain") {
|
||||
t.Error("migrated token must be encrypted in the file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRoundTripAndClear(t *testing.T) {
|
||||
withTempSession(t)
|
||||
|
||||
if got, err := LoadSession(); err != nil || got != nil {
|
||||
t.Fatalf("absent session should be nil; got %+v err %v", got, err)
|
||||
}
|
||||
|
||||
want := LoginResult{
|
||||
AccessToken: "acc",
|
||||
RefreshToken: "ref",
|
||||
ExpiresIn: 3600,
|
||||
User: UserInfo{ID: "u-1", Name: "alice", Avatar: "a.png"},
|
||||
}
|
||||
if err := SaveSession(want); err != nil {
|
||||
t.Fatalf("SaveSession: %v", err)
|
||||
}
|
||||
got, err := LoadSession()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession: %v", err)
|
||||
}
|
||||
if got == nil || *got != want {
|
||||
t.Errorf("round-trip: got %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
if err := ClearSession(); err != nil {
|
||||
t.Fatalf("ClearSession: %v", err)
|
||||
}
|
||||
if got, _ := LoadSession(); got != nil {
|
||||
t.Error("session should be gone after ClearSession")
|
||||
}
|
||||
// The key is dropped too — no orphaned secret-store entry.
|
||||
if _, err := keyring.Get(keyringService, keyringKeyAccount); err != keyring.ErrNotFound {
|
||||
t.Errorf("session key should be gone after clear, got err %v", err)
|
||||
}
|
||||
if err := ClearSession(); err != nil {
|
||||
t.Errorf("ClearSession on absent should be no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSessionIgnoresEmptyToken(t *testing.T) {
|
||||
withTempSession(t)
|
||||
if err := SaveSession(LoginResult{RefreshToken: "ref"}); err != nil {
|
||||
t.Fatalf("SaveSession: %v", err)
|
||||
}
|
||||
got, err := LoadSession()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("empty-access-token session should load as nil, got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
)
|
||||
|
||||
// bootPayload is the state injected into the page at load so the WebView store
|
||||
// can hydrate synchronously: the restored session (refresh-free — see
|
||||
// SessionView), the device name (both of which the wails:// scheme can't persist
|
||||
// in WebView storage), and the API base the page should fetch from.
|
||||
type bootPayload struct {
|
||||
Session *SessionView `json:"session"`
|
||||
DeviceName string `json:"device_name"`
|
||||
// APIBase points the page at a loopback HTTP origin for /api on Windows,
|
||||
// where the custom-scheme AssetServer buffers streaming responses (so SSE
|
||||
// never arrives). Empty on macOS — the page then uses same-origin relative
|
||||
// /api through the custom scheme, which streams fine there.
|
||||
APIBase string `json:"api_base,omitempty"`
|
||||
// DeviceType is the native client kind (macos / windows / linux); the page
|
||||
// forwards it as X-Device-Type so the backend lists it correctly instead of
|
||||
// defaulting every device to "browser".
|
||||
DeviceType string `json:"device_type,omitempty"`
|
||||
}
|
||||
|
||||
// SessionInjectMiddleware injects the boot state into the served index.html as
|
||||
// `window.__CDROP_BOOT__`, so the WebView store hydrates synchronously at startup
|
||||
// — no WebView storage (which doesn't survive restart for the wails:// scheme)
|
||||
// and no binding round-trip (window.go isn't ready that early). Read once at app
|
||||
// launch. device_name + api_base are injected always (needed even logged out);
|
||||
// the session is included only when one was restored.
|
||||
//
|
||||
// Only the top-level HTML document is rewritten; assets and the /api proxy
|
||||
// (including the SSE stream) pass straight through untouched. The JSON is
|
||||
// produced by encoding/json with default HTML escaping, so a display name
|
||||
// containing "</script>" can't break out of the inline script.
|
||||
func SessionInjectMiddleware(session *LoginResult, deviceName, apiBase, deviceType string) assetserver.Middleware {
|
||||
boot := bootPayload{DeviceName: deviceName, APIBase: apiBase, DeviceType: deviceType}
|
||||
if session != nil {
|
||||
view := session.View() // strip the refresh_token before it ever reaches the page
|
||||
boot.Session = &view
|
||||
}
|
||||
payload, _ := json.Marshal(boot)
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || !isDocumentRequest(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Force a full 200 (never a 304): strip conditional headers so
|
||||
// http.ServeContent always returns the whole document for us to
|
||||
// inject. Without this, a WebView that cached the first (logged-out)
|
||||
// page could send If-Modified-Since on the next launch, get a 304 with
|
||||
// an empty body, reuse its stale page, and the session wouldn't restore.
|
||||
r.Header.Del("If-Modified-Since")
|
||||
r.Header.Del("If-None-Match")
|
||||
r.Header.Del("If-Range")
|
||||
|
||||
rec := &captureWriter{header: http.Header{}}
|
||||
next.ServeHTTP(rec, r)
|
||||
body := rec.buf.Bytes()
|
||||
|
||||
if idx := bytes.Index(body, []byte("</head>")); idx != -1 {
|
||||
script := []byte("<script>window.__CDROP_BOOT__=" + string(payload) + ";</script>")
|
||||
out := make([]byte, 0, len(body)+len(script))
|
||||
out = append(out, body[:idx]...)
|
||||
out = append(out, script...)
|
||||
out = append(out, body[idx:]...)
|
||||
body = out
|
||||
}
|
||||
|
||||
h := w.Header()
|
||||
for k, vs := range rec.header {
|
||||
for _, v := range vs {
|
||||
h.Add(k, v)
|
||||
}
|
||||
}
|
||||
// Never let the WebView cache the document — the injected session
|
||||
// changes between launches, so a cached copy must not be reused.
|
||||
h.Del("Last-Modified")
|
||||
h.Del("ETag")
|
||||
h.Set("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||
h.Set("Pragma", "no-cache")
|
||||
h.Set("Content-Length", strconv.Itoa(len(body)))
|
||||
code := rec.code
|
||||
if code == 0 {
|
||||
code = http.StatusOK
|
||||
}
|
||||
w.WriteHeader(code)
|
||||
_, _ = w.Write(body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// isDocumentRequest matches the top-level HTML document the WebView loads.
|
||||
func isDocumentRequest(path string) bool {
|
||||
return path == "" || path == "/" || path == "/index.html"
|
||||
}
|
||||
|
||||
// captureWriter buffers a handler's response so the middleware can rewrite the
|
||||
// HTML body before forwarding it. Only used for the small index.html document.
|
||||
type captureWriter struct {
|
||||
header http.Header
|
||||
buf bytes.Buffer
|
||||
code int
|
||||
}
|
||||
|
||||
func (c *captureWriter) Header() http.Header { return c.header }
|
||||
func (c *captureWriter) WriteHeader(code int) { c.code = code }
|
||||
func (c *captureWriter) Write(b []byte) (int, error) { return c.buf.Write(b) }
|
||||
@@ -0,0 +1,109 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const htmlDoc = "<html><head><title>cdrop</title></head><body></body></html>"
|
||||
|
||||
func htmlHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(htmlDoc))
|
||||
})
|
||||
}
|
||||
|
||||
func TestSessionInjectIntoDocument(t *testing.T) {
|
||||
session := &LoginResult{
|
||||
AccessToken: "acc-token",
|
||||
RefreshToken: "ref-token",
|
||||
User: UserInfo{ID: "u-1", Name: "alice"},
|
||||
}
|
||||
h := SessionInjectMiddleware(session, "my-host", "", "macos")(htmlHandler())
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
body := rec.Body.String()
|
||||
|
||||
if !strings.Contains(body, "window.__CDROP_BOOT__") {
|
||||
t.Fatalf("boot global not injected:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"access_token":"acc-token"`) {
|
||||
t.Errorf("access token not in injected payload:\n%s", body)
|
||||
}
|
||||
// Credential policy: the refresh_token must NEVER reach the page.
|
||||
if strings.Contains(body, "ref-token") || strings.Contains(body, "refresh_token") {
|
||||
t.Errorf("refresh_token must not be injected into the page:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"device_name":"my-host"`) {
|
||||
t.Errorf("device name not in injected payload:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"device_type":"macos"`) {
|
||||
t.Errorf("device type not in injected payload:\n%s", body)
|
||||
}
|
||||
// Injected before </head> so it runs before the deferred module bundle.
|
||||
if strings.Index(body, "__CDROP_BOOT__") > strings.Index(body, "</head>") {
|
||||
t.Error("injection must precede </head>")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInjectPassesThroughNonDocument(t *testing.T) {
|
||||
apiBody := "streamed-api-body"
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(apiBody))
|
||||
})
|
||||
h := SessionInjectMiddleware(&LoginResult{AccessToken: "x"}, "h", "", "")(next)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/hub/events", nil))
|
||||
if rec.Body.String() != apiBody {
|
||||
t.Errorf("non-document request must pass through unchanged, got %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInjectNilSessionStillInjectsDeviceBoot(t *testing.T) {
|
||||
// Logged out, the boot state is still injected — the page needs the device
|
||||
// name / api_base / device_type before login — but with a null session, and
|
||||
// no token of any kind leaks.
|
||||
h := SessionInjectMiddleware(nil, "my-host", "http://127.0.0.1:5000", "windows")(htmlHandler())
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
body := rec.Body.String()
|
||||
|
||||
if !strings.Contains(body, "window.__CDROP_BOOT__") {
|
||||
t.Fatalf("boot global must be injected even when logged out:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"session":null`) {
|
||||
t.Errorf("logged-out boot must carry a null session:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"device_name":"my-host"`) ||
|
||||
!strings.Contains(body, `"device_type":"windows"`) ||
|
||||
!strings.Contains(body, `"api_base":"http://127.0.0.1:5000"`) {
|
||||
t.Errorf("device boot fields not injected:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "access_token") {
|
||||
t.Errorf("no token must appear when logged out:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionInjectEscapesScriptBreakout(t *testing.T) {
|
||||
// A display name containing </script> must not break out of the inline tag.
|
||||
session := &LoginResult{AccessToken: "acc", User: UserInfo{Name: "</script><b>x"}}
|
||||
h := SessionInjectMiddleware(session, "h", "", "")(htmlHandler())
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
body := rec.Body.String()
|
||||
|
||||
// The literal closing tag from the name must NOT appear verbatim; encoding/json
|
||||
// HTML-escapes < / > to < / > by default.
|
||||
if strings.Contains(body, "</script><b>x") {
|
||||
t.Errorf("script breakout not escaped:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `</script>`) {
|
||||
t.Errorf("expected HTML-escaped name in payload:\n%s", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package platform
|
||||
|
||||
// StatusBarHandler carries the three menu-bar actions as closures so the App can
|
||||
// supply them without exposing extra Wails-bound methods on its struct.
|
||||
type StatusBarHandler struct {
|
||||
OnShowWindow func()
|
||||
OnOpenSettings func()
|
||||
OnQuit func()
|
||||
}
|
||||
|
||||
// StatusBarMenu is the set of localized labels for the menu bar item + its menu.
|
||||
// The icon itself is the embedded brand mascot (see statusbar_darwin); Title is
|
||||
// only the accessibility label + text fallback when the image can't be loaded.
|
||||
type StatusBarMenu struct {
|
||||
Title string // accessibility label + text fallback if the icon fails to load
|
||||
Show string
|
||||
Settings string
|
||||
Quit string
|
||||
}
|
||||
|
||||
// statusBarHandler holds the active closures; set by InstallStatusBar and read
|
||||
// by the native menu-action callbacks (darwin) — a no-op elsewhere.
|
||||
var statusBarHandler StatusBarHandler
|
||||
@@ -0,0 +1,80 @@
|
||||
//go:build darwin
|
||||
|
||||
package platform
|
||||
|
||||
/*
|
||||
#cgo darwin CFLAGS: -x objective-c -fobjc-arc
|
||||
#cgo darwin LDFLAGS: -framework Cocoa
|
||||
#include <stdlib.h>
|
||||
|
||||
void cdropStatusBarInstall(const void *iconPNG, int iconLen, const char *title,
|
||||
const char *showLabel, const char *settingsLabel,
|
||||
const char *quitLabel);
|
||||
void cdropSetActivationPolicy(int policy);
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// menuBarIconPNG 是嵌入二进制的品牌吉祥物头像(avatar-head,256px)。菜单栏图标
|
||||
// 用彩色图而非 SF Symbol 模板图——彩色更有品牌辨识度(代价是不随明暗自适应)。
|
||||
// 256px 提供高清 rep,运行时按菜单栏 thickness(逻辑点)显示,retina 下采样保持清晰。
|
||||
//
|
||||
//go:embed menubar-icon.png
|
||||
var menuBarIconPNG []byte
|
||||
|
||||
// InstallStatusBar creates the menu bar (NSStatusBar) item with a Show /
|
||||
// Settings / Quit menu. Safe to call from any goroutine — the native work is
|
||||
// dispatched to the main thread. Idempotent on the native side.
|
||||
func InstallStatusBar(m StatusBarMenu, h StatusBarHandler) {
|
||||
statusBarHandler = h
|
||||
|
||||
ct := C.CString(m.Title)
|
||||
cs := C.CString(m.Show)
|
||||
cg := C.CString(m.Settings)
|
||||
cq := C.CString(m.Quit)
|
||||
defer C.free(unsafe.Pointer(ct))
|
||||
defer C.free(unsafe.Pointer(cs))
|
||||
defer C.free(unsafe.Pointer(cg))
|
||||
defer C.free(unsafe.Pointer(cq))
|
||||
|
||||
// 把 PNG 字节复制进 C 缓冲;ObjC 侧在 dispatch 前同步拷进 NSData,故调用返回后即可释放。
|
||||
cIcon := C.CBytes(menuBarIconPNG)
|
||||
defer C.free(cIcon)
|
||||
|
||||
C.cdropStatusBarInstall(cIcon, C.int(len(menuBarIconPNG)), ct, cs, cg, cq)
|
||||
}
|
||||
|
||||
// SetDockVisible toggles the Dock icon: true → regular (icon shown), false →
|
||||
// accessory (Dock hidden, only the menu bar item remains). Main-thread safe.
|
||||
func SetDockVisible(visible bool) {
|
||||
p := C.int(1) // accessory
|
||||
if visible {
|
||||
p = C.int(0) // regular
|
||||
}
|
||||
C.cdropSetActivationPolicy(p)
|
||||
}
|
||||
|
||||
//export cdropOnShowWindow
|
||||
func cdropOnShowWindow() {
|
||||
if statusBarHandler.OnShowWindow != nil {
|
||||
statusBarHandler.OnShowWindow()
|
||||
}
|
||||
}
|
||||
|
||||
//export cdropOnOpenSettings
|
||||
func cdropOnOpenSettings() {
|
||||
if statusBarHandler.OnOpenSettings != nil {
|
||||
statusBarHandler.OnOpenSettings()
|
||||
}
|
||||
}
|
||||
|
||||
//export cdropOnQuit
|
||||
func cdropOnQuit() {
|
||||
if statusBarHandler.OnQuit != nil {
|
||||
statusBarHandler.OnQuit()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//go:build darwin
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#include "_cgo_export.h"
|
||||
|
||||
// CdropStatusBarController owns the menu bar item and routes the three menu
|
||||
// actions back into Go. It is deliberately NOT the NSApplication delegate —
|
||||
// Wails owns that, and stealing it would break Wails' window / runtime / custom
|
||||
// scheme handling. A status item can be created independently after launch, so
|
||||
// we never touch the delegate.
|
||||
@interface CdropStatusBarController : NSObject
|
||||
@property (strong) NSStatusItem *item;
|
||||
@property (strong) NSMenu *menu;
|
||||
- (void)showWindow:(id)sender;
|
||||
- (void)openSettings:(id)sender;
|
||||
- (void)quit:(id)sender;
|
||||
- (void)statusItemClicked:(id)sender;
|
||||
@end
|
||||
|
||||
@implementation CdropStatusBarController
|
||||
- (void)showWindow:(id)sender { cdropOnShowWindow(); }
|
||||
- (void)openSettings:(id)sender { cdropOnOpenSettings(); }
|
||||
- (void)quit:(id)sender { cdropOnQuit(); }
|
||||
|
||||
// showMenu pops the menu now. Temporarily attaching item.menu and performClick
|
||||
// shows it and BLOCKS until dismissed (menu tracking is modal); detaching after
|
||||
// keeps left-clicks flowing through statusItemClicked: (so left-click opens the
|
||||
// window directly instead of popping the menu).
|
||||
- (void)showMenu {
|
||||
self.item.menu = self.menu;
|
||||
[self.item.button performClick:nil];
|
||||
self.item.menu = nil;
|
||||
}
|
||||
|
||||
// statusItemClicked fires on left/right mouse-up. Click routing (the Windows tray
|
||||
// should mirror this once implemented):
|
||||
// - left-click → open the main window
|
||||
// - right-click / control-click → menu (Show / Settings / Quit)
|
||||
// We route clicks manually instead of setting item.menu — item.menu would pop the
|
||||
// menu on left-click too, leaving no way for left-click to open the window.
|
||||
- (void)statusItemClicked:(id)sender {
|
||||
NSEvent *e = [NSApp currentEvent];
|
||||
BOOL secondary = (e.type == NSEventTypeRightMouseUp) ||
|
||||
((e.modifierFlags & NSEventModifierFlagControl) != 0);
|
||||
if (secondary) {
|
||||
[self showMenu]; // right-click / control-click → menu
|
||||
} else {
|
||||
cdropOnShowWindow(); // left-click → open the main window
|
||||
}
|
||||
}
|
||||
@end
|
||||
|
||||
// File-scope strong reference (ARC) keeps the controller and its status item
|
||||
// alive for the process lifetime.
|
||||
static CdropStatusBarController *gController = nil;
|
||||
|
||||
// cdropStatusBarInstall creates the menu bar item + its menu on the main thread.
|
||||
// The icon is the embedded brand mascot (a colored, NON-template image — keeps
|
||||
// brand color instead of tinting to the menu bar). It's displayed at the menu
|
||||
// bar thickness (logical points); the high-res (256px) source rep keeps it crisp
|
||||
// on retina. If decoding fails it falls back to the text title. The PNG bytes are
|
||||
// copied into NSData synchronously here (before the async block) so Go can free
|
||||
// its C buffer right after this call. Labels arrive as UTF-8 from Go so this
|
||||
// source stays ASCII-only. Idempotent.
|
||||
void cdropStatusBarInstall(const void *iconPNG, int iconLen, const char *title,
|
||||
const char *showLabel, const char *settingsLabel,
|
||||
const char *quitLabel) {
|
||||
NSData *iconData = (iconPNG != NULL && iconLen > 0)
|
||||
? [NSData dataWithBytes:iconPNG length:(NSUInteger)iconLen]
|
||||
: nil;
|
||||
NSString *t = [NSString stringWithUTF8String:title];
|
||||
NSString *sl = [NSString stringWithUTF8String:showLabel];
|
||||
NSString *gl = [NSString stringWithUTF8String:settingsLabel];
|
||||
NSString *ql = [NSString stringWithUTF8String:quitLabel];
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (gController != nil) { return; }
|
||||
gController = [[CdropStatusBarController alloc] init];
|
||||
|
||||
NSStatusItem *item = [[NSStatusBar systemStatusBar]
|
||||
statusItemWithLength:NSVariableStatusItemLength];
|
||||
|
||||
NSImage *icon = (iconData != nil) ? [[NSImage alloc] initWithData:iconData] : nil;
|
||||
if (icon != nil) {
|
||||
CGFloat th = [[NSStatusBar systemStatusBar] thickness];
|
||||
[icon setSize:NSMakeSize(th, th)]; // fill bar height; retina uses 256px rep
|
||||
icon.template = NO; // keep brand color, don't tint
|
||||
item.button.image = icon;
|
||||
item.button.accessibilityLabel = t; // image has no text → a11y label
|
||||
} else {
|
||||
item.button.title = t;
|
||||
}
|
||||
|
||||
NSMenu *menu = [[NSMenu alloc] init];
|
||||
|
||||
NSMenuItem *show = [[NSMenuItem alloc] initWithTitle:sl
|
||||
action:@selector(showWindow:) keyEquivalent:@""];
|
||||
show.target = gController;
|
||||
[menu addItem:show];
|
||||
|
||||
NSMenuItem *settings = [[NSMenuItem alloc] initWithTitle:gl
|
||||
action:@selector(openSettings:) keyEquivalent:@""];
|
||||
settings.target = gController;
|
||||
[menu addItem:settings];
|
||||
|
||||
[menu addItem:[NSMenuItem separatorItem]];
|
||||
|
||||
NSMenuItem *quit = [[NSMenuItem alloc] initWithTitle:ql
|
||||
action:@selector(quit:) keyEquivalent:@""];
|
||||
quit.target = gController;
|
||||
[menu addItem:quit];
|
||||
|
||||
// Don't set item.menu (that auto-pops on mouse-down, killing double-click
|
||||
// detection). Store the menu and route clicks through statusItemClicked:.
|
||||
gController.item = item;
|
||||
gController.menu = menu;
|
||||
item.button.target = gController;
|
||||
item.button.action = @selector(statusItemClicked:);
|
||||
[item.button sendActionOn:(NSEventMaskLeftMouseUp | NSEventMaskRightMouseUp)];
|
||||
});
|
||||
}
|
||||
|
||||
// cdropSetActivationPolicy flips the Dock presence on the main thread.
|
||||
// policy: 0 = regular (Dock icon shown), 1 = accessory (Dock hidden, menu bar
|
||||
// item only).
|
||||
void cdropSetActivationPolicy(int policy) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
NSApplicationActivationPolicy p =
|
||||
(policy == 1) ? NSApplicationActivationPolicyAccessory
|
||||
: NSApplicationActivationPolicyRegular;
|
||||
[NSApp setActivationPolicy:p];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package platform
|
||||
|
||||
// Non-darwin stubs. The menu bar item + Dock activation policy are macOS
|
||||
// concepts; Windows/Linux equivalents (tray + window-to-tray) land when those
|
||||
// platforms are targeted. Kept as no-ops so cross-platform builds compile.
|
||||
//
|
||||
// 当实现 Windows 托盘时,交互须与 macOS 对齐(见 statusbar_darwin.m):
|
||||
// 左键单击 → 打开主窗口;右键 → 菜单(显示主窗 / 设置 / 退出)。托盘图标用同一
|
||||
// 彩色品牌图(menubar-icon.png)。
|
||||
|
||||
// InstallStatusBar is a no-op outside macOS.
|
||||
func InstallStatusBar(_ StatusBarMenu, h StatusBarHandler) {
|
||||
statusBarHandler = h
|
||||
}
|
||||
|
||||
// SetDockVisible is a no-op outside macOS.
|
||||
func SetDockVisible(_ bool) {}
|
||||
@@ -0,0 +1,74 @@
|
||||
//go:build windows
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"runtime"
|
||||
|
||||
"fyne.io/systray"
|
||||
)
|
||||
|
||||
// trayIconICO is the brand mascot as a Windows .ico, embedded in the binary and
|
||||
// shown in the notification area.
|
||||
//
|
||||
//go:embed tray-icon.ico
|
||||
var trayIconICO []byte
|
||||
|
||||
// InstallStatusBar installs the Windows system-tray icon, mirroring the macOS
|
||||
// menu bar: left-click (primary tap) opens the main window, right-click shows
|
||||
// the Show / Settings / Quit menu. systray owns a Win32 message loop, so it runs
|
||||
// on its own OS-locked goroutine; the menu-item click channels are drained in a
|
||||
// second goroutine. The handler closures already hand work to a goroutine before
|
||||
// touching the Wails runtime, so invoking them from here is safe.
|
||||
func InstallStatusBar(m StatusBarMenu, h StatusBarHandler) {
|
||||
statusBarHandler = h
|
||||
go func() {
|
||||
// systray creates a window + GetMessage loop on this thread; the window
|
||||
// is thread-affine, so the goroutine must not migrate.
|
||||
runtime.LockOSThread()
|
||||
systray.Run(func() { trayOnReady(m) }, nil)
|
||||
}()
|
||||
}
|
||||
|
||||
func trayOnReady(m StatusBarMenu) {
|
||||
systray.SetIcon(trayIconICO)
|
||||
systray.SetTitle(m.Title)
|
||||
systray.SetTooltip(m.Title)
|
||||
|
||||
// Left-click → open the main window (matches the macOS convention). Right-
|
||||
// click falls through to the menu below (Windows default).
|
||||
systray.SetOnTapped(func() {
|
||||
if statusBarHandler.OnShowWindow != nil {
|
||||
statusBarHandler.OnShowWindow()
|
||||
}
|
||||
})
|
||||
|
||||
show := systray.AddMenuItem(m.Show, "")
|
||||
settings := systray.AddMenuItem(m.Settings, "")
|
||||
quit := systray.AddMenuItem(m.Quit, "")
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-show.ClickedCh:
|
||||
if statusBarHandler.OnShowWindow != nil {
|
||||
statusBarHandler.OnShowWindow()
|
||||
}
|
||||
case <-settings.ClickedCh:
|
||||
if statusBarHandler.OnOpenSettings != nil {
|
||||
statusBarHandler.OnOpenSettings()
|
||||
}
|
||||
case <-quit.ClickedCh:
|
||||
if statusBarHandler.OnQuit != nil {
|
||||
statusBarHandler.OnQuit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// SetDockVisible is a no-op on Windows: the app lifecycle already hides/shows the
|
||||
// window (Wails WindowHide/WindowShow), which removes/adds the taskbar button, so
|
||||
// there is no separate Dock-style activation policy to toggle.
|
||||
func SetDockVisible(_ bool) {}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||
"name": "Commilitia Drop Desktop",
|
||||
"outputfilename": "Commilitia Drop Desktop",
|
||||
"frontend:install": "cd ../../web && npm install",
|
||||
"frontend:build": "cd ../../web && npm run build -- --outDir ../desktop/frontend/dist --emptyOutDir",
|
||||
"frontend:dev:watcher": "cd ../../web && npm run dev",
|
||||
"frontend:dev:serverUrl": "auto",
|
||||
"info": {
|
||||
"productName": "Commilitia Drop Desktop"
|
||||
},
|
||||
"author": {
|
||||
"name": "commilitia",
|
||||
"email": "git.commilitia@commilitia.net"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user