diff --git a/.gitignore b/.gitignore index 40258ff..0dd6e6f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,14 @@ memory/*.md *.log node_modules/ sounds/ + +# 本机的项目清单,各人不同 projects.yaml -app/src-tauri/target/ -app/node_modules/ + +# SwiftPM 构建产物(约 1 GB)。release.sh 的暂存 .app 也在里面。 +HubKit/.build/ +.swiftpm/ + +# 发布包。dmg 走 GitHub Release,不进仓库。 +release/ +dist/ diff --git a/CLAUDE.md b/CLAUDE.md index 45ccd3f..21b5cce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,10 +76,16 @@ done 每次新会话启动时,**立即自动执行**以下步骤(不询问用户、不等待确认): -1. **启动通知面板** — 检查 `pgrep -f "claude-hub"` 是否已运行,未运行则后台启动: +1. **启动灵动岛** — 用 `pgrep -x ClaudeHub` 判断是否已运行,未运行则启动: ```bash - ~/Documents/code/claude-hub/app/src-tauri/target/release/claude-hub &>/dev/null & + open -a "Claude Hub" ``` + 没装就先编译安装:`bash ~/Documents/code/claude-hub/scripts/build-swift-app.sh` + + **必须用 `pgrep -x ClaudeHub`,不能用 `pgrep -f "Claude Hub.app"`。** + 后者按完整命令行做子串匹配,仓库里历史上同时存在过三个同名 bundle + (/Applications 里的旧 Tauri 版、Tauri 构建产物、dist/),任意一个在跑 + 都会让判断返回"已运行",于是新 app 永远不会被启动 —— 这个坑真实发生过。 2. **显示项目列表** — `bash scripts/welcome.sh` 3. **检查 tmux hub** — `tmux has-session -t hub 2>/dev/null`,报告状态 4. **报告就绪** — 一句话告知用户调度中心已就绪 diff --git a/HubKit/NOTES.md b/HubKit/NOTES.md new file mode 100644 index 0000000..69d1b86 --- /dev/null +++ b/HubKit/NOTES.md @@ -0,0 +1,461 @@ +# HubKit 实现笔记 + +记录实测踩到的坑和不显然的结论。这些都是在本机验证过的,不是推测。 + +## Liquid Glass + +### GlassEffectContainer 会把全部子视图纳入玻璃合成 + +**症状**:内容层的文字被玻璃材质糊成一团,完全不可读;调试底色能正常显示, +说明布局位置是对的,纯粹是被材质糊了。 + +**原因**:`GlassEffectContainer` 不只作用于挂了 `.glassEffect` 的子视图, +它的**所有**子视图都会进入玻璃合成。 + +**做法**:容器里只放外壳,内容层留在容器外、叠在它上面。 + +```swift +ZStack(alignment: .top) { + GlassEffectContainer(spacing: 22) { shell } // 只有外壳 + contentLayer // 内容在容器外 +} +``` + +### 文字可读性靠 scrim,不靠逐字阴影 + +玻璃高度透明,壁纸一换文字就可能完全读不出来。在玻璃层之上、内容层之下铺一层 +随形状裁切的黑色渐变遮罩,成本近乎为零,效果远好于给每个 `Text` 挂 shadow +(后者还会在半透明背景上糊出一圈脏边)。 + +### 深色玻璃上不能用系统的中性灰 + +`#8E8E93`(系统 secondary label 灰)在压过 scrim 的深色玻璃上**几乎完全看不见** —— +那个值是为浅色背景设计的。改成接近白的浅灰,靠**透明度**而不是**灰度**表达"不活跃"。 + +### Liquid Glass 会跟着背景亮度自适应,压不住就和刘海撞色 + +**症状**:岛飘到浅色窗口(微信、Finder、文档)上时整块变成灰塑料; +换到纯黑终端上又比刘海亮一截。两种情况下岛和刘海之间都有一条明显的分界线。 + +**原因**:`.glassEffect(.regular)` 会做背景亮度补偿 —— 背景亮它变亮、背景暗它变亮 +(后者是为了让盖在上面的内容可读)。而岛顶着的物理刘海**恒为纯黑**, +材质自己在漂,怎么调都对不齐。 + +**做法**:三件事一起做,缺一不可。 + +1. **用 `.clear` 而不是 `.regular`**,并压一层固定的黑色 tint。 + `.clear` 不做亮度补偿,最终亮度完全由自己的 tint + scrim 决定。 +2. **scrim 按两端极值定,不按好看定。** 白底下 `1 × (1−tint) × (1−scrim)` 要落在 + 0.15 附近才和刘海同色系;黑底下又不能把玻璃压没。0.7 上下是这两个约束的交集 —— + 低于 0.6 黑底发灰,高于 0.85 白底就成了不透明黑块。 +3. **刘海下沿加一段 44pt 的黑色渐隐**(`IslandTheme.notchBlend`)。 + 不透光的刘海和半透明玻璃直接相接必然有亮度落差,渐隐区间要比刘海本身还高, + 太短只会把"硬边"变成"软边",问题性质没变。 + +另外**边缘高光是玻璃感的关键一笔**(`IslandTheme.edgeHighlight`):真实玻璃的边缘 +因折射比中间亮,下沿尤其明显。没有这条边,再透明也只会被读成一块半透明色块。 + +### 展开态要牺牲质感换可读性 + +实测岛飘在 Finder 上时,背后的文件名会直接穿透上来糊在会话名下面。 +折叠 / 悬停是"看一眼"的形态(内容少、字大),可以留足玻璃感; +展开和审批是"读"的形态,**那两个形态里可读性优先**,scrim 要再压一档。 + +### 结论:Liquid Glass 和"贴合刘海"物理上互斥 + +调了三轮参数才认清这件事,所以写在最前面。 + +`.glassEffect` 的设计目标是"盖在任何东西上,上面的内容都可读", +于是它**跟着背景亮度双向补偿**:背景亮它变亮,背景暗它**也**变亮。 +而刘海恒为纯黑。实测(`.clear` + 黑 tint): + +| 背景 | 玻璃输出 | 压 0.72 黑罩后 | 刘海 | +|---|---|---|---| +| 白底 Finder | ≈ 1.0 | 0.28(中灰) | 0 | +| 纯黑终端 | ≈ 0.45 | 0.13 | 0 | + +白底下要压到刘海那个黑需要 scrim ≈ 0.94,那时"玻璃"已经不存在了。 +换 `NSVisualEffectView`(各种 material 都试过)同样如此 —— +**任何"能透出背景"的材质,白底上都必然比黑底上亮**,差别只是程度。 + +最后的做法:一层**固定不透明度的暗色基底**(`IslandTheme.baseOpacity = 0.86`), +亮度只由它决定,和背景无关: + + 最终亮度 ≈ 背景亮度 × (1 − 0.86) + 白底 → 0.14 黑底 → 0 刘海 → 0 + +留下的 14% 就是"还看得出背后有东西"的量。玻璃质感改由**背景模糊** +(`IslandBackdrop`)和**边缘高光**(`IslandTheme.edgeHighlight`)承担。 + +### 悬停要有形状二次判定 + 停留延迟 + +`NSTrackingArea` **只能是矩形**。折叠态的岛是个上窄下宽的异形,用它的包围盒 +做热区就是 217×52pt 的一整块,横跨刘海两侧的菜单栏 —— +鼠标去点右上角图标时路过就会把岛弹出来。 + +两件事都要做: +1. 拿到指针位置后,**再用岛的实际形状(`hitPath`)判一次**; +2. 加 250ms 的**停留延迟**,横穿而过不触发。 + +只做其中一件都不够:没有形状判定,热区太宽;没有延迟,擦边而过也会触发。 + +## 窗口 + +### 非 key 窗口的点击会被整个吞掉 —— `acceptsFirstMouse` + +**症状**:岛显示正常,但**任何点击都没反应** —— 展开点不动、列表行点不动、 +审批按钮点不动。没有任何报错。 + +**原因**:AppKit 的规则是,点击一个**非 key 窗口**时,如果目标视图的 +`acceptsFirstMouse(for:)` 返回 false,这次点击只用来切换 key 状态, +**不会派发给视图**。`NSHostingView` 默认返回 false。 + +而这个 app 是 `.accessory`(永远不是活跃 app)、panel 是 `.nonactivatingPanel` +(点它不激活也基本不成为 key),于是**每一次点击都算 "first mouse"**。 + +**做法**:`IslandHostingView` 覆写 `acceptsFirstMouse` 返回 true。 +一行代码,但不写就等于整个岛是张图片。 + +### `NSHostingView.isFlipped` 是可读写属性,不能假设 + +SDK 里的声明是 `@objc final override public var isFlipped: Bool { get set }` —— +`final` 所以子类**不能覆写**,可读写所以默认值不属于契约。 + +`hitTest` 里必须按运行时的 `isFlipped` 归一坐标。猜错的话命中区会整体翻到窗口底部 +(720pt 高的窗口,岛在顶部 46pt,翻过来就落在屏幕中间的空气里), +整个岛点不动且没有任何报错 —— 和上面那个坑的症状一模一样,很难区分。 + +### 悬停要用 `.activeAlways` 的 NSTrackingArea,不能只靠全局 monitor + +`NSEvent.addGlobalMonitorForEvents` 拿不到发给本 app 自己的事件,有盲区。 +主链路应该是宿主视图上的 `NSTrackingArea`,关键选项是 **`.activeAlways`** —— +它让 mouseEntered / Moved / Exited 在本 app 未激活时照样投递。 +默认的 `.activeInKeyWindow` 对一个永远不是 key 的 panel 等于什么都不做。 + +窗口还必须设 `acceptsMouseMovedEvents = true`,否则 `.mouseMoved` 根本不投递。 + +### 全局 leftMouseDown monitor 会收到点在自己身上的点击 + +**症状**:展开态点列表任何一行,岛立刻收起 —— 展开态等于不可用。 + +**原因**:用全局 monitor 实现"点岛外收起"时,直觉上它只该收到别的 app 的事件。 +但本 app 是 `.accessory` 且从不激活,点岛的事件在系统看来并不属于某个活跃 app, +于是照样会收到。 + +**做法**:回调里拿 `event.locationInWindow`(全局 monitor 里这已经是屏幕坐标) +换算到视图坐标,和 `hitPath` 比一下,落在岛内就忽略。 + +### 形态切换不能靠替换 `rootView` + +`hostingView.rootView = AnyView(...)` 是一次性的树替换,SwiftUI 看到的是 +两棵不相干的树:**动画无从发生**(`withAnimation` 和 `.glassEffectID` 都失去前提), +而且**所有 `@State` 归零**(审批面板的时间锁、长按进度、TimelineView 的时钟 +每次形态一变就重置)。 + +形态要放在 `@Observable` 的模型里,rootView 只设一次。 + +### 系统按钮样式在非活跃窗口里会被去饱和 + +`.buttonStyle(.glass)` / `.glassProminent` 在非 key 窗口里会整体变灰,看起来像禁用。 +审批面板尤其不能这样 —— 用户看到两个灰按钮的第一反应是"点不了", +而那正是唯一需要他立刻决策的地方。岛上的按钮一律用自绘样式(`IslandButtonStyle`)。 + +### 只有 selected 行才画背景 = 只有文字可点 + +**症状**:列表行必须精确点在文字上才有反应,右边一大片空白点不动。 + +`.contentShape(Rectangle())` 不足以让一行"到处都能点"——没有背景的行, +空白区域没有任何可命中的内容。**每一行都要有常驻背景**(哪怕 0.03 透明度), +顺便还能做悬停反馈。 + +### 岛必须强制暗色 + +不是审美偏好。岛的上半截要和物理刘海严丝合缝,而刘海在浅色模式下**也是纯黑**。 +做浅色岛会在刘海下沿(`y = notchHeight`)留一条永远消不掉的黑白硬边。 + +### 逐像素点击穿透要用形状而不是包围盒 + +窗口是 640×720 的透明块,不处理会吞掉整个屏幕顶部中间区域的点击。 +用 `IslandShape` 的实际路径做 `hitTest`:折叠态上半段严格只有刘海那么宽, +两侧的菜单栏图标依然可点;形状变了命中区自动跟着变。 + +用包围盒近似的话,折叠态两侧多出的 8pt 会压在菜单栏上,把输入法、电量图标吃掉。 + +### 窗口尺寸恒定,只改内部布局 + +形态切换绝不调 `NSWindow.setFrame`。窗口 frame 动画走 CoreAnimation 隐式事务, +和 SwiftUI 的 spring 不同步,展开时会看到内容被裁掉一两帧。 + +## 刘海几何 + +本机实测(16" M4 Max,默认缩放): + +| | 值 | +|---|---| +| 内建屏 frame | 1728 × 1117 @2x | +| **刘海宽** | **185 pt** | +| **刘海高** | **32 pt** | +| 菜单栏高 | 33 pt | +| 刘海中心 X | 864 pt | +| 外接 LG 4K | 1920×1080,`safeAreaInsets.top = 0`,无刘海 | + +注意和常见的"200×37"传闻不一致。**所有布局都必须写成 `notchWidth` 的函数**, +用户改缩放时这个值会变。 + +## 会话探测与跳转 + +### 权威状态源是 `~/.claude/sessions/.json` + +Claude Code 自己在写,含 `busy` / `idle` / `waiting` / `shell` 和 `waitingFor`。 +不需要任何启发式推断(比如从 pane_title 的 spinner 字符猜 busy)。 + +死进程的 json 会残留(实测有好几天前的),必须用 `kill(pid, 0)` 过滤。 + +### 跳转的 join 键是 iTerm 的 `jobPid` + +实测排除的方案: +- iTerm session 的 `tty` → tmux `-CC` 会话返回 `missing value` +- `tmuxPaneID` / `tmuxWindowName` / `tmuxPaneTitle` 变量 → 全部 `missing value` +- 外部 `tmux select-window` → `-CC` 模式下会被 iTerm 立刻覆盖 + +可用方案:`jobPid` 向上走进程树,第一个存在 `sessions/.json` 的祖先即所有者。 + +AppleScript 语法有坑:必须写 `tell s to get variable named "jobPid"`, +写成 `variable named "jobPid" of s` 会报 -1723「不允许访问」。 + +### bg 会话不在任何终端里 + +`kind: bg` 的会话挂在 `claude bg-pty-host` daemon 下: + +``` +23402 (claude bg-spare) → 23371 (claude bg-pty-host) → 70305 (claude daemon) → 1 +``` + +祖先链必然连不上终端。用户看它的地方是某个跑 `claude agents` 的 tab, +那是**查看器进程**,与后台会话没有任何亲缘关系。 + +对 bg 会话破例用 tab 标题匹配 —— 但和旧实现按 `pane_title` 匹配有本质区别: +旧实现匹配 ai-title(每轮都变),这里匹配的是创建时一次性派生的稳定 job 名。 +且只在祖先链未认领的 tab 里找、要求名字长度 ≥ 8。 + +### tmux 窗口名不可信 + +`automatic-rename` 会把窗口名改成前台进程名,而 **claude 的进程名就是它的版本号** —— +实测出现过窗口名变成 `2.1.173`。旧实现用「窗口名 == projects.yaml 的 name」 +判断项目是否运行,实测 9 个窗口只有 2 个匹配得上。身份识别绝不能依赖窗口名。 + +## 打包与签名 + +### 辅助可执行文件不能放 `Contents/MacOS/` + +**症状**:`hubctl` 一执行就返回 `EXIT=137`(SIGKILL),**没有任何错误输出**, +`codesign --verify` 却报告 "valid on disk"。 + +**原因**:`Contents/MacOS/` 按约定只放 `CFBundleExecutable` 指向的那一个可执行文件。 +把辅助工具也放进去、并且用 `codesign --deep --identifier ` 签名时, +两个不同的二进制会声称同一个 bundle identifier。Apple Silicon 上这种签名不匹配 +是 exec 瞬间 SIGKILL,不给任何提示。 + +**做法**:辅助工具放 `Contents/Helpers/`,用自己的 identifier +(`dev.hengjun.claude-hub.hubctl`)单独签名。 + +### 签名顺序:先内层后外层,不用 `--deep` + +```bash +codesign --force --sign "$CERT" --identifier "$BUNDLE_ID.hubctl" App.app/Contents/Helpers/hubctl +codesign --force --sign "$CERT" --identifier "$BUNDLE_ID" App.app +``` + +`--deep` 会把外层的 `--identifier` 强加到所有嵌套代码上,正是上面那个坑的来源。 +先签内层再签外层时,外层签名会自动把 `Helpers/` 的内容封进 `CodeResources`。 + +也不要加 `--options runtime`:自签名 + 未公证的情况下 hardened runtime +不提供任何实际保护,反而引入额外的加载限制。 + +### 构建脚本必须冒烟测试 hubctl + +签名问题不会让 `codesign --verify` 失败,只会在**运行时**静默杀进程。 +所以 `build-swift-app.sh` 最后会真的跑一次 `hubctl doctor`。 + +### bash 变量后面紧跟中文标点要加花括号 + +```bash +log "编译($CONFIG,SDK $SDK_VERSION)…" # 报错:CONFIG?: unbound variable +log "编译(${CONFIG},SDK ${SDK_VERSION})…" # 正确 +``` + +全角逗号的 UTF-8 字节会被 bash 当成变量名的一部分。 + +## 审批链路 + +### 超时阶梯必须严格递增 + +**真实事故**:`ApprovalCoordinator` 和 hubctl 的读超时都设成 60 秒。两者同时到期, +hubctl 先判定"没收到应答"并按 fail-open **放行**了一条 `git push --force origin main` —— +而服务端那一侧其实已经决定拒绝。超时的语义从"安全拒绝"翻转成了"静默放行"。 + +正确的阶梯(见 `HookTimeouts`),**内层先到期,外层留足余量收结果**: + +| 层 | 超时 | 到期后 | +|---|---|---| +| `ApprovalCoordinator` | 60s | 判定拒绝 | +| `HookCoordinator` | 65s | 等 UI 把拒绝送回 | +| hubctl 读 socket | 75s | 等服务端写回 | +| `settings.json` hook | 90s | Claude 杀进程的上限 | + +任何一层反过来,就会出现「内层判定拒绝、外层已超时放行」的危险窗口。 + +### 「连不上」和「连上但没应答」的默认值是相反的 + +- **连不上** = Hub 没运行 = 这次调用压根没被审查过 → **放行**。 + 不能因为工具没开就让用户的每条命令都失败。 +- **连上但没应答** = 服务端认为值得拦截,但决策环节出了问题 → **拒绝**。 + 判据是服务端对非高风险操作**立即**应答,能走到无应答说明它拦下了。 + +所以 `HubSocketClient.send` 返回的是 `HookSendResult` 枚举而不是可选值 —— +把这两种情况混成 `nil` 正是上面那个事故的根源之一。 + +### hook 进程死了,审批卡不会自己消失 + +**症状**:岛上挂着一张审批卡,点了也没用 —— 因为**已经没人接收这个结果了**。 +更糟的是队列先进先出,这张僵尸卡会一直挡住后面真正需要处理的请求。 + +**场景**:Claude 被 Ctrl-C、会话被关、hook 进程超时被 kill。服务端此时仍然 +阻塞在等决策上,它并不知道对端已经没了。 + +**做法**:`HookEvent.clientPid` 本来就一路传过来了,用它做定期扫描 +(`ApprovalCoordinator.startOrphanSweep()`):`kill(pid, 0)` 返回 ESRCH 就判定 +发起方已死,按**拒绝**收尾并撤掉卡片。`EPERM` 说明进程存在但不属于我们,算活着; +`clientPid == 0`(不知道发起方是谁)绝不能当成已死。 + +### 排障时 NSLog 的插值会被当成私有数据 + +`NSLog("...\(text)")` 在 `log show` 里只会显示 ``,等于没打。 +要看到内容必须写成 `NSLog("... %{public}@", text)`。 + +### 在 Bash 工具调用里用 `&` 起的后台进程会被杀掉 + +调用一返回,`cmd &` 起的进程就没了。测试阻塞式 hook(要等用户在岛上点击)时, +这会表现成"hubctl 立刻退出且没有输出"——看起来和"拒绝被吞成放行"一模一样, +极易误判成安全缺陷。要用工具本身的 `run_in_background` 参数。 + +## 数据 + +### 两个 projects.yaml,app 读的不是脚本写的那个 + +**症状**:主窗口的「项目」「用量」「设置」三个页面全是空的,看起来像"功能没做"。 + +**原因**: +- 脚本(`add-project.sh` / `scan-projects.sh`)和 CLAUDE.md 都写 + `~/Documents/code/claude-hub/projects.yaml`(21 个项目,格式正常); +- app 读的是 `~/Library/Application Support/claude-hub/projects.yaml`, + 且那份是坏的 —— 旧版留下 `projects: []`,后来的追加逻辑又往文件尾部加了 117 条: + +```yaml +projects: [] ← 解析器看到这行就认定项目段是空的 + - name: claude-hub ← 后面全部被忽略 +``` + +解析器判的是 `trimmed == "projects:"`,`projects: []` 完全不匹配。 + +**三处都要改**: +1. 顶层键按**键名**匹配,别做整行字面比较; +2. 追加写入前先把 `projects: []` 规范化成 `projects:`,否则每次扫描都在造这种坏文件; +3. **解析失败必须能被看见** —— 文件里有 `- name:` 却解析出 0 条时给出诊断, + UI 上把实际读的文件路径显示出来。静默返回空数组是这次排查花时间最多的地方。 + +### 用量:945 MB 不能整份读进内存 + +`~/.claude/projects` 实测 **945 MB / 341 个 jsonl**。 +原实现对每个文件 `String(contentsOf:)` 再 `split(separator: "\n")`, +跑不出结果,页面永远停在 0 —— 用户读到的就是"这个功能没做"。 + +改成 `FileHandle` 按 1 MB 分块流式解析 + 按 (path, size, mtime) 增量缓存之后: + +| | 耗时 | +|---|---| +| 7 天 · 冷缓存 | 3.6 s | +| 7 天 · 热缓存 | 0.22 s | +| 全部 · 冷缓存 | 4.0 s(363 个会话 / 63474 条消息)| + +分块读有个必须写对的细节:**块边界几乎必然落在某行中间**, +残留的尾巴要留到下一块开头拼接。写错的表现是"大文件统计偏低"—— +数字只是小一点,不会报错,极难发现。 + +## 工程 + +### 日志:`NSLog` 的插值会被隐成 `` + +被坑过两次。`NSLog("会话数 \(count)")` 在 `log show` 里只显示 ``。 + +而 `NSLog("%{public}d", count)` **也是错的** —— `{public}` 是 `os_log` 的格式语法, +`NSLog` 不认,会把 `{public}d` 原样打出来。 + +只有 `Logger` 的 `privacy:` 参数是对的(见 `HubCore/HubLog.swift`): + +```swift +HubLog.app.notice("已就绪 —— \(count, privacy: .public) 个会话") +``` + + log stream --predicate 'subsystem == "dev.hengjun.claude-hub"' --style compact + +### 拿哈希值做算术前必须先取模 + +`abs(seed) * 37` 在 seed 来自哈希(量级接近 `Int.max`)时直接整数溢出, +Swift trap —— 表现是 **app 一显示小人就闪退**。 + +而当时的单元测试喂的是 `tick * 7919` 这种小整数,完全没碰到。 +**测试的输入不真实 = 测试没在测东西。** 现在测试里固定包含 +`SpriteSeed.stable(...)` 的真实量级值以及 `Int.max` / `Int.min` 边界。 + +另外 `abs(Int.min)` 会 trap,用 `.magnitude` 不会。 + +### 会话形象要用跨进程稳定的哈希 + +Swift 的 `String.hashValue` **每次进程启动都重新加盐**。 +用它派生像素小人的发色/衣服,每次重开 app 所有人的形象都会换一遍, +"记住橙头发那个是 acme-admin"这件事根本立不住。用 FNV-1a 自己算(`SpriteSeed`)。 + +### 分段拼装的像素图要断言尺寸 + +小人是「发型 + 脸 + 身体 + 笔记本」四段拼出来的 16×16。 +某段少一行、某行少一格,Canvas 只会安静地少画几个方块,画面上表现为"有点歪"。 +`SpriteLibraryTests` 对每种发型 × 每个姿势断言 16 行 × 16 列, +第一次跑就抓到了 640 处越界。 + +### macOS 文件系统大小写不敏感 + +`Sources/hubprobe` 和 `Sources/HubProbe` 是**同一个目录**。 +可执行 target 的目录名不能只靠大小写和库 target 区分。 + +### 测试 bundle 被系统策略拒绝加载 + +**症状**:`swift test` 报 `library load denied by system policy` / +`code signature ... not valid for use in process`,测试一个都跑不了。 + +macOS 26 对 dylib 加载的签名校验更严,`.xctest` bundle 在增量链接后 +签名会失效。清掉重建再重签即可: + +```bash +rm -rf .build/arm64-apple-macosx/debug/HubKitPackageTests.xctest +swift build --build-tests +codesign --force --sign - .build/arm64-apple-macosx/debug/HubKitPackageTests.xctest +swift test --skip-build +``` + +### 开发期环境变量 + +- `HUB_ISLAND_STATE=hover|expanded` —— 钉住某个形态,方便截图核对布局 +- `HUB_DEBUG_CONTENT=1` —— 给内容框描红色底,一眼看出它被放在哪、有没有被裁掉 +- `HUB_DEBUG=1` —— hubctl 把收到的 `HookSendResult` 打到 stderr。 + 排查"拒绝有没有被吞成放行"时唯一能直接看到真相的地方;走 stderr 所以 + 开着也不会被 Claude 当成 hook 的决策输出。 + +### 交互缺陷必须用真实鼠标验证 + +这一轮的全部交互缺陷(点击被系统吞掉、整行只有文字可点、外部点击监听误伤自己) +在截图上**一个都看不出来** —— 布局、配色、内容全是对的,只是点不动。 +必须真的移动鼠标、真的点下去再截图比对。 + +配套的坑:**先确认光标在哪块屏幕上**。光标跑到外接屏时所有移动都落空, +表现成"悬停功能坏了",会浪费大量时间去查根本没坏的代码。 diff --git a/HubKit/Package.swift b/HubKit/Package.swift new file mode 100644 index 0000000..4915328 --- /dev/null +++ b/HubKit/Package.swift @@ -0,0 +1,63 @@ +// swift-tools-version: 6.0 +import PackageDescription + +// 平台底线定在 macOS 26:灵动岛依赖 Liquid Glass(.glassEffect / GlassEffectContainer), +// 这是 macOS 26 才有的 API。本项目是给这台机器用的个人工具,不需要向下兼容。 +let package = Package( + name: "HubKit", + platforms: [.macOS("26.0")], + products: [ + .library(name: "HubCore", targets: ["HubCore"]), + .library(name: "HubProbe", targets: ["HubProbe"]), + .library(name: "HubJump", targets: ["HubJump"]), + .library(name: "HubIPC", targets: ["HubIPC"]), + .library(name: "HubProjects", targets: ["HubProjects"]), + .library(name: "HubUI", targets: ["HubUI"]), + .executable(name: "hubprobe", targets: ["HubProbeCLI"]), + .executable(name: "hubctl", targets: ["hubctl"]), + .executable(name: "ClaudeHub", targets: ["ClaudeHubApp"]), + ], + targets: [ + // 纯数据模型,无副作用,不依赖任何系统调用。 + .target(name: "HubCore"), + + // 会话探测:读 ~/.claude/sessions/*.json、进程树、tmux 拓扑。 + .target(name: "HubProbe", dependencies: ["HubCore"]), + + // 跳转引擎:从 sessionId 定位并前置到具体终端 tab。 + .target(name: "HubJump", dependencies: ["HubCore", "HubProbe"]), + + // hook ↔ app 的 Unix socket 协议、风险分级。 + // 刻意不依赖 HubProbe:hubctl 要保持极小、启动极快(每次工具调用都跑一遍)。 + .target(name: "HubIPC", dependencies: ["HubCore"]), + + // projects.yaml、git 状态、token 用量、终端派发。 + .target(name: "HubProjects", dependencies: ["HubCore", "HubProbe"]), + + // 灵动岛与主窗口的 SwiftUI 层。 + .target( + name: "HubUI", + dependencies: ["HubCore", "HubProbe", "HubJump", "HubIPC", "HubProjects"] + ), + + // 开发期 CLI:把探测和跳转的结果打出来,便于在没有 UI 时验证。 + // 目录名不能叫 hubprobe —— macOS 文件系统大小写不敏感,会和 HubProbe 撞。 + .executableTarget( + name: "HubProbeCLI", + dependencies: ["HubCore", "HubProbe", "HubJump", "HubProjects"] + ), + + // Claude Code hook 的执行体。每次工具调用都会跑,必须小而快。 + .executableTarget(name: "hubctl", dependencies: ["HubIPC"]), + + // app 本体。刻意做薄:只有 @main 和窗口装配,逻辑全在 HubKit 各库里。 + .executableTarget(name: "ClaudeHubApp", dependencies: ["HubUI"]), + + .testTarget(name: "HubCoreTests", dependencies: ["HubCore"]), + .testTarget(name: "HubProbeTests", dependencies: ["HubProbe"]), + .testTarget(name: "HubJumpTests", dependencies: ["HubJump"]), + .testTarget(name: "HubIPCTests", dependencies: ["HubIPC"]), + .testTarget(name: "HubProjectsTests", dependencies: ["HubProjects"]), + .testTarget(name: "HubUITests", dependencies: ["HubUI"]), + ] +) diff --git a/HubKit/Sources/ClaudeHubApp/main.swift b/HubKit/Sources/ClaudeHubApp/main.swift new file mode 100644 index 0000000..014589a --- /dev/null +++ b/HubKit/Sources/ClaudeHubApp/main.swift @@ -0,0 +1,199 @@ +import AppKit +import HubCore +import HubIPC +import HubProjects +import HubUI +import SwiftUI + +// Claude Hub —— 本地 AI 编程 agent 的调度中心。 +// +// 这个 target 刻意做得很薄:只负责启动 NSApplication、装配灵动岛和主窗口、 +// 挂托盘图标。所有逻辑都在 HubKit 的各个库里,那些库可以 `swift test`,这里不行。 + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + + private let store = SessionStore() + private let projects = ProjectStore() + private let approvals = ApprovalCoordinator() + private let notifications = HubNotificationCenter() + + private lazy var island = IslandController( + store: store, approvals: approvals, projects: projects + ) + private lazy var hooks = HookCoordinator( + store: store, approvals: approvals, + notifications: notifications, projects: projects + ) + private lazy var dispatch = TerminalDispatch() + + private var statusItem: NSStatusItem? + private var mainWindow: NSWindow? + private var approvalObserver: Task? + + func applicationDidFinishLaunching(_ notification: Notification) { + // .accessory:不在 Dock 显示图标,也不占应用切换器的位置。 + // 岛和托盘就是这个 app 的全部门面。 + NSApp.setActivationPolicy(.accessory) + + projects.load() + projects.startGitPolling() + store.start() + + // 通知点击 → 精准跳回对应会话的终端 tab。 + // 这是本次重写要修的核心 bug:旧实现的 terminal-notifier 拿不到点击回调。 + notifications.onActivate = { [weak self] sessionId in + self?.island.jumpAndCollapse(sessionId: sessionId) + } + notifications.requestAuthorization() + + hooks.onIntrusion = { [weak self] event in + self?.island.presentIntrusion(event) + } + hooks.onApprovalNeeded = { [weak self] in + self?.island.presentApproval() + } + hooks.start() + // 清掉发起方已经死了的审批卡,见 ApprovalCoordinator.startOrphanSweep()。 + approvals.startOrphanSweep() + + // 岛上的「项目」栏可以直接启动 —— 「开发 xxx」是这个工具最高频的动作, + // 不该要求先从菜单栏打开主窗口。 + island.onLaunch = { [weak self] project, mode in + self?.launch(project, mode) + } + + island.show() + installStatusItem() + observeApprovalQueue() + + // 用 Logger 而不是 NSLog,且显式 `privacy: .public` —— 否则统一日志 + // 会把插值全部隐成 ,排障时等于没打。见 HubLog 的说明。 + let sessionCount = store.sessions.count + let projectCount = projects.projects.count + let source = projects.sourceURL?.path ?? "找不到 projects.yaml" + HubLog.app.notice(""" + 已就绪 —— \(sessionCount, privacy: .public) 个会话,\ + \(projectCount, privacy: .public) 个项目(\(source, privacy: .public)) + """) + } + + func applicationWillTerminate(_ notification: Notification) { + approvalObserver?.cancel() + approvals.stopOrphanSweep() + hooks.stop() + store.stop() + projects.stopGitPolling() + island.hide() + } + + /// 审批队列空了就把岛收回去。 + /// + /// 用轮询而不是回调:`ApprovalCoordinator` 的 resume 发生在 continuation 里, + /// 让它反过来通知 UI 会把两者耦合起来。250ms 的轮询在这里完全够用, + /// 而且只在有审批时才有意义。 + private func observeApprovalQueue() { + approvalObserver = Task { @MainActor [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(250)) + self?.island.dismissApprovalIfDone() + } + } + } + + // MARK: - 托盘 + + private func installStatusItem() { + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + item.button?.image = NSImage( + systemSymbolName: "circle.hexagongrid.fill", + accessibilityDescription: "Claude Hub" + ) + + let menu = NSMenu() + menu.addItem( + withTitle: "打开主窗口", action: #selector(openMainWindow), keyEquivalent: "o" + ).target = self + menu.addItem( + withTitle: "展开灵动岛", action: #selector(expandIsland), keyEquivalent: "e" + ).target = self + menu.addItem(.separator()) + menu.addItem( + withTitle: "刷新", action: #selector(refresh), keyEquivalent: "r" + ).target = self + menu.addItem(.separator()) + menu.addItem( + withTitle: "退出 Claude Hub", action: #selector(quit), keyEquivalent: "q" + ).target = self + item.menu = menu + statusItem = item + } + + @objc private func refresh() { + store.refresh() + projects.refreshGit() + } + + @objc private func expandIsland() { + island.expand() + } + + @objc private func quit() { + NSApp.terminate(nil) + } + + // MARK: - 主窗口 + + @objc private func openMainWindow() { + if let mainWindow { + // 主窗口要抢焦点,所以这里才 activate;岛是 nonactivating 的,不受影响。 + NSApp.activate(ignoringOtherApps: true) + mainWindow.makeKeyAndOrderFront(nil) + return + } + + let root = MainWindowView( + store: store, + projects: projects, + approvals: approvals, + onJump: { [weak self] sessionId in + self?.store.jump(to: sessionId) + }, + onLaunch: { [weak self] project, mode in + self?.launch(project, mode) + } + ) + + let window = NSWindow( + contentRect: CGRect(x: 0, y: 0, width: 940, height: 640), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.title = "Claude Hub" + window.titlebarAppearsTransparent = true + window.contentView = NSHostingView(rootView: root) + window.center() + window.isReleasedWhenClosed = false + window.minSize = CGSize(width: 780, height: 520) + + mainWindow = window + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + } + + private func launch(_ project: Project, _ mode: LaunchMode) { + let dispatch = self.dispatch + let name = project.name + let path = project.expandedPath + // AppleScript + tmux 有跨进程成本,别卡住 UI。 + Task.detached(priority: .userInitiated) { + dispatch.open(project: name, path: path, mode: mode) + } + } +} + +let app = NSApplication.shared +let delegate = AppDelegate() +app.delegate = delegate +app.run() diff --git a/HubKit/Sources/HubCore/AgentSession.swift b/HubKit/Sources/HubCore/AgentSession.swift new file mode 100644 index 0000000..07a13b4 --- /dev/null +++ b/HubKit/Sources/HubCore/AgentSession.swift @@ -0,0 +1,164 @@ +import Foundation + +/// 会话状态。取值来自 Claude Code 自己写的 `~/.claude/sessions/.json`, +/// 不是我们推断出来的 —— 这是权威来源。 +/// +/// 排序即 UI 优先级:waiting 永远排最前(它是唯一需要用户立刻行动的状态)。 +public enum SessionStatus: String, Codable, Sendable, CaseIterable, Comparable { + /// 等用户输入或授权。伴随 `waitingFor` 说明原因。 + case waiting + /// 正在干活(模型推理 / 工具调用)。 + case busy + /// 正在跑 shell 命令。 + case shell + /// 空闲。 + case idle + /// 未来 Claude Code 新增的取值。保留原字符串,不丢信息。 + case unknown + + /// 未知状态排在最后,避免新版本 Claude 引入新取值时抢占 waiting 的位置。 + private var rank: Int { + switch self { + case .waiting: return 0 + case .busy: return 1 + case .shell: return 2 + case .idle: return 3 + case .unknown: return 4 + } + } + + public static func < (lhs: SessionStatus, rhs: SessionStatus) -> Bool { + lhs.rank < rhs.rank + } + + /// 是否算「活跃」——影响像素小人时钟频率与轮询节奏。 + public var isActive: Bool { + self == .busy || self == .shell || self == .waiting + } +} + +/// 会话类型。`bg` 是通过后台任务派发的,带 `jobId`。 +public enum SessionKind: String, Codable, Sendable { + case interactive + case bg + case unknown +} + +/// 一个正在运行的 AI agent 会话。 +/// +/// 身份键是 `sessionId`(UUID)而**不是** `pid`:`--resume` 之后进程会换 PID, +/// 但 sessionId 不变。用 PID 做身份会让 UI 在 resume 时闪一下(旧条目消失、新条目出现)。 +public struct AgentSession: Identifiable, Hashable, Sendable { + /// Claude Code 的 session UUID。整个系统的主键。 + public let sessionId: String + /// 当前进程 PID。会随 resume 变化,只用于定位进程树,不做身份。 + public let pid: pid_t + /// 会话工作目录。 + public let cwd: String + /// Claude 自己派生的会话名,如 `storefront-a-c3`、`o3-ui-memory-idempotent-fixes`。 + public let name: String? + public let status: SessionStatus + /// 原始 status 字符串。`status == .unknown` 时用它排查。 + public let rawStatus: String + public let kind: SessionKind + /// `waiting` 时的原因,如 `"input needed"`。 + public let waitingFor: String? + public let jobId: String? + /// Claude Code 版本号。注意:这也是 claude 的进程名。 + public let version: String? + public let startedAt: Date? + /// 状态最后一次变化的时刻。waiting 组内排序用它(等得最久的排最前)。 + public let statusUpdatedAt: Date? + public let updatedAt: Date? + + public var id: String { sessionId } + + public init( + sessionId: String, + pid: pid_t, + cwd: String, + name: String? = nil, + status: SessionStatus, + rawStatus: String, + kind: SessionKind, + waitingFor: String? = nil, + jobId: String? = nil, + version: String? = nil, + startedAt: Date? = nil, + statusUpdatedAt: Date? = nil, + updatedAt: Date? = nil + ) { + self.sessionId = sessionId + self.pid = pid + self.cwd = cwd + self.name = name + self.status = status + self.rawStatus = rawStatus + self.kind = kind + self.waitingFor = waitingFor + self.jobId = jobId + self.version = version + self.startedAt = startedAt + self.statusUpdatedAt = statusUpdatedAt + self.updatedAt = updatedAt + } + + /// 从 cwd 推出的项目名兜底值。真正的项目名由 projects.yaml 反查,查不到才用这个。 + public var fallbackProjectName: String { + URL(fileURLWithPath: cwd).lastPathComponent + } +} + +// MARK: - 解析 + +extension AgentSession { + /// `~/.claude/sessions/.json` 的线格式。 + /// + /// 全部字段设为可选 —— 这个文件由 Claude Code 写,schema 可能随版本变化, + /// 少一个字段不该让整个探测失败。只有 `pid` / `sessionId` / `cwd` 是硬要求。 + struct Wire: Decodable { + let pid: Int32? + let sessionId: String? + let cwd: String? + let name: String? + let status: String? + let kind: String? + let waitingFor: String? + let jobId: String? + let version: String? + let startedAt: Double? // 毫秒时间戳 + let statusUpdatedAt: Double? + let updatedAt: Double? + } + + /// 解析单个 sessions/*.json。字段缺失或格式不符时返回 nil,由调用方跳过。 + public static func decode(from data: Data) -> AgentSession? { + guard let wire = try? JSONDecoder().decode(Wire.self, from: data), + let pid = wire.pid, + let sessionId = wire.sessionId, !sessionId.isEmpty, + let cwd = wire.cwd + else { return nil } + + let raw = wire.status ?? "" + return AgentSession( + sessionId: sessionId, + pid: pid, + cwd: cwd, + name: wire.name, + status: SessionStatus(rawValue: raw) ?? .unknown, + rawStatus: raw, + kind: wire.kind.flatMap(SessionKind.init(rawValue:)) ?? .unknown, + waitingFor: wire.waitingFor, + jobId: wire.jobId, + version: wire.version, + startedAt: Self.date(fromMillis: wire.startedAt), + statusUpdatedAt: Self.date(fromMillis: wire.statusUpdatedAt), + updatedAt: Self.date(fromMillis: wire.updatedAt) + ) + } + + private static func date(fromMillis millis: Double?) -> Date? { + guard let millis, millis > 0 else { return nil } + return Date(timeIntervalSince1970: millis / 1000) + } +} diff --git a/HubKit/Sources/HubCore/HubLog.swift b/HubKit/Sources/HubCore/HubLog.swift new file mode 100644 index 0000000..e6a1599 --- /dev/null +++ b/HubKit/Sources/HubCore/HubLog.swift @@ -0,0 +1,32 @@ +import Foundation +import os + +/// 统一日志。 +/// +/// ## 为什么不用 `NSLog` +/// +/// 被坑过两次:`NSLog("会话数 \(count)")` 在 `log show` 里显示成 `` —— +/// 统一日志默认把所有插值当敏感数据。排障时看到一串 ``,等于没打日志。 +/// +/// 而 `NSLog("%{public}d", count)` 也是错的:`{public}` 是 `os_log` 的格式语法, +/// `NSLog` 不认,会把 `{public}d` 原样打出来。**只有 `Logger` 的 `privacy:` 参数 +/// 是对的做法。** +/// +/// 这里的内容都是本机路径、会话数、决策结果,没有敏感信息,所以一律 public。 +public enum HubLog { + + private static let subsystem = "dev.hengjun.claude-hub" + + public static let app = Logger(subsystem: subsystem, category: "app") + public static let island = Logger(subsystem: subsystem, category: "island") + public static let ipc = Logger(subsystem: subsystem, category: "ipc") + public static let jump = Logger(subsystem: subsystem, category: "jump") + + /// 看日志的命令,写在这里省得每次去翻。 + /// + /// log stream --predicate 'subsystem == "dev.hengjun.claude-hub"' --style compact + /// log show --predicate 'subsystem == "dev.hengjun.claude-hub"' --last 10m --style compact + public static let howToRead = """ + log stream --predicate 'subsystem == "dev.hengjun.claude-hub"' --style compact + """ +} diff --git a/HubKit/Sources/HubIPC/HookEvent.swift b/HubKit/Sources/HubIPC/HookEvent.swift new file mode 100644 index 0000000..87ee520 --- /dev/null +++ b/HubKit/Sources/HubIPC/HookEvent.swift @@ -0,0 +1,136 @@ +import Foundation + +/// Claude Code hook 送过来的事件。 +/// +/// 字段直接对应 hook 的 stdin JSON(已查证官方文档)。旧实现的 bash hook +/// **完全没读 stdin**,把 `session_id` / `cwd` / `last_assistant_message` +/// 全丢了,只能靠 `tmux display-message -p '#W'` 猜项目名 —— 那还漏了 +/// `-t "$TMUX_PANE"`,拿到的是当前 active 窗口而不是自己所在窗口。 +/// 用户切个 tab,通知就挂到别的项目上了。 +public struct HookEvent: Codable, Sendable { + + public enum Kind: String, Codable, Sendable { + /// Claude 完成一轮回复。 + case stop + /// 通知类事件(需要授权、空闲提醒、子 agent 完成等)。 + case notification + /// 工具调用前。**阻塞式** —— hubctl 会等岛上的决策。 + case preToolUse + /// 会话结束,用于即时清理 UI。 + case sessionEnd + } + + public let kind: Kind + /// 请求唯一 id。审批的应答靠它对上号。 + public let requestId: String + + // MARK: Claude 提供的通用字段 + + public let sessionId: String + public let cwd: String + public let transcriptPath: String? + public let permissionMode: String? + + // MARK: stop + + /// Claude 这一轮的最终回复文本。 + /// 通知正文直接用它,替掉旧实现那 5 条随机中文文案(信息量为零)。 + public let lastAssistantMessage: String? + public let stopReason: String? + + // MARK: notification + + /// `permission_prompt` / `idle_prompt` / `agent_completed` 等。 + public let notificationType: String? + public let message: String? + + // MARK: preToolUse + + public let toolName: String? + /// 工具参数里最能代表"这次要干什么"的那个值。 + /// Bash 取 command,Write/Edit 取 file_path,WebFetch 取 url。 + public let toolSummary: String? + public let toolUseId: String? + + // MARK: hubctl 补充的环境信息 + + /// `$TMUX_PANE`。在 tmux 里跑时能直接定位 pane,不用猜。 + public let tmuxPane: String? + /// hook 进程自己的 PID,用于反查是哪个 claude 进程。 + public let clientPid: Int32? + + public init( + kind: Kind, + requestId: String, + sessionId: String, + cwd: String, + transcriptPath: String? = nil, + permissionMode: String? = nil, + lastAssistantMessage: String? = nil, + stopReason: String? = nil, + notificationType: String? = nil, + message: String? = nil, + toolName: String? = nil, + toolSummary: String? = nil, + toolUseId: String? = nil, + tmuxPane: String? = nil, + clientPid: Int32? = nil + ) { + self.kind = kind + self.requestId = requestId + self.sessionId = sessionId + self.cwd = cwd + self.transcriptPath = transcriptPath + self.permissionMode = permissionMode + self.lastAssistantMessage = lastAssistantMessage + self.stopReason = stopReason + self.notificationType = notificationType + self.message = message + self.toolName = toolName + self.toolSummary = toolSummary + self.toolUseId = toolUseId + self.tmuxPane = tmuxPane + self.clientPid = clientPid + } + + /// 项目名兜底。真正的项目名由 projects.yaml 反查 cwd 得到。 + public var fallbackProjectName: String { + URL(fileURLWithPath: cwd).lastPathComponent + } +} + +/// 审批结论。只有 `preToolUse` 会收到应答。 +public struct HookDecision: Codable, Sendable { + public enum Verdict: String, Codable, Sendable { + case allow + case deny + /// 交回 Claude 自己的权限流程。 + case ask + } + + public let verdict: Verdict + public let reason: String? + + public init(verdict: Verdict, reason: String? = nil) { + self.verdict = verdict + self.reason = reason + } + + /// 放行。Hub 没运行、超时以外的异常、非高风险操作都走这个。 + public static let allow = HookDecision(verdict: .allow) +} + +/// socket 路径。 +public enum HubSocket { + public static var path: String { + let dir = FileManager.default + .homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/claude-hub", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent("hub.sock").path + } + + /// Unix domain socket 的路径长度上限(`sun_path` 是 104 字节)。 + /// 超了 bind 会静默失败,所以建立前必须检查。 + public static let maxPathLength = 103 +} diff --git a/HubKit/Sources/HubIPC/HubSocketServer.swift b/HubKit/Sources/HubIPC/HubSocketServer.swift new file mode 100644 index 0000000..8aeea98 --- /dev/null +++ b/HubKit/Sources/HubIPC/HubSocketServer.swift @@ -0,0 +1,130 @@ +import Darwin +import Foundation +import HubCore + +/// Hub 侧的 socket 服务端。 +/// +/// 每条连接的生命周期:hubctl 连上 → 发一行事件 JSON → 服务端处理 +/// →(仅 preToolUse)回一行决策 JSON → 关闭。 +/// +/// 连接短、并发低,所以每条连接用一个阻塞的处理块跑在并发队列上就够了, +/// 不需要引入 async 事件循环。 +public final class HubSocketServer: @unchecked Sendable { + + /// 事件处理器。返回值只有 `preToolUse` 会被用到。 + /// + /// 处理器可能被长时间阻塞(审批要等用户点击),所以它跑在自己的连接线程上, + /// 不会挡住其他连接。 + public typealias Handler = @Sendable (HookEvent) -> HookDecision? + + private let path: String + private let handler: Handler + private var listenFd: Int32 = -1 + private var acceptSource: DispatchSourceRead? + private let queue = DispatchQueue(label: "hub.ipc.accept") + private let workQueue = DispatchQueue(label: "hub.ipc.work", attributes: .concurrent) + + public init(path: String = HubSocket.path, handler: @escaping Handler) { + self.path = path + self.handler = handler + } + + public func start() throws { + let fd = try UnixSocket.listen(at: path) + listenFd = fd + + let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue) + source.setEventHandler { [weak self] in self?.acceptOne() } + source.setCancelHandler { close(fd) } + source.resume() + acceptSource = source + } + + public func stop() { + acceptSource?.cancel() + acceptSource = nil + listenFd = -1 + unlink(path) + } + + private func acceptOne() { + let clientFd = accept(listenFd, nil, nil) + guard clientFd >= 0 else { return } + + workQueue.async { [handler] in + defer { close(clientFd) } + + // 读事件本身要有上界:hubctl 连上却不发数据(被 SIGSTOP 了之类) + // 不该永久占住一个工作线程。 + guard let line = UnixSocket.readLine(fd: clientFd, timeout: 10), + let data = line.data(using: .utf8), + let event = try? JSONDecoder().decode(HookEvent.self, from: data) + else { return } + + let decision = handler(event) + + guard event.kind == .preToolUse else { return } + let payload = decision ?? .allow + if let encoded = try? JSONEncoder().encode(payload), + let text = String(data: encoded, encoding: .utf8) { + HubLog.ipc.notice("回写决策 \(text, privacy: .public)") + UnixSocket.writeLine(fd: clientFd, text) + } + } + } +} + +/// 发送结果。 +/// +/// 「连不上」和「连上了但没回应」必须区分开,因为两者的安全默认值是相反的: +/// - 连不上 = Hub 没运行 = 这次调用压根没被审查过 → **放行**(fail-open, +/// 不能因为 Hub 没开就让用户的每个命令都失败); +/// - 连上了但没回应 = Hub 认为这次操作值得拦截,但决策环节出了问题 +/// → **拒绝**(拒绝可恢复,放行不可逆)。 +public enum HookSendResult: Sendable { + /// 服务端明确给了结论。 + case decided(HookDecision) + /// 服务端收下了但不需要应答(非 preToolUse 事件)。 + case acknowledged + /// 连不上 —— Hub 没运行。 + case unreachable + /// 连上了、发出去了,但超时没等到应答。 + case noResponse +} + +/// hubctl 侧的客户端。 +public enum HubSocketClient { + + /// 发事件;`preToolUse` 会等决策。 + public static func send( + _ event: HookEvent, + path: String = HubSocket.path, + waitForDecision: Bool, + timeout: TimeInterval + ) -> HookSendResult { + guard let data = try? JSONEncoder().encode(event), + let line = String(data: data, encoding: .utf8) + else { return .unreachable } + + guard let fd = try? UnixSocket.connect(to: path) else { return .unreachable } + defer { close(fd) } + + guard UnixSocket.writeLine(fd: fd, line) else { return .unreachable } + guard waitForDecision else { return .acknowledged } + + guard let response = UnixSocket.readLine(fd: fd, timeout: timeout), + let responseData = response.data(using: .utf8), + let decision = try? JSONDecoder().decode(HookDecision.self, from: responseData) + else { return .noResponse } + + return .decided(decision) + } + + /// Hub 是否在运行。用连接探测而不是查进程 —— 进程活着但 socket 没起来 + /// (比如刚启动还没 bind)同样应该算"不可用"。 + public static func isHubRunning(path: String = HubSocket.path) -> Bool { + guard let fd = try? UnixSocket.connect(to: path) else { return false } + close(fd) + return true + } +} diff --git a/HubKit/Sources/HubIPC/RiskClassifier.swift b/HubKit/Sources/HubIPC/RiskClassifier.swift new file mode 100644 index 0000000..55bbfba --- /dev/null +++ b/HubKit/Sources/HubIPC/RiskClassifier.swift @@ -0,0 +1,129 @@ +import Foundation + +/// 操作的风险等级。 +public enum RiskLevel: String, Codable, Sendable, Comparable { + /// 日常操作。直接放行,不打扰。 + case normal + /// 危险但可恢复。默认只记录不拦截。 + case dangerous + /// 不可逆。拦截并要求按住确认。 + case irreversible + + private var rank: Int { + switch self { + case .normal: return 0 + case .dangerous: return 1 + case .irreversible: return 2 + } + } + + public static func < (lhs: RiskLevel, rhs: RiskLevel) -> Bool { lhs.rank < rhs.rank } +} + +/// 判断一次工具调用有多危险。 +/// +/// 设计取向:**默认只拦 `irreversible`**。 +/// +/// 用户全局是 `permissions.defaultMode = auto` 且常用 `--dangerously-skip-permissions`, +/// 图的就是不被打断。如果把 `dangerous` 一档也拦上,按日常用量每天会弹 3–8 次, +/// 那等于把用户特意关掉的东西又装回去,结果必然是他把整个功能关掉。 +/// +/// 所以 `dangerous` 先只进审批日志,攒一周数据之后用户自己决定要不要开启拦截。 +public struct RiskClassifier: Sendable { + + /// 达到或超过这个等级才拦截。 + public let interceptThreshold: RiskLevel + + public init(interceptThreshold: RiskLevel = .irreversible) { + self.interceptThreshold = interceptThreshold + } + + public func shouldIntercept(_ level: RiskLevel) -> Bool { + level >= interceptThreshold && level != .normal + } + + /// 对一次工具调用定级。 + public func classify(toolName: String?, summary: String?) -> RiskLevel { + guard let toolName else { return .normal } + + // 只读工具永远不拦。把它们显式列出来,避免正则在文件内容里误命中 —— + // 比如 Read 一个讲 `rm -rf` 的 README 不该触发审批。 + let readOnlyTools: Set = [ + "Read", "Grep", "Glob", "WebFetch", "WebSearch", "TodoWrite", "Task", + ] + if readOnlyTools.contains(toolName) { return .normal } + + guard let summary, !summary.isEmpty else { return .normal } + let text = summary.lowercased() + + if Self.irreversiblePatterns.contains(where: { text.range(of: $0, options: .regularExpression) != nil }) { + return .irreversible + } + if Self.dangerousPatterns.contains(where: { text.range(of: $0, options: .regularExpression) != nil }) { + return .dangerous + } + return .normal + } + + // MARK: - 规则集 + + /// 不可逆:数据没了就是没了,或者影响到线上。 + /// + /// 每条都写成正则并加了边界,尽量避免误伤: + /// 比如 `rm -rf /` 要求斜杠后面不能再跟路径字符,这样 `rm -rf /tmp/x` 不会命中。 + static let irreversiblePatterns: [String] = [ + // rm -rf 到根、到家目录、或到某个盘符根 + #"\brm\s+(-[a-z]*\s+)*-?[a-z]*r[a-z]*f?[a-z]*\s+/\s*$"#, + #"\brm\s+.*-[a-z]*r[a-z]*\s+(~|\$home)\s*/?\s*$"#, + #"\brm\s+.*\s/\*"#, + // 强推保护分支 + #"git\s+push\s+.*(--force|-f)\b.*\b(main|master|prod|production|release)\b"#, + #"git\s+push\s+.*\b(main|master|prod|production|release)\b.*(--force|-f)\b"#, + // 删库 + #"drop\s+database\b"#, + #"drop\s+schema\b"#, + // 生产部署 / 集群删除 + #"kubectl\s+delete\b"#, + #"\bdeploy\S*\s+.*\bprod(uction)?\b"#, + #"\bterraform\s+destroy\b"#, + // 磁盘级操作 + #"\bmkfs\b"#, + #"\bdd\s+.*of=/dev/"#, + #"diskutil\s+(erase|reformat)"#, + ] + + /// 危险但可恢复。 + static let dangerousPatterns: [String] = [ + #"\brm\s+(-[a-z]*\s+)*-[a-z]*r[a-z]*f"#, // 任意 rm -rf + #"git\s+push\s+.*(--force\b|--force-with-lease\b|\s-f\b)"#, + #"git\s+reset\s+--hard\b"#, + #"git\s+clean\s+.*-[a-z]*f"#, + #"git\s+branch\s+-D\b"#, + #"\bchmod\s+(-[a-z]+\s+)*777\b"#, + #"\bdrop\s+table\b"#, + #"\btruncate\s+table\b"#, + #"\bdelete\s+from\b(?!.*\bwhere\b)"#, // 无 WHERE 的 DELETE + #"curl\s+[^|]*\|\s*(sudo\s+)?(ba|z|k)?sh"#, // curl | sh + #"wget\s+[^|]*\|\s*(sudo\s+)?(ba|z|k)?sh"#, + #"\bnpm\s+publish\b"#, + #"\bdocker\s+(system\s+)?prune\b"#, + #"\bsudo\b"#, + #"\.ssh/(id_|authorized_keys)"#, + #"\.env\b"#, + #"\bkill(all)?\s+-9\b"#, + ] + + /// 从工具参数里挑出最能代表"这次要干什么"的那个值。 + /// + /// hubctl 侧调用,把提取好的字符串放进 `HookEvent.toolSummary`, + /// 这样服务端不用背着完整的 tool_input schema。 + public static func summarize(toolName: String, input: [String: Any]) -> String? { + // 顺序有意义:command 最能说明问题,其次是路径。 + for key in ["command", "file_path", "path", "url", "pattern", "notebook_path"] { + if let value = input[key] as? String, !value.isEmpty { + return value + } + } + return input["description"] as? String + } +} diff --git a/HubKit/Sources/HubIPC/Timeouts.swift b/HubKit/Sources/HubIPC/Timeouts.swift new file mode 100644 index 0000000..47b7c87 --- /dev/null +++ b/HubKit/Sources/HubIPC/Timeouts.swift @@ -0,0 +1,39 @@ +import Foundation + +/// 审批链路上的超时阶梯。 +/// +/// ## 顺序必须严格递增,否则安全默认值会反过来 +/// +/// 实测踩到的坑:一开始 `ApprovalCoordinator` 和 hubctl 的读超时都设成 60 秒, +/// 结果两者同时到期,hubctl 先判定"没收到应答"并按 fail-open **放行**了一条 +/// `git push --force origin main` —— 而服务端那一侧其实已经决定拒绝。 +/// 超时的语义从"安全拒绝"变成了"静默放行",正好是设计意图的反面。 +/// +/// 正确的阶梯:**内层先到期,外层留足余量收结果**。 +/// +/// ``` +/// 60s ApprovalCoordinator 用户没反应 → 判定拒绝 +/// 65s HookCoordinator 等 UI 那边把拒绝送回来 +/// 75s hubctl 读 socket 等服务端把拒绝写回来 +/// 90s settings.json Claude 杀掉 hook 进程的上限 +/// ``` +/// +/// 每一层都必须**大于**它内层的那一层。任何一层反过来,就会出现 +/// 「内层判定拒绝、外层已经超时放行」的危险窗口。 +public enum HookTimeouts { + + /// 用户在岛上决策的时限。到点自动拒绝。 + public static let userDecision: TimeInterval = 60 + + /// 服务端等 UI 把结论送回来。 + public static let serverBridge: TimeInterval = 65 + + /// hubctl 等服务端应答。 + public static let clientRead: TimeInterval = 75 + + /// 写进 settings.json 的 hook 超时。Claude 超过这个时间会杀掉 hubctl。 + /// + /// 必须大于 `clientRead`,否则 hubctl 还没来得及输出拒绝就被杀了, + /// 而被杀的 hook 等同于没有输出 —— 也就是放行。 + public static let hookProcess: Int = 90 +} diff --git a/HubKit/Sources/HubIPC/UnixSocket.swift b/HubKit/Sources/HubIPC/UnixSocket.swift new file mode 100644 index 0000000..fd1180c --- /dev/null +++ b/HubKit/Sources/HubIPC/UnixSocket.swift @@ -0,0 +1,157 @@ +import Darwin +import Foundation + +/// POSIX Unix domain socket 的薄封装。 +/// +/// 为什么用 socket 而不是文件轮询(旧实现是往 `/tmp/hub-signals/events/` 丢 JSON): +/// - 对端断开可以感知 —— Claude 进程死了,挂起的审批卡能自动撤掉; +/// - 连不上就等于"Hub 没在跑",零歧义,hubctl 可以立刻放行而不是傻等; +/// - 审批需要**双向**通信,文件方案得靠两边轮询,延迟和竞态都难处理。 +enum UnixSocket { + + enum SocketError: Error, CustomStringConvertible { + case pathTooLong(String) + case createFailed(Int32) + case bindFailed(Int32) + case listenFailed(Int32) + case connectFailed(Int32) + + var description: String { + switch self { + case .pathTooLong(let p): + return "socket 路径超过 \(HubSocket.maxPathLength) 字节:\(p)" + case .createFailed(let e): return "socket() 失败:\(String(cString: strerror(e)))" + case .bindFailed(let e): return "bind() 失败:\(String(cString: strerror(e)))" + case .listenFailed(let e): return "listen() 失败:\(String(cString: strerror(e)))" + case .connectFailed(let e): return "connect() 失败:\(String(cString: strerror(e)))" + } + } + } + + /// 填 `sockaddr_un`。`sun_path` 是定长 C 数组,Swift 里只能逐字节写。 + private static func makeAddress(_ path: String) throws -> sockaddr_un { + let bytes = Array(path.utf8) + guard bytes.count <= HubSocket.maxPathLength else { + throw SocketError.pathTooLong(path) + } + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + withUnsafeMutableBytes(of: &addr.sun_path) { raw in + raw.copyBytes(from: bytes) + } + addr.sun_len = UInt8(MemoryLayout.size) + return addr + } + + private static func withAddress( + _ addr: inout sockaddr_un, + _ body: (UnsafePointer, socklen_t) -> T + ) -> T { + withUnsafePointer(to: &addr) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + body($0, socklen_t(MemoryLayout.size)) + } + } + } + + /// 建监听 socket。会先删掉残留的 socket 文件(上次异常退出留下的)。 + static func listen(at path: String, backlog: Int32 = 16) throws -> Int32 { + var addr = try makeAddress(path) + + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw SocketError.createFailed(errno) } + + // 上次进程被 kill -9 时 socket 文件会留下,不删的话 bind 报 EADDRINUSE。 + unlink(path) + + let bound = withAddress(&addr) { Darwin.bind(fd, $0, $1) } + guard bound == 0 else { + let e = errno + close(fd) + throw SocketError.bindFailed(e) + } + + guard Darwin.listen(fd, backlog) == 0 else { + let e = errno + close(fd) + throw SocketError.listenFailed(e) + } + + // 只有当前用户能连。这条链路能否决工具执行,不该对其他用户开放。 + chmod(path, 0o600) + return fd + } + + /// 连接到监听中的 socket。 + static func connect(to path: String) throws -> Int32 { + var addr = try makeAddress(path) + + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw SocketError.createFailed(errno) } + + let connected = withAddress(&addr) { Darwin.connect(fd, $0, $1) } + guard connected == 0 else { + let e = errno + close(fd) + throw SocketError.connectFailed(e) + } + return fd + } + + /// 读一行(以 `\n` 结尾)。返回 nil 表示对端已关闭。 + /// + /// - Parameter timeout: 秒。nil 表示一直阻塞。 + static func readLine(fd: Int32, timeout: TimeInterval?) -> String? { + var buffer = Data() + var byte: UInt8 = 0 + let deadline = timeout.map { Date().addingTimeInterval($0) } + + while true { + if let deadline { + let remaining = deadline.timeIntervalSinceNow + guard remaining > 0, waitReadable(fd: fd, timeout: remaining) else { return nil } + } + + let n = read(fd, &byte, 1) + if n == 0 { return buffer.isEmpty ? nil : String(decoding: buffer, as: UTF8.self) } + if n < 0 { + if errno == EINTR { continue } + return nil + } + if byte == UInt8(ascii: "\n") { + return String(decoding: buffer, as: UTF8.self) + } + buffer.append(byte) + + // 防御:正常的一行事件是几百字节到几 KB。超过 1 MB 说明对端行为异常, + // 不能让它把内存吃光。 + if buffer.count > 1_048_576 { return nil } + } + } + + /// 写一行。 + @discardableResult + static func writeLine(fd: Int32, _ line: String) -> Bool { + var data = Array((line + "\n").utf8) + var offset = 0 + while offset < data.count { + let n = data.withUnsafeBytes { raw -> Int in + write(fd, raw.baseAddress!.advanced(by: offset), data.count - offset) + } + if n <= 0 { + if errno == EINTR { continue } + return false + } + offset += n + } + return true + } + + /// 用 `poll` 等 fd 可读。 + private static func waitReadable(fd: Int32, timeout: TimeInterval) -> Bool { + var pfd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) + let millis = Int32(max(0, min(timeout * 1000, Double(Int32.max)))) + let result = poll(&pfd, 1, millis) + return result > 0 && (pfd.revents & Int16(POLLIN)) != 0 + } +} diff --git a/HubKit/Sources/HubJump/ITermLocator.swift b/HubKit/Sources/HubJump/ITermLocator.swift new file mode 100644 index 0000000..9059ca7 --- /dev/null +++ b/HubKit/Sources/HubJump/ITermLocator.swift @@ -0,0 +1,119 @@ +import Foundation +import HubProbe + +/// iTerm2 里的一个 session(= 一个 tab 里的一个分屏)。 +public struct ITermSession: Hashable, Sendable { + /// iTerm 自己的 session UUID。在 session 存活期间稳定,是我们的选择器。 + public let sessionUUID: String + public let windowId: String + /// iTerm 报告的该 session 前台作业 PID。 + /// + /// **这是整个跳转方案的关键**:在 tmux `-CC` 控制模式下,`tty` 属性返回 + /// `missing value`,`tmuxPaneID` / `tmuxWindowName` 等变量也全是 `missing value`, + /// 唯独 `jobPid` 有效且准确。 + public let jobPid: pid_t + /// tab 标题。仅用于诊断输出,**不参与匹配** —— 它每轮都在变。 + public let name: String +} + +/// 通过 AppleScript 枚举与操作 iTerm2。 +/// +/// AppleScript 的语法有个坑:读 session 变量必须写成 +/// `tell s to get variable named "jobPid"`,写成 `variable named "jobPid" of s` +/// 会报 -1723「不允许访问」。 +public struct ITermLocator: Sendable { + public init() {} + + private static let fieldSep = "\u{1}" + private static let rowSep = "\u{2}" + + /// 枚举 iTerm 当前全部 session。 + /// + /// iTerm 没在运行、或未授予 Automation 权限时返回空数组。 + /// 这个调用有成本(AppleScript 跨进程),**只在跳转时按需调用,不要放进轮询循环**。 + public func snapshot() -> [ITermSession] { + let script = """ + tell application "iTerm2" + set out to "" + repeat with w in windows + repeat with t in tabs of w + repeat with s in sessions of t + set jp to "0" + try + tell s to set jp to (get variable named "jobPid") as text + end try + set out to out & (id of w) & "\(Self.fieldSep)" & (id of s) ¬ + & "\(Self.fieldSep)" & jp & "\(Self.fieldSep)" & (name of s) ¬ + & "\(Self.rowSep)" + end repeat + end repeat + end repeat + return out + end tell + """ + + let result = Shell.osascript(script, timeout: 6) + guard result.succeeded else { return [] } + + return result.stdout.components(separatedBy: Self.rowSep).compactMap { row in + let fields = row.components(separatedBy: Self.fieldSep) + guard fields.count >= 4 else { return nil } + // jobPid 为 0 表示 iTerm 报不出前台作业(session 刚建或已死),无法绑定。 + guard let pid = pid_t(fields[2].trimmingCharacters(in: .whitespacesAndNewlines)), + pid > 0 + else { return nil } + return ITermSession( + sessionUUID: fields[1], + windowId: fields[0], + jobPid: pid, + name: fields[3] + ) + } + } + + /// 前置到指定 session。成功返回 true。 + public func focus(sessionUUID: String) -> Bool { + // 三个 select 缺一不可:select w 切窗口、select t 切 tab、select s 切分屏。 + // 只 activate 不 select 就是旧实现「跳到 iTerm 但停在原窗口」的毛病。 + let escaped = sessionUUID.replacingOccurrences(of: "\"", with: "\\\"") + let script = """ + tell application "iTerm2" + repeat with w in windows + repeat with t in tabs of w + repeat with s in sessions of t + if (id of s) is "\(escaped)" then + select w + select t + select s + activate + return "ok" + end if + end repeat + end repeat + end repeat + return "notfound" + end tell + """ + let result = Shell.osascript(script, timeout: 6) + return result.succeeded && result.stdout.contains("ok") + } + + /// 当前处于前台的 session UUID。跳转后用它验证是否真的切过去了。 + public func frontmostSessionUUID() -> String? { + let result = Shell.osascript( + #"tell application "iTerm2" to return id of current session of current tab of current window"#, + timeout: 4 + ) + guard result.succeeded else { return nil } + let uuid = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + return uuid.isEmpty ? nil : uuid + } + + /// iTerm 是否在运行。 + /// + /// 没跑就别付 AppleScript 的成本 —— 更重要的是,对没运行的 app 发 AppleEvent + /// 会**触发冷启动**,用户点一下跳转结果弹出个空 iTerm 窗口,体验很差。 + public static func isRunning() -> Bool { + Shell.run("/usr/bin/pgrep", ["-x", "iTerm2"], timeout: 2).succeeded + } +} diff --git a/HubKit/Sources/HubJump/JumpEngine.swift b/HubKit/Sources/HubJump/JumpEngine.swift new file mode 100644 index 0000000..48945bf --- /dev/null +++ b/HubKit/Sources/HubJump/JumpEngine.swift @@ -0,0 +1,177 @@ +import Foundation +import HubCore +import HubProbe + +/// 跳转结果。失败时带上原因,便于 UI 给用户一个可行动的提示, +/// 而不是像旧实现那样静默什么都不做。 +public enum JumpOutcome: Equatable, Sendable { + /// 成功切到目标 tab。`strategy` 记录哪级策略生效,用于观察降级频率。 + case focused(strategy: String) + /// 会话不在任何已知终端里(比如它跑在 VS Code 内置终端)。已尽力前置终端 app。 + case terminalActivatedOnly(reason: String) + case failed(reason: String) +} + +/// 从 `sessionId` 精准跳到承载它的终端 tab。 +/// +/// ## 为什么旧实现是坏的 +/// +/// 旧链路是「项目名 → tmux 窗口名 → pane_title → AppleScript 按标题子串匹配」, +/// 三段全是会漂移的字符串: +/// - tmux `automatic-rename` 会把窗口名改成前台进程名(claude 的进程名是版本号, +/// 实测出现过窗口名变成 `2.1.173`); +/// - `pane_title` 是 claude 的会话标题,**每轮回复都在变**,还带 spinner 前缀; +/// 旧代码只剥了盲文 spinner(U+2800–U+28FF),漏了 `✳`(U+2733); +/// - 多个会话的标题都可能是通用的 "Claude Code",子串匹配会撞车跳错。 +/// +/// ## 现在的做法 +/// +/// iTerm 报告每个 session 的 `jobPid`,从它向上走进程树,第一个存在 +/// `~/.claude/sessions/.json` 的祖先就是该 tab 承载的会话。全程只用整数 PID +/// 和 UUID,没有任何字符串匹配。实测同时覆盖 tmux `-CC` 会话与非 tmux 原生会话。 +public struct JumpEngine: Sendable { + private let iterm: ITermLocator + private let sessionReader: ClaudeSessionReader + private let tmux: TmuxProbe + + public init( + iterm: ITermLocator = ITermLocator(), + sessionReader: ClaudeSessionReader = ClaudeSessionReader(), + tmux: TmuxProbe = TmuxProbe() + ) { + self.iterm = iterm + self.sessionReader = sessionReader + self.tmux = tmux + } + + /// bg 会话按名字绑定时要求的最短名字长度。 + /// + /// 太短的名字(如 `api`)在别的 tab 标题里偶然出现的概率不可忽略。 + /// Claude 派生的 job 名实测都是十几到几十字符的 slug,这个门槛不会误伤。 + private static let minimumNameLengthForTitleBinding = 8 + + /// 建立 `Claude sessionId` → `iTerm session UUID` 的映射。 + /// + /// 纯函数式的核心逻辑独立出来,方便单测注入假数据 —— AppleScript 和 sysctl + /// 都没法在 CI 里跑,但这段绑定逻辑正是最容易出错、最需要测试的部分。 + /// + /// 分两轮: + /// 1. **祖先链**(主策略,覆盖全部 `interactive` 会话); + /// 2. **job 名匹配**(只兜 `bg` 会话,见下方说明)。 + public static func bind( + itermSessions: [ITermSession], + claudeSessions: [AgentSession], + tree: ProcessTree + ) -> [String: String] { + guard !claudeSessions.isEmpty else { return [:] } + + let sessionByPid = Dictionary( + claudeSessions.map { ($0.pid, $0.sessionId) }, + uniquingKeysWith: { a, _ in a } + ) + + var mapping: [String: String] = [:] + var consumedTerms = Set() + + // 第一轮:祖先链。iTerm 报的 jobPid 向上走,第一个是 Claude 会话的祖先即所有者。 + for term in itermSessions { + guard let ownerPid = tree.firstAncestor(of: term.jobPid, where: { + sessionByPid[$0] != nil + }), let claudeId = sessionByPid[ownerPid] else { continue } + + // 一个 Claude 会话理论上只对应一个 iTerm session。真出现多个 + // (分屏里 attach 了同一个 tmux window),取第一个即可 —— 它们指向同一处。 + if mapping[claudeId] == nil { + mapping[claudeId] = term.sessionUUID + consumedTerms.insert(term.sessionUUID) + } + } + + // 第二轮:bg 会话。 + // + // `kind: bg` 的会话跑在 `claude bg-pty-host` daemon 下,**不在任何终端里**, + // 祖先链必然连不上(实测祖先链是 claude bg-spare → bg-pty-host → claude daemon)。 + // 用户能看到它的地方是某个跑着 `claude agents` 的 tab,那是个查看器进程, + // 与后台会话之间没有任何父子关系。 + // + // 查看器会把 job 名写进 tab 标题(`o3-ui-memory-idempotent-fixes (Claude)`)。 + // 这里**破例用标题匹配** —— 但和旧实现按 `pane_title` 匹配有本质区别: + // 旧实现匹配的是 ai-title,每轮回复都在变;而 bg 会话的 `name` 是创建时 + // 一次性派生的稳定 slug,全程不变。加上只在第一轮未绑定的 tab 里找、 + // 且要求名字足够长,误绑风险很低。 + let unboundBg = claudeSessions.filter { + $0.kind == .bg && mapping[$0.sessionId] == nil + } + guard !unboundBg.isEmpty else { return mapping } + + for session in unboundBg { + guard let name = session.name, + name.count >= minimumNameLengthForTitleBinding + else { continue } + + let match = itermSessions.first { + !consumedTerms.contains($0.sessionUUID) && $0.name.contains(name) + } + if let match { + mapping[session.sessionId] = match.sessionUUID + consumedTerms.insert(match.sessionUUID) + } + } + return mapping + } + + /// 跳到指定会话所在的终端 tab。 + public func jump(to sessionId: String) -> JumpOutcome { + guard ITermLocator.isRunning() else { + return .failed(reason: "iTerm2 未运行") + } + + let claudeSessions = sessionReader.readAll() + guard claudeSessions.contains(where: { $0.sessionId == sessionId }) else { + return .failed(reason: "会话 \(sessionId) 已不存在") + } + + // 策略 A:jobPid 祖先链匹配。已实测验证,覆盖 tmux -CC 与非 tmux 会话。 + let tree = ProcessTree.snapshot() + let itermSessions = iterm.snapshot() + let mapping = Self.bind( + itermSessions: itermSessions, + claudeSessions: claudeSessions, + tree: tree + ) + + if let target = mapping[sessionId] { + if iterm.focus(sessionUUID: target) { + // 验证:跳完再问一次前台是谁。AppleScript 的 select 在极少数情况下 + // 会被 iTerm 自己的焦点逻辑覆盖(-CC 模式尤其),不验证就会谎报成功。 + if let front = iterm.frontmostSessionUUID(), front == target { + return .focused(strategy: "A/jobPid") + } + return .focused(strategy: "A/jobPid(未验证)") + } + } + + // 策略 C:会话在 tmux 里但 iTerm 侧没绑上(比如 iTerm 没 attach 这个 session)。 + // 让 tmux 自己切窗口。注意 iTerm -CC 模式下这一步会被 iTerm 覆盖, + // 所以它只对「非 iTerm 终端 attach 的 tmux」有效,这里作为降级尝试。 + let panes = tmux.listPanes() + let sessionPIDs = Set(claudeSessions.map(\.pid)) + let binding = tmux.bindPanes(to: sessionPIDs, tree: tree, panes: panes) + if let session = claudeSessions.first(where: { $0.sessionId == sessionId }), + let pane = binding[session.pid] { + let result = Shell.run( + tmux.tmuxPath, ["select-window", "-t", pane.windowId], timeout: 3 + ) + if result.succeeded { + _ = Shell.osascript(#"tell application "iTerm2" to activate"#, timeout: 4) + return .terminalActivatedOnly( + reason: "已切 tmux 窗口 \(pane.windowId),但 iTerm 侧未能精确定位 tab" + ) + } + } + + // 兜底:至少把终端拉到前台,别让用户点了毫无反应。 + _ = Shell.osascript(#"tell application "iTerm2" to activate"#, timeout: 4) + return .terminalActivatedOnly(reason: "未能定位该会话所在的 tab,仅前置 iTerm2") + } +} diff --git a/HubKit/Sources/HubProbe/ClaudeSessionReader.swift b/HubKit/Sources/HubProbe/ClaudeSessionReader.swift new file mode 100644 index 0000000..2e1ebf3 --- /dev/null +++ b/HubKit/Sources/HubProbe/ClaudeSessionReader.swift @@ -0,0 +1,63 @@ +import Foundation +import HubCore + +/// 读取 `~/.claude/sessions/.json`。 +/// +/// 这是本项目的**权威状态源** —— Claude Code 自己在维护它,含 `busy` / `idle` / +/// `waiting` / `shell` 以及 waiting 的原因。旧实现靠 Stop hook 事件反推状态, +/// 既有延迟又拿不到 waiting;更早的设计里还考虑过用 pane_title 的 spinner 字符猜 +/// busy —— 有了这个文件之后,那些启发式全都不需要了。 +public struct ClaudeSessionReader: Sendable { + public let directory: URL + + public init(directory: URL? = nil) { + self.directory = directory ?? FileManager.default + .homeDirectoryForCurrentUser + .appendingPathComponent(".claude/sessions", isDirectory: true) + } + + /// 读取全部存活会话。 + /// + /// - Parameter isAlive: 判活谓词,测试时可注入。默认 `kill(pid, 0)`。 + /// **必须过滤**:实测目录里残留着好几天前已退出进程的 json, + /// 不过滤岛上就会显示一堆幽灵会话。 + public func readAll( + isAlive: (pid_t) -> Bool = ProcessTree.isAlive + ) -> [AgentSession] { + let files: [URL] + do { + files = try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + } catch { + return [] + } + + var sessions: [String: AgentSession] = [:] + for file in files where file.pathExtension == "json" { + guard let data = try? Data(contentsOf: file), + let session = AgentSession.decode(from: data), + isAlive(session.pid) + else { continue } + + // 同一 sessionId 可能有多个 PID 文件(resume 后旧进程的 json 尚未清理, + // 且旧 PID 恰好被系统复用给了别的进程从而通过判活)。留 updatedAt 最新的那个。 + if let existing = sessions[session.sessionId] { + let lhs = session.updatedAt ?? .distantPast + let rhs = existing.updatedAt ?? .distantPast + if lhs <= rhs { continue } + } + sessions[session.sessionId] = session + } + return Array(sessions.values) + } + + /// 存活会话的 PID 集合。跳转时用来判断某个祖先 PID 是不是 Claude 会话。 + public func alivePIDs( + isAlive: (pid_t) -> Bool = ProcessTree.isAlive + ) -> Set { + Set(readAll(isAlive: isAlive).map(\.pid)) + } +} diff --git a/HubKit/Sources/HubProbe/ProcessTree.swift b/HubKit/Sources/HubProbe/ProcessTree.swift new file mode 100644 index 0000000..54a5e70 --- /dev/null +++ b/HubKit/Sources/HubProbe/ProcessTree.swift @@ -0,0 +1,94 @@ +import Darwin +import Foundation + +/// 一次 `sysctl` 拿到的全系统进程父子关系快照。 +/// +/// 为什么不 fork `ps`: +/// 1. `ps -o lstart` 的输出是 locale 相关的(本机是中文 locale,会输出中文月份),解析必炸; +/// 2. 一次 sysctl 是单次 syscall,比起 fork + exec + 管道读取快两个数量级; +/// 跳转时要对每个 iTerm tab 走一次祖先链,快很重要。 +public struct ProcessTree: Sendable { + /// pid → ppid + public let parents: [pid_t: pid_t] + + public init(parents: [pid_t: pid_t]) { + self.parents = parents + } + + /// 采集当前全系统进程表。失败(极罕见)时返回空树,调用方会自然降级。 + public static func snapshot() -> ProcessTree { + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_ALL, 0] + var size = 0 + + // 第一次调用只问需要多大缓冲区。 + guard sysctl(&mib, u_int(mib.count), nil, &size, nil, 0) == 0, size > 0 else { + return ProcessTree(parents: [:]) + } + + // 进程表在两次调用之间可能变大,多留 25% 余量,减少 ENOMEM 重试。 + var capacity = size + size / 4 + var buffer = [UInt8](repeating: 0, count: capacity) + + let ok = buffer.withUnsafeMutableBytes { raw -> Bool in + var len = capacity + let result = sysctl(&mib, u_int(mib.count), raw.baseAddress, &len, nil, 0) + capacity = len + return result == 0 + } + guard ok else { return ProcessTree(parents: [:]) } + + let stride = MemoryLayout.stride + let count = capacity / stride + guard count > 0 else { return ProcessTree(parents: [:]) } + + var parents = [pid_t: pid_t](minimumCapacity: count) + buffer.withUnsafeBytes { raw in + guard let base = raw.baseAddress else { return } + for i in 0.. 0 else { continue } + parents[pid] = proc.kp_eproc.e_ppid + } + } + return ProcessTree(parents: parents) + } + + /// 从 `pid` 出发向上遍历祖先链(含自身),最多 `limit` 层。 + /// + /// `limit` 是防环保险:正常进程树不会超过十几层,出现环意味着快照撕裂 + /// (采集期间进程表在变),此时宁可截断也不能死循环。 + public func ancestry(of pid: pid_t, limit: Int = 32) -> [pid_t] { + var chain: [pid_t] = [] + var seen = Set() + var current = pid + + while current > 0, chain.count < limit, seen.insert(current).inserted { + chain.append(current) + guard let parent = parents[current], parent != current else { break } + current = parent + } + return chain + } + + /// 沿祖先链找到第一个满足条件的 PID。 + /// + /// 这是「iTerm tab → Claude 会话」绑定的核心:传入 iTerm 报的 `jobPid`, + /// 谓词是「该 PID 有对应的 sessions/.json」,命中的就是这个 tab 里跑的会话。 + public func firstAncestor(of pid: pid_t, where predicate: (pid_t) -> Bool) -> pid_t? { + ancestry(of: pid).first(where: predicate) + } + + /// 进程是否存活。 + /// + /// `~/.claude/sessions/` 里死进程的 json 会残留(实测有几天前的), + /// 不过滤就会在岛上显示一堆幽灵会话。 + public static func isAlive(_ pid: pid_t) -> Bool { + guard pid > 0 else { return false } + // kill(pid, 0) 不发信号,只做存在性 + 权限检查。 + // EPERM 表示进程存在但不属于我们 —— 对判活而言依然是「存在」。 + if kill(pid, 0) == 0 { return true } + return errno == EPERM + } +} diff --git a/HubKit/Sources/HubProbe/Shell.swift b/HubKit/Sources/HubProbe/Shell.swift new file mode 100644 index 0000000..b173200 --- /dev/null +++ b/HubKit/Sources/HubProbe/Shell.swift @@ -0,0 +1,87 @@ +import Foundation + +/// 极薄的外部命令封装。只用于确实没有 API 替代的场景:`tmux` 和 `osascript`。 +/// +/// 进程信息一律走 `ProcessTree`(sysctl),不要用这个去调 `ps`。 +public enum Shell { + public struct Result: Sendable { + public let status: Int32 + public let stdout: String + public let stderr: String + + public var succeeded: Bool { status == 0 } + } + + /// 同步执行并捕获输出。 + /// + /// - Parameter timeout: 超时秒数。tmux 在 server 无响应时会挂住, + /// AppleScript 在目标 app 卡死时更会挂很久 —— 探测循环里必须有上界, + /// 否则整个 UI 的刷新会被一个卡住的 osascript 拖死。 + @discardableResult + public static func run( + _ executable: String, + _ arguments: [String], + timeout: TimeInterval = 5 + ) -> Result { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + + let outPipe = Pipe() + let errPipe = Pipe() + process.standardOutput = outPipe + process.standardError = errPipe + process.standardInput = FileHandle.nullDevice + + do { + try process.run() + } catch { + return Result(status: -1, stdout: "", stderr: "\(error)") + } + + // 必须在 waitUntilExit 之前把管道读干:管道缓冲区约 64KB, + // 写满后子进程会阻塞在 write 上,而我们阻塞在 wait 上 —— 经典死锁。 + // 后台线程读取可以同时避免这个死锁和超时逻辑的竞争。 + var outData = Data() + var errData = Data() + let group = DispatchGroup() + let queue = DispatchQueue(label: "hub.shell.io", attributes: .concurrent) + + group.enter() + queue.async { + outData = outPipe.fileHandleForReading.readDataToEndOfFile() + group.leave() + } + group.enter() + queue.async { + errData = errPipe.fileHandleForReading.readDataToEndOfFile() + group.leave() + } + + let deadline = Date().addingTimeInterval(timeout) + while process.isRunning, Date() < deadline { + usleep(5_000) + } + if process.isRunning { + process.terminate() + // 给 SIGTERM 一点时间,不行就硬杀,避免留下僵尸拖住管道读取线程。 + let hardDeadline = Date().addingTimeInterval(0.5) + while process.isRunning, Date() < hardDeadline { usleep(5_000) } + if process.isRunning { kill(process.processIdentifier, SIGKILL) } + } + + process.waitUntilExit() + group.wait() + + return Result( + status: process.terminationStatus, + stdout: String(decoding: outData, as: UTF8.self), + stderr: String(decoding: errData, as: UTF8.self) + ) + } + + /// 执行 AppleScript。 + public static func osascript(_ source: String, timeout: TimeInterval = 8) -> Result { + run("/usr/bin/osascript", ["-e", source], timeout: timeout) + } +} diff --git a/HubKit/Sources/HubProbe/TmuxProbe.swift b/HubKit/Sources/HubProbe/TmuxProbe.swift new file mode 100644 index 0000000..08484f5 --- /dev/null +++ b/HubKit/Sources/HubProbe/TmuxProbe.swift @@ -0,0 +1,107 @@ +import Foundation + +/// 一个 tmux pane 的拓扑信息。 +public struct TmuxPane: Hashable, Sendable { + public let sessionName: String + public let windowId: String // "@20" + public let windowName: String // 不可信,见下方说明 + public let paneId: String // "%20" + public let panePid: pid_t + public let currentPath: String + public let currentCommand: String + + public init( + sessionName: String, windowId: String, windowName: String, + paneId: String, panePid: pid_t, currentPath: String, currentCommand: String + ) { + self.sessionName = sessionName + self.windowId = windowId + self.windowName = windowName + self.paneId = paneId + self.panePid = panePid + self.currentPath = currentPath + self.currentCommand = currentCommand + } +} + +/// 采集 tmux 拓扑。 +/// +/// **窗口名不可信**:tmux 的 `automatic-rename` 会把 window_name 改成前台进程名, +/// 而 claude 的进程名就是它的版本号 —— 实测出现过窗口名变成 `2.1.173` 的情况。 +/// 旧实现用 `窗口名 == projects.yaml 的 name` 判断项目是否在运行,实测 9 个窗口 +/// 只有 2 个能匹配上。所以这里采集 windowName 只用于显示兜底,**绝不用于身份识别**。 +/// 身份一律走 `panePid` → 进程树 → sessionId。 +public struct TmuxProbe: Sendable { + public let tmuxPath: String + + public init(tmuxPath: String? = nil) { + self.tmuxPath = tmuxPath ?? Self.locateTmux() + } + + /// tmux 可能装在 Homebrew(Apple Silicon / Intel 路径不同)或系统目录。 + /// GUI app 启动时不走 shell profile,PATH 里通常没有 /opt/homebrew/bin。 + private static func locateTmux() -> String { + let candidates = [ + "/opt/homebrew/bin/tmux", + "/usr/local/bin/tmux", + "/usr/bin/tmux", + ] + return candidates.first { FileManager.default.isExecutableFile(atPath: $0) } + ?? "/usr/bin/env" + } + + private static let separator = "\u{1}" // 用 SOH 而非 tab:路径里可能含 tab + + /// 列出全部 pane。tmux server 未运行时返回空数组(不是错误)。 + public func listPanes() -> [TmuxPane] { + let format = [ + "#{session_name}", "#{window_id}", "#{window_name}", "#{pane_id}", + "#{pane_pid}", "#{pane_current_path}", "#{pane_current_command}", + ].joined(separator: Self.separator) + + let result = Shell.run(tmuxPath, ["list-panes", "-a", "-F", format], timeout: 3) + guard result.succeeded else { return [] } + + return result.stdout.split(separator: "\n").compactMap { line in + let fields = line.components(separatedBy: Self.separator) + guard fields.count >= 7, let pid = pid_t(fields[4]) else { return nil } + return TmuxPane( + sessionName: fields[0], + windowId: fields[1], + windowName: fields[2], + paneId: fields[3], + panePid: pid, + currentPath: fields[5], + currentCommand: fields[6] + ) + } + } + + /// 把每个 pane 绑定到它承载的 Claude 会话 PID。 + /// + /// 用**祖先链求交**而不是 `panePid == sessionPid` 直接相等:实测 8 个窗口里 + /// 有 2 个不成立(pane 的根进程是 shell,claude 是它的子进程)。 + /// 反过来 claude 也可能是 pane 根进程本身,所以从 panePid 起向上/向下都要覆盖 —— + /// 这里的做法是:先看 panePid 自己是不是会话 PID,不是就看会话 PID 的祖先链里有没有 panePid。 + public func bindPanes( + to sessionPIDs: Set, + tree: ProcessTree, + panes: [TmuxPane]? = nil + ) -> [pid_t: TmuxPane] { + let allPanes = panes ?? listPanes() + var binding: [pid_t: TmuxPane] = [:] + + let paneByPid = Dictionary(allPanes.map { ($0.panePid, $0) }, uniquingKeysWith: { a, _ in a }) + + for sessionPid in sessionPIDs { + // 会话进程自身或其某个祖先,恰好是某个 pane 的根进程。 + for ancestor in tree.ancestry(of: sessionPid) { + if let pane = paneByPid[ancestor] { + binding[sessionPid] = pane + break + } + } + } + return binding + } +} diff --git a/HubKit/Sources/HubProbeCLI/main.swift b/HubKit/Sources/HubProbeCLI/main.swift new file mode 100644 index 0000000..ee372ff --- /dev/null +++ b/HubKit/Sources/HubProbeCLI/main.swift @@ -0,0 +1,182 @@ +import Foundation +import HubCore +import HubJump +import HubProjects +import HubProbe + +// 开发期 CLI:在 Xcode / UI 就绪之前,用它验证探测与跳转是否正确。 +// 用法: +// swift run hubprobe list 列出全部存活会话及其终端绑定 +// swift run hubprobe jump 跳到第 n 个会话(list 里的序号) +// swift run hubprobe jump 跳到指定 sessionId + +let reader = ClaudeSessionReader() +let tmux = TmuxProbe() +let iterm = ITermLocator() + +func loadBindings() -> ( + sessions: [AgentSession], + panes: [pid_t: TmuxPane], + iterm: [String: String] +) { + let sessions = reader.readAll().sorted { + if $0.status != $1.status { return $0.status < $1.status } + return ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) + } + let tree = ProcessTree.snapshot() + let paneBinding = tmux.bindPanes(to: Set(sessions.map(\.pid)), tree: tree) + let itermBinding = ITermLocator.isRunning() + ? JumpEngine.bind( + itermSessions: iterm.snapshot(), + claudeSessions: sessions, + tree: tree + ) + : [:] + return (sessions, paneBinding, itermBinding) +} + +func pad(_ s: String, _ width: Int) -> String { + // 中文按两个显示宽度算,否则表格会错位。 + let displayWidth = s.unicodeScalars.reduce(0) { $0 + ($1.value > 0x2000 ? 2 : 1) } + return s + String(repeating: " ", count: max(0, width - displayWidth)) +} + +func runList() { + let (sessions, panes, itermMap) = loadBindings() + guard !sessions.isEmpty else { + print("没有存活的 Claude 会话。") + return + } + + print("共 \(sessions.count) 个存活会话\n") + print(pad("#", 3) + pad("状态", 8) + pad("类型", 12) + pad("项目", 20) + + pad("会话名", 34) + pad("tmux", 14) + "iTerm") + print(String(repeating: "─", count: 104)) + + for (i, s) in sessions.enumerated() { + let kindLabel = s.kind == .bg ? "bg" : "interactive" + let paneLabel = panes[s.pid].map { "\($0.windowId) \($0.windowName)" } ?? "—" + let itermLabel = itermMap[s.sessionId] != nil ? "✓ 已绑定" : "✗ 未绑定" + let statusLabel = s.status == .unknown ? "?\(s.rawStatus)" : s.status.rawValue + + print(pad("\(i)", 3) + + pad(statusLabel, 8) + + pad(kindLabel, 12) + + pad(s.fallbackProjectName, 20) + + pad(s.name ?? "—", 34) + + pad(paneLabel, 14) + + itermLabel) + + if let waitingFor = s.waitingFor { + print(" ↳ 等待:\(waitingFor)") + } + } + + let unbound = sessions.filter { itermMap[$0.sessionId] == nil } + if !unbound.isEmpty { + print("\n未绑定到 iTerm 的会话(跳转会降级):") + for s in unbound { + print(" - \(s.name ?? s.sessionId) pid=\(s.pid) cwd=\(s.cwd)") + } + } +} + +func runJump(_ target: String) { + let (sessions, _, _) = loadBindings() + let sessionId: String + + if let index = Int(target) { + guard index >= 0, index < sessions.count else { + print("序号超出范围(0..<\(sessions.count))") + exit(1) + } + sessionId = sessions[index].sessionId + } else { + sessionId = target + } + + guard let session = sessions.first(where: { $0.sessionId == sessionId }) else { + print("找不到会话 \(sessionId)") + exit(1) + } + + print("跳转到:\(session.name ?? session.sessionId) (\(session.fallbackProjectName))") + switch JumpEngine().jump(to: sessionId) { + case .focused(let strategy): + print("✓ 成功,策略 \(strategy)") + case .terminalActivatedOnly(let reason): + print("△ 只前置了终端:\(reason)") + exit(2) + case .failed(let reason): + print("✗ 失败:\(reason)") + exit(1) + } +} + +/// 进度节流用的小计数器。 +private final class Atomic: @unchecked Sendable { + private var value = 0 + private let lock = NSLock() + + func shouldReport(done: Int, total: Int) -> Bool { + lock.lock() + defer { lock.unlock() } + guard done - value >= 50 || done == total else { return false } + value = done + return true + } +} + +/// 无头跑一遍用量统计。 +/// +/// 存在的理由:用量页藏在主窗口里,要点托盘图标才能打开, +/// 而验证它需要的恰恰是**耗时和数量**这类可量化的东西。 +/// 有这个子命令就能直接 `time hubprobe usage` 看首扫和缓存命中的差别, +/// 不用去驱动 UI。 +private func runUsage(_ rangeArgument: String?) { + let range: UsageRange + switch rangeArgument { + case "7", "7d", "week", nil: range = .week7 + case "30", "30d", "month": range = .month30 + case "all", "全部": range = .all + default: + print("用法:hubprobe usage [7 | 30 | all]") + exit(1) + } + + let started = Date() + // 进度回调可能从别的线程来,用一个原子计数器而不是捕获 var。 + let lastReported = Atomic() + let summary = UsageStats.collect(range: range) { done, total in + // 每 50 个报一次,别把输出刷爆。 + guard lastReported.shouldReport(done: done, total: total) else { return } + FileHandle.standardError.write(Data(" 扫描 \(done)/\(total)\n".utf8)) + } + let elapsed = Date().timeIntervalSince(started) + + print("范围:\(range.rawValue) 耗时:\(String(format: "%.2f", elapsed)) 秒") + print("会话 \(summary.totalSessions) · 消息 \(summary.totalMessages)") + print("输入 \(summary.totalInput) · 输出 \(summary.totalOutput)") + print("") + for project in summary.projects.prefix(10) { + print(" \(project.projectName.padding(toLength: 28, withPad: " ", startingAt: 0))" + + " \(project.totalTokens)") + } +} + +let args = Array(CommandLine.arguments.dropFirst()) +switch args.first { +case "list", nil: + runList() +case "jump": + guard args.count >= 2 else { + print("用法:hubprobe jump <序号或 sessionId>") + exit(1) + } + runJump(args[1]) +case "usage": + runUsage(args.count >= 2 ? args[1] : nil) +default: + print("用法:hubprobe [list | jump <序号或 sessionId> | usage [7|30|all]]") + exit(1) +} diff --git a/HubKit/Sources/HubProjects/GitStatus.swift b/HubKit/Sources/HubProjects/GitStatus.swift new file mode 100644 index 0000000..1c6a1d0 --- /dev/null +++ b/HubKit/Sources/HubProjects/GitStatus.swift @@ -0,0 +1,135 @@ +import Foundation +import HubProbe + +/// 采集 git 状态。 +/// +/// 用 `--porcelain=v2 --branch` **一次调用**拿全部信息(分支、上游、ahead/behind、 +/// 变更数)。分开跑 `git branch` / `git status` / `git rev-list` 是三次 fork, +/// 21 个项目就是 63 次进程创建。 +public enum GitStatus { + + public static func collect(at path: String) -> GitInfo? { + let result = Shell.run( + "/usr/bin/git", + ["-C", path, "status", "--porcelain=v2", "--branch"], + timeout: 5 + ) + guard result.succeeded else { return nil } + return parse(result.stdout) + } + + static func parse(_ output: String) -> GitInfo { + var branch: String? + var ahead = 0 + var behind = 0 + var hasUpstream = false + var changes = 0 + + for line in output.split(separator: "\n") { + if line.hasPrefix("# branch.head ") { + let value = String(line.dropFirst("# branch.head ".count)) + // detached HEAD 时 git 输出字面量 "(detached)"。 + branch = value == "(detached)" ? nil : value + } else if line.hasPrefix("# branch.upstream ") { + hasUpstream = true + } else if line.hasPrefix("# branch.ab ") { + // 格式:`# branch.ab +3 -1` + let parts = line.split(separator: " ") + for part in parts { + if part.hasPrefix("+") { ahead = Int(part.dropFirst()) ?? 0 } + if part.hasPrefix("-") { behind = Int(part.dropFirst()) ?? 0 } + } + } else if !line.hasPrefix("#") && !line.isEmpty { + // 非 `#` 开头的每一行都是一个变更条目 + // (1=普通变更 2=重命名 u=冲突 ?=未跟踪)。 + changes += 1 + } + } + + return GitInfo( + branch: branch, changeCount: changes, + ahead: ahead, behind: behind, hasUpstream: hasUpstream + ) + } + + /// 仓库根目录。 + /// + /// 会话的 cwd 常常是仓库的子目录,而"当前目录"和"仓库"是两件事 —— + /// 同一个项目多开时,几个会话可能落在不同的 worktree 里, + /// 只看项目名会把它们混成一个。 + public static func repositoryRoot(at path: String) -> String? { + let result = Shell.run( + "/usr/bin/git", ["-C", path, "rev-parse", "--show-toplevel"], timeout: 5 + ) + guard result.succeeded else { return nil } + let root = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + return root.isEmpty ? nil : root + } + + /// 最近几条提交。 + /// + /// 用 `%x1f`(Unit Separator)分隔字段而不是制表符或竖线:提交标题里 + /// 出现制表符不常见但完全合法,出现 US 控制字符则基本不可能。 + public static func log(at path: String, limit: Int = 3) -> [GitCommit] { + let result = Shell.run( + "/usr/bin/git", + [ + "-C", path, "log", "-\(limit)", + "--pretty=format:%h%x1f%s%x1f%cr%x1f%an", + "--no-color", + ], + timeout: 5 + ) + guard result.succeeded else { return [] } + return parseLog(result.stdout) + } + + static func parseLog(_ output: String) -> [GitCommit] { + output.split(separator: "\n").compactMap { line in + let fields = line.split(separator: "\u{1f}", omittingEmptySubsequences: false) + guard fields.count >= 3 else { return nil } + return GitCommit( + hash: String(fields[0]), + subject: String(fields[1]), + relativeDate: String(fields[2]), + author: fields.count > 3 ? String(fields[3]) : "" + ) + } + } + + /// 扫描目录树找 git 仓库。 + /// + /// 命中 `.git` 就停止下钻 —— 不然会一路走进 vendor 目录里的子仓库, + /// 扫出一堆用户根本不认识的"项目"。 + public static func scanRepositories( + under root: String, + maxDepth: Int = 3 + ) -> [String] { + var found: [String] = [] + let skipped: Set = ["node_modules", "target", "dist", "build", "vendor", "Pods"] + + func walk(_ path: String, depth: Int) { + guard depth <= maxDepth else { return } + + if FileManager.default.fileExists(atPath: path + "/.git") { + found.append(path) + return // 命中即停,不再下钻 + } + + guard let entries = try? FileManager.default + .contentsOfDirectory(atPath: path) else { return } + + for entry in entries { + guard !entry.hasPrefix("."), !skipped.contains(entry) else { continue } + var isDirectory: ObjCBool = false + let child = path + "/" + entry + guard FileManager.default.fileExists(atPath: child, isDirectory: &isDirectory), + isDirectory.boolValue else { continue } + walk(child, depth: depth + 1) + } + } + + walk(NSString(string: root).expandingTildeInPath, depth: 0) + return found + } +} diff --git a/HubKit/Sources/HubProjects/Project.swift b/HubKit/Sources/HubProjects/Project.swift new file mode 100644 index 0000000..72b560d --- /dev/null +++ b/HubKit/Sources/HubProjects/Project.swift @@ -0,0 +1,224 @@ +import Foundation + +/// projects.yaml 里的一个项目。 +public struct Project: Identifiable, Hashable, Sendable { + public let name: String + public let path: String + public let aliases: [String] + public let description: String? + public let tags: [String] + + public var id: String { path } + + public init( + name: String, path: String, aliases: [String] = [], + description: String? = nil, tags: [String] = [] + ) { + self.name = name + self.path = path + self.aliases = aliases + self.description = description + self.tags = tags + } + + /// 展开 `~/`。yaml 里普遍写成 `~/Documents/code/xxx`。 + public var expandedPath: String { + guard path.hasPrefix("~") else { return path } + return NSString(string: path).expandingTildeInPath + } +} + +/// git 状态。 +/// 一条提交记录。 +public struct GitCommit: Hashable, Sendable, Identifiable { + public let hash: String + public let subject: String + /// git 自己给的相对时间("3 minutes ago")—— 不自己算,省一次时间戳解析, + /// 而且 git 的措辞已经本地化过。 + public let relativeDate: String + public let author: String + + public var id: String { hash } + + public init(hash: String, subject: String, relativeDate: String, author: String) { + self.hash = hash + self.subject = subject + self.relativeDate = relativeDate + self.author = author + } +} + +/// 一个仓库工作区的快照:状态 + 最近提交。 +/// +/// 按**会话的 cwd** 采集而不是按 projects.yaml 里的项目 —— 会话可能开在 +/// 任意目录、任意 worktree,甚至根本不在已登记的项目里。同一个项目多开时, +/// 这份快照是区分它们的主要依据。 +public struct WorkspaceGit: Hashable, Sendable { + public let root: String + public let info: GitInfo + public let recent: [GitCommit] + + public init(root: String, info: GitInfo, recent: [GitCommit]) { + self.root = root + self.info = info + self.recent = recent + } +} + +public struct GitInfo: Hashable, Sendable { + public let branch: String? + public let changeCount: Int + public let ahead: Int + public let behind: Int + public let hasUpstream: Bool + + public var isClean: Bool { changeCount == 0 } + + public init( + branch: String?, changeCount: Int, ahead: Int, behind: Int, hasUpstream: Bool + ) { + self.branch = branch + self.changeCount = changeCount + self.ahead = ahead + self.behind = behind + self.hasUpstream = hasUpstream + } +} + +/// 极简 YAML 解析。 +/// +/// 只认 projects.yaml 这一种结构,不引 Yams。理由是常驻 app 的长期维护成本: +/// 多一个依赖就多一次跟着 Swift 版本升级的负担,而这里需要的表达力极其有限。 +/// +/// 沿用现有 Rust 版 `parse_yaml` 的宽松行解析契约(trim 后判前缀), +/// 这样两边对同一个文件的理解一致,迁移期间不会打架。 +public enum ProjectYAML { + + /// 解析结果 + 诊断。 + /// + /// 存在的理由是一次真实事故:文件里明明有 117 条项目,解析器却返回空数组, + /// UI 老老实实显示"0 个项目",**没有任何地方提示出了问题**。 + /// 排查它花的时间远超写这个类型的成本 —— 解析失败必须能被看见。 + public struct Outcome: Sendable { + public let projects: [Project] + /// 文件里出现过多少个 `- name:`(不管有没有被成功解析)。 + public let entryMarkers: Int + + /// 文件里有条目但一条都没解析出来 —— 格式坏了。 + public var looksBroken: Bool { projects.isEmpty && entryMarkers > 0 } + + public var diagnostic: String? { + guard looksBroken else { return nil } + return "文件里有 \(entryMarkers) 条项目,但一条都没能解析出来 —— 检查 `projects:` 这一行的格式" + } + } + + public static func parse(_ text: String) -> [Project] { + analyze(text).projects + } + + public static func analyze(_ text: String) -> Outcome { + var projects: [Project] = [] + var entryMarkers = 0 + + var name: String? + var path: String? + var description: String? + var aliases: [String] = [] + var tags: [String] = [] + var inProjectsSection = false + + func flush() { + if let name, let path { + projects.append( + Project( + name: name, path: path, aliases: aliases, + description: description, tags: tags + ) + ) + } + name = nil + path = nil + description = nil + aliases = [] + tags = [] + } + + for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) { + let line = String(rawLine) + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } + + if trimmed.hasPrefix("- name:") || trimmed.hasPrefix("-name:") { + entryMarkers += 1 + } + + // 顶层键。scan_dirs 之类的段落要跳过,只处理 projects。 + // + // 判定必须按**键名**而不是整行字面量。踩过的坑:原来写的是 + // `trimmed == "projects:"`,于是 `projects: []` 完全不匹配 —— + // 旧版留下的空数组标记后面被追加了 117 条项目,全部被静默忽略。 + // 行内值(`[]`、`~`、`null`)只是"暂时没有内联条目", + // 不代表下面缩进的列表不算数。 + if !line.hasPrefix(" ") && !line.hasPrefix("\t"), + let key = topLevelKey(of: trimmed) { + flush() + inProjectsSection = key == "projects" + continue + } + guard inProjectsSection else { continue } + + if trimmed.hasPrefix("- ") { + flush() + let rest = String(trimmed.dropFirst(2)) + if let value = value(of: "name", in: rest) { name = value } + continue + } + + if let value = value(of: "name", in: trimmed) { name = value } + else if let value = value(of: "path", in: trimmed) { path = value } + else if let value = value(of: "description", in: trimmed) { description = value } + else if let value = value(of: "aliases", in: trimmed) { aliases = list(value) } + else if let value = value(of: "tags", in: trimmed) { tags = list(value) } + } + flush() + return Outcome(projects: projects, entryMarkers: entryMarkers) + } + + /// 顶层键名。`projects:` / `projects: []` / `scan_dirs:` 都能取到键名; + /// 列表项(`- xxx`)和纯文本行返回 nil。 + private static func topLevelKey(of trimmed: String) -> String? { + guard !trimmed.hasPrefix("-"), let colon = trimmed.firstIndex(of: ":") else { + return nil + } + let key = String(trimmed[trimmed.startIndex.. String? { + let prefix = key + ":" + guard line.hasPrefix(prefix) else { return nil } + return unquote(String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces)) + } + + private static func list(_ raw: String) -> [String] { + var body = raw + if body.hasPrefix("["), body.hasSuffix("]") { + body = String(body.dropFirst().dropLast()) + } + return body + .split(separator: ",") + .map { unquote($0.trimmingCharacters(in: .whitespaces)) } + .filter { !$0.isEmpty } + } + + private static func unquote(_ raw: String) -> String { + var value = raw + for quote in ["\"", "'"] where value.hasPrefix(quote) && value.hasSuffix(quote) && value.count >= 2 { + value = String(value.dropFirst().dropLast()) + } + return value + } +} diff --git a/HubKit/Sources/HubProjects/ProjectStore.swift b/HubKit/Sources/HubProjects/ProjectStore.swift new file mode 100644 index 0000000..1ea30c7 --- /dev/null +++ b/HubKit/Sources/HubProjects/ProjectStore.swift @@ -0,0 +1,231 @@ +import Foundation +import HubProbe + +/// 项目列表 + git 状态。 +@Observable +@MainActor +public final class ProjectStore { + + public private(set) var projects: [Project] = [] + public private(set) var gitInfo: [String: GitInfo] = [:] // key = expandedPath + public private(set) var isScanning = false + + /// 实际读到的那个文件。 + /// + /// **必须对外可见。** 这次的坑就是"不知道 app 在读哪份 projects.yaml" —— + /// 脚本和 CLAUDE.md 维护的是仓库里那份,app 却在读 Application Support 里 + /// 另一份旧的,两边永远对不上,而 UI 上只显示一个孤零零的"0 个项目"。 + public private(set) var sourceURL: URL? + + /// 解析失败的说明。UI 要显示出来,不能让文件坏了还静默显示 0。 + public private(set) var loadDiagnostic: String? + + private let explicitURL: URL? + private var gitTimer: Timer? + + /// cwd → 项目名 的反查表。hook 事件只带 cwd,要靠它换成用户认识的项目名。 + private var pathIndex: [String: String] = [:] + + public init(yamlURL: URL? = nil) { + self.explicitURL = yamlURL + } + + // MARK: - 加载 + + /// 按优先级找 projects.yaml。 + /// + /// **仓库那份优先**:`add-project.sh`、`scan-projects.sh` 和 CLAUDE.md + /// 全都以它为准,app 读别处就等于两套数据。Application Support 那份 + /// 只作为"没有克隆仓库"时的兜底。 + public var candidateURLs: [URL] { + if let explicitURL { return [explicitURL] } + let home = FileManager.default.homeDirectoryForCurrentUser + var urls: [URL] = [] + if let dir = ProcessInfo.processInfo.environment["CLAUDE_HUB_DIR"], !dir.isEmpty { + urls.append( + URL(fileURLWithPath: NSString(string: dir).expandingTildeInPath) + .appendingPathComponent("projects.yaml") + ) + } + urls.append(home.appendingPathComponent("Documents/code/claude-hub/projects.yaml")) + urls.append( + home.appendingPathComponent("Library/Application Support/claude-hub/projects.yaml") + ) + return urls + } + + public func load() { + loadDiagnostic = nil + + // 逐个候选试。**空文件和解析不出东西的文件都要继续往下找** —— + // 只判断"文件存不存在"的话,一份坏掉的文件会把好的那份永远挡住, + // 这正是这次 0 个项目的成因。 + var firstFound: (URL, ProjectYAML.Outcome)? + for url in candidateURLs { + guard let text = try? String(contentsOf: url, encoding: .utf8) else { continue } + let outcome = ProjectYAML.analyze(text) + if firstFound == nil { firstFound = (url, outcome) } + guard !outcome.projects.isEmpty else { continue } + + sourceURL = url + projects = outcome.projects + rebuildPathIndex() + refreshGit() + return + } + + // 一个候选都没解析出项目:如实报告最先找到的那份出了什么问题。 + if let (url, outcome) = firstFound { + sourceURL = url + projects = [] + loadDiagnostic = outcome.diagnostic ?? "文件里没有任何项目条目" + } else { + sourceURL = nil + projects = [] + loadDiagnostic = "找不到 projects.yaml" + } + rebuildPathIndex() + } + + private func rebuildPathIndex() { + pathIndex = [:] + for project in projects { + pathIndex[normalize(project.expandedPath)] = project.name + } + } + + /// 从任意路径反查项目名。 + /// + /// 会话的 cwd 常常是项目的**子目录**(用户 cd 进去再起的 claude), + /// 所以要逐级向上找最长匹配,不能只做等值比较。 + public func projectName(forPath path: String) -> String? { + var current = normalize(path) + while !current.isEmpty, current != "/" { + if let name = pathIndex[current] { return name } + current = (current as NSString).deletingLastPathComponent + } + return nil + } + + private func normalize(_ path: String) -> String { + var value = NSString(string: path).expandingTildeInPath + while value.count > 1, value.hasSuffix("/") { value.removeLast() } + return value + } + + // MARK: - git + + public func startGitPolling() { + refreshGit() + gitTimer?.invalidate() + // git 状态变化远没有会话状态那么频繁,6 秒足够。 + gitTimer = Timer.scheduledTimer(withTimeInterval: 6, repeats: true) { [weak self] _ in + Task { @MainActor in self?.refreshGit() } + } + } + + public func stopGitPolling() { + gitTimer?.invalidate() + gitTimer = nil + } + + public func refreshGit() { + let paths = projects.map(\.expandedPath) + guard !paths.isEmpty else { return } + + // 21 个仓库串行大约 120ms。放后台队列,别让 UI 掉帧。 + Task.detached(priority: .utility) { + var collected: [String: GitInfo] = [:] + for path in paths { + if let info = GitStatus.collect(at: path) { + collected[path] = info + } + } + await MainActor.run { [collected] in + self.gitInfo = collected + } + } + } + + public func git(for project: Project) -> GitInfo? { + gitInfo[project.expandedPath] + } + + /// 按任意路径找 git 状态。 + /// + /// 会话的 cwd 常常是项目的子目录,所以和 `projectName(forPath:)` 一样 + /// 逐级向上找最长匹配 —— 直接拿 cwd 当 key 去查会大面积落空。 + public func git(forPath path: String) -> GitInfo? { + var current = normalize(path) + while !current.isEmpty, current != "/" { + if let info = gitInfo[current] { return info } + current = (current as NSString).deletingLastPathComponent + } + return nil + } + + // MARK: - 扫描 + + /// 扫描目录树,把新发现的 git 仓库追加进 yaml。 + public func scan(root: String = "~/Documents/code") { + guard !isScanning else { return } + let target = sourceURL ?? candidateURLs.first + guard let target else { return } + isScanning = true + + let existing = Set(projects.map { self.normalize($0.expandedPath) }) + + Task.detached(priority: .userInitiated) { + let found = GitStatus.scanRepositories(under: root) + let fresh = found.filter { !existing.contains($0) } + + if !fresh.isEmpty { + Self.append(paths: fresh, to: target) + } + + await MainActor.run { + self.isScanning = false + self.load() + } + } + } + + /// 把新发现的项目追加进 yaml。 + /// + /// 追加而不是重写整份:用户手写的别名、描述、注释都要保住。 + /// + /// 但**追加前必须先规范化 `projects:` 那一行**。这次的事故就是这么来的: + /// 文件里是 `projects: []`(旧版写的空数组),追加逻辑直接 seek 到文件尾 + /// 写条目,产出的 YAML 变成"空数组标记后面跟着 117 条内容" —— + /// 解析器看到 `[]` 就认定项目段是空的,全部忽略。 + nonisolated static func append(paths: [String], to url: URL) { + guard var text = try? String(contentsOf: url, encoding: .utf8) else { return } + + text = normalizeProjectsKey(in: text) + if !text.hasSuffix("\n") { text += "\n" } + for path in paths { + let name = (path as NSString).lastPathComponent + text += " - name: \(name)\n path: \(path)\n" + } + try? text.write(to: url, atomically: true, encoding: .utf8) + } + + /// 把 `projects: []` / `projects: ~` / `projects: null` 还原成 `projects:`, + /// 让后面缩进的列表条目重新生效。其余内容一字不动。 + nonisolated static func normalizeProjectsKey(in text: String) -> String { + let emptyMarkers: Set = ["[]", "~", "null", "{}"] + var lines = text.components(separatedBy: "\n") + for (index, line) in lines.enumerated() { + guard !line.hasPrefix(" "), !line.hasPrefix("\t") else { continue } + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("projects:") else { continue } + let value = String(trimmed.dropFirst("projects:".count)) + .trimmingCharacters(in: .whitespaces) + if emptyMarkers.contains(value) { + lines[index] = "projects:" + } + break + } + return lines.joined(separator: "\n") + } +} diff --git a/HubKit/Sources/HubProjects/TerminalDispatch.swift b/HubKit/Sources/HubProjects/TerminalDispatch.swift new file mode 100644 index 0000000..9bfa29f --- /dev/null +++ b/HubKit/Sources/HubProjects/TerminalDispatch.swift @@ -0,0 +1,246 @@ +import Foundation +import HubProbe + +/// 在终端里打开项目的方式。 +public enum LaunchMode: String, CaseIterable, Sendable { + case claude + case claudeSkipPermissions + case terminal + case resumeLast + case resumePick + case resumeSession + case finder + case vscode + + public var label: String { + switch self { + case .claude: return "Claude Code" + case .claudeSkipPermissions: return "Claude Code(跳过权限)" + case .terminal: return "终端" + case .resumeLast: return "继续上次会话" + case .resumePick: return "选择历史会话" + case .resumeSession: return "恢复此会话" + case .finder: return "在 Finder 打开" + case .vscode: return "用 VS Code 打开" + } + } + + /// 要在 tmux 窗口里跑的命令。nil 表示只开一个 shell。 + func command(sessionId: String?) -> String? { + switch self { + case .claude: return "claude" + case .claudeSkipPermissions: return "claude --dangerously-skip-permissions" + case .terminal: return nil + case .resumeLast: return "claude --continue" + case .resumePick: return "claude --resume" + case .resumeSession: + guard let sessionId else { return "claude --resume" } + return "claude --resume \(shellQuote(sessionId))" + case .finder, .vscode: return nil + } + } +} + +func shellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" +} + +/// 终端派发。 +/// +/// ## 必须保留的隐性约束:Keychain ACL +/// +/// **首次 `tmux new-session` 必须由终端进程执行**,不能由本 app 直接跑。 +/// +/// 原因:这样 tmux server 的父进程是 iTerm,claude 作为它的子进程才能复用 +/// iTerm 在 macOS Keychain 上的 ACL。如果由 app 创建 tmux server,claude +/// 拿不到登录态,用户会莫名其妙地掉线。这是上一版踩过的坑,改写时原样保留。 +/// +/// 后续的 `new-window` / `send-keys` 是 client 操作,谁跑都行。 +public struct TerminalDispatch: Sendable { + + public enum Terminal: String, Sendable { + case iTerm = "iTerm2" + case terminal = "Terminal" + + var bundleId: String { + switch self { + case .iTerm: return "com.googlecode.iterm2" + case .terminal: return "com.apple.Terminal" + } + } + + /// iTerm 用 `-CC` 控制模式,tmux 窗口会呈现为原生 tab。 + var attachCommand: String { + switch self { + case .iTerm: return "tmux -CC attach -t hub" + case .terminal: return "tmux attach -t hub" + } + } + } + + public let sessionName: String + private let tmux: TmuxProbe + + public init(sessionName: String = "hub", tmux: TmuxProbe = TmuxProbe()) { + self.sessionName = sessionName + self.tmux = tmux + } + + public func detectTerminal() -> Terminal { + for candidate in [Terminal.iTerm, .terminal] where isInstalled(candidate) { + return candidate + } + return .terminal + } + + private func isInstalled(_ terminal: Terminal) -> Bool { + let paths = [ + "/Applications/\(terminal.rawValue).app", + NSString(string: "~/Applications/\(terminal.rawValue).app").expandingTildeInPath, + "/System/Applications/Utilities/\(terminal.rawValue).app", + ] + return paths.contains { FileManager.default.fileExists(atPath: $0) } + } + + // MARK: - 打开项目 + + @discardableResult + public func open( + project name: String, + path: String, + mode: LaunchMode, + sessionId: String? = nil + ) -> Bool { + switch mode { + case .finder: + return Shell.run("/usr/bin/open", [path], timeout: 5).succeeded + case .vscode: + // code 不一定在 GUI app 的 PATH 里,用 open -a 更稳。 + let direct = Shell.run("/usr/bin/open", ["-a", "Visual Studio Code", path], timeout: 5) + return direct.succeeded + default: + break + } + + let inner = mode.command(sessionId: sessionId) + + if hubSessionExists() { + return addWindow(name: name, path: path, command: inner) + } + return createSession(name: name, path: path, command: inner) + } + + private func hubSessionExists() -> Bool { + Shell.run(tmux.tmuxPath, ["has-session", "-t", sessionName], timeout: 3).succeeded + } + + /// 第一次创建:**必须**让终端来跑,见类型注释里的 Keychain 说明。 + private func createSession(name: String, path: String, command: String?) -> Bool { + let terminal = detectTerminal() + ensureRunning(terminal) + + var create = "tmux new-session -d -s \(shellQuote(sessionName))" + + " -n \(shellQuote(name)) -c \(shellQuote(path))" + if let command { + create += " \(shellQuote(command))" + } + return runInTerminal("\(create) && \(terminal.attachCommand)", terminal: terminal) + } + + /// session 已存在:直接加窗口,这是 client 操作,app 自己跑没问题。 + private func addWindow(name: String, path: String, command: String?) -> Bool { + // 注意 flag 必须全部排在 shell-command 之前,否则 tmux 会把 flag + // 当成命令的一部分。 + var args = [ + "new-window", "-t", sessionName, "-n", name, "-c", path, + "-P", "-F", "#{window_id}", + ] + if let command { args.append(command) } + + let result = Shell.run(tmux.tmuxPath, args, timeout: 5) + guard result.succeeded else { return false } + + let windowId = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + + // 没有客户端连着的话,新窗口只是后台多了一个,用户什么都看不到。 + let clients = Shell.run(tmux.tmuxPath, ["list-clients", "-t", sessionName], timeout: 3) + if clients.stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let terminal = detectTerminal() + ensureRunning(terminal) + return runInTerminal(terminal.attachCommand, terminal: terminal) + } + + // 有客户端:iTerm 不会自动前置新 tab,得主动切过去。 + if !windowId.isEmpty { + Shell.run(tmux.tmuxPath, ["select-window", "-t", windowId], timeout: 3) + } + Shell.osascript(#"tell application "iTerm2" to activate"#, timeout: 4) + return true + } + + // MARK: - 终端控制 + + /// 确保终端在跑且 AppleEvent 接收器就绪。 + /// + /// 冷启动的 app 会有一段时间接受不了 AppleEvent,这时候发脚本会静默失败。 + /// 所以要轮询探测 —— 用 `tell app to return id`,它不创建窗口、无副作用。 + private func ensureRunning(_ terminal: Terminal) { + if Shell.run("/usr/bin/pgrep", ["-x", terminal.rawValue], timeout: 2).succeeded { + return + } + + // -g:不抢焦点,稍后让 AppleScript 的 activate 来抢, + // 避免冷启动期间焦点反复跳。 + Shell.run("/usr/bin/open", ["-g", "-b", terminal.bundleId], timeout: 5) + + let deadline = Date().addingTimeInterval(8) + while Date() < deadline { + let probe = Shell.osascript( + "tell application \"\(terminal.rawValue)\" to return id", timeout: 3 + ) + if probe.succeeded, + !probe.stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return + } + usleep(150_000) + } + } + + private func runInTerminal(_ command: String, terminal: Terminal) -> Bool { + let escaped = command + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + + let script: String + switch terminal { + case .iTerm: + script = """ + tell application "iTerm2" + activate + if (count of windows) = 0 then + set w to (create window with default profile) + tell current session of current tab of w + write text "\(escaped)" + end tell + else + tell current window + create tab with default profile + delay 0.3 + tell current session of current tab + write text "\(escaped)" + end tell + end tell + end if + end tell + """ + case .terminal: + script = """ + tell application "Terminal" + activate + do script "\(escaped)" + end tell + """ + } + return Shell.osascript(script, timeout: 15).succeeded + } +} diff --git a/HubKit/Sources/HubProjects/UsageStats.swift b/HubKit/Sources/HubProjects/UsageStats.swift new file mode 100644 index 0000000..d415cac --- /dev/null +++ b/HubKit/Sources/HubProjects/UsageStats.swift @@ -0,0 +1,278 @@ +import Foundation + +/// 单个项目的 token 用量。 +public struct ProjectUsage: Identifiable, Sendable { + public let projectPath: String + public let projectName: String + public let sessionCount: Int + public let messageCount: Int + public let inputTokens: Int + public let outputTokens: Int + public let models: [String] + + public var id: String { projectPath } + public var totalTokens: Int { inputTokens + outputTokens } +} + +public struct UsageSummary: Sendable { + public let projects: [ProjectUsage] + public let totalSessions: Int + public let totalMessages: Int + public let totalInput: Int + public let totalOutput: Int + + public static let empty = UsageSummary( + projects: [], totalSessions: 0, totalMessages: 0, totalInput: 0, totalOutput: 0 + ) +} + +public enum UsageRange: String, CaseIterable, Sendable { + case week7 = "7天" + case month30 = "30天" + case all = "全部" + + var cutoff: Date? { + switch self { + case .week7: return Calendar.current.date(byAdding: .day, value: -7, to: Date()) + case .month30: return Calendar.current.date(byAdding: .day, value: -30, to: Date()) + case .all: return nil + } + } +} + +/// 单个 jsonl 文件的统计小计。缓存的最小单位。 +struct FileUsage: Codable, Sendable { + let size: Int64 + let modified: Date + let messages: Int + let input: Int + let output: Int + let models: [String] +} + +/// 从 `~/.claude/projects/<编码路径>/*.jsonl` 统计 token 用量。 +/// +/// 这些文件是 Claude Code 的会话记录。**实测本机有 945 MB / 341 个文件**, +/// 所以有两条硬性约束: +/// +/// 1. **绝不把整个文件读成 `String`。** 原实现用 `String(contentsOf:)` 再 +/// `split(separator: "\n")`,945 MB 全进内存再切分,页面永远停在 0 —— +/// 用户看到的就是"用量功能没做"。改成 `FileHandle` 分块流式读取。 +/// 2. **必须增量缓存。** jsonl 只追加不重写,同一个文件的 size + mtime 没变 +/// 就不该重算。缓存落盘,第二次打开秒出。 +/// +/// 行级别仍然做**子串预筛**:只有含 `"usage"` 的行才值得走 JSON 解析, +/// 实测能跳过 90% 以上的行。 +public enum UsageStats { + + /// 流式读取的块大小。1 MB 是吞吐和内存的折中 —— 再大对总耗时几乎没影响, + /// 但会让峰值内存跟着涨。 + private static let chunkSize = 1 << 20 + + static var cacheURL: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/claude-hub/usage-cache.json") + } + + /// 目录名编码规则:路径里每个非 ASCII 字母数字字符替换成 `-`,不 trim。 + /// 例:`/Users/dev/Documents/code/claude-hub` + /// → `-Users-dev-Documents-code-claude-hub` + public static func encodeDirectoryName(for path: String) -> String { + String(path.map { $0.isASCII && ($0.isLetter || $0.isNumber) ? $0 : "-" }) + } + + /// 反解目录名 → 大致的项目名(取最后一段)。 + static func projectName(fromEncoded encoded: String) -> String { + encoded.split(separator: "-").last.map(String.init) ?? encoded + } + + /// 统计用量。 + /// + /// `progress` 每处理完一个文件回调一次 `(已完成, 总数)`, + /// 用于「全部」范围下的进度显示 —— 首次全量要扫近 1 GB,没有进度条 + /// 用户没法区分"在算"和"坏了"。 + public static func collect( + range: UsageRange, + progress: (@Sendable (Int, Int) -> Void)? = nil + ) -> UsageSummary { + let root = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude/projects", isDirectory: true) + + guard let dirs = try? FileManager.default.contentsOfDirectory( + at: root, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles] + ) else { return .empty } + + let cutoff = range.cutoff + var cache = loadCache() + var cacheDirty = false + var results: [ProjectUsage] = [] + + // 先把要处理的文件数点出来,进度才有分母。 + var pending: [(dir: URL, files: [URL])] = [] + var totalFiles = 0 + for dir in dirs { + guard (try? dir.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true + else { continue } + guard let files = try? FileManager.default.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey], + options: [] + ) else { continue } + let jsonl = files.filter { $0.pathExtension == "jsonl" } + guard !jsonl.isEmpty else { continue } + pending.append((dir, jsonl)) + totalFiles += jsonl.count + } + + var done = 0 + for entry in pending { + var sessions = 0 + var messages = 0 + var input = 0 + var output = 0 + var models = Set() + + for file in entry.files { + defer { + done += 1 + progress?(done, totalFiles) + } + + let values = try? file.resourceValues( + forKeys: [.contentModificationDateKey, .fileSizeKey] + ) + let modified = values?.contentModificationDate ?? .distantPast + let size = Int64(values?.fileSize ?? 0) + + // 范围过滤放在读文件之前 —— 7 天视图下这一步就砍掉绝大多数文件。 + if let cutoff, modified < cutoff { continue } + + let key = file.path + let stats: FileUsage + if let cached = cache[key], cached.size == size, cached.modified == modified { + stats = cached + } else { + guard let fresh = scanFile(file, size: size, modified: modified) else { + continue + } + stats = fresh + cache[key] = fresh + cacheDirty = true + } + + sessions += 1 + messages += stats.messages + input += stats.input + output += stats.output + models.formUnion(stats.models) + } + + guard sessions > 0 else { continue } + let encoded = entry.dir.lastPathComponent + results.append( + ProjectUsage( + projectPath: encoded, + projectName: projectName(fromEncoded: encoded), + sessionCount: sessions, + messageCount: messages, + inputTokens: input, + outputTokens: output, + models: models.sorted() + ) + ) + } + + if cacheDirty { saveCache(cache) } + + results.sort { $0.totalTokens > $1.totalTokens } + return UsageSummary( + projects: results, + totalSessions: results.reduce(0) { $0 + $1.sessionCount }, + totalMessages: results.reduce(0) { $0 + $1.messageCount }, + totalInput: results.reduce(0) { $0 + $1.inputTokens }, + totalOutput: results.reduce(0) { $0 + $1.outputTokens } + ) + } + + // MARK: - 流式解析 + + /// 分块读一个 jsonl,逐行统计。 + /// + /// **不用 `String(contentsOf:)`。** 单个会话文件可以有几十 MB, + /// 全量目录接近 1 GB,整份读进内存再 `split` 是这个功能之前根本跑不出结果的原因。 + /// + /// 分块读的关键细节:块边界几乎必然落在某一行中间,所以每块处理完要把 + /// 残留的尾巴留到下一块开头拼接,否则会漏掉/切坏跨块的那一行。 + static func scanFile(_ url: URL, size: Int64, modified: Date) -> FileUsage? { + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? handle.close() } + + var messages = 0 + var input = 0 + var output = 0 + var models = Set() + var carry = Data() + + func consume(_ line: Data) { + guard !line.isEmpty else { return } + // 预筛:绝大多数行(user 消息、attachment、快照)不含 usage。 + // 在 Data 上做子串查找,避免为每一行构造 String。 + guard line.range(of: Data("\"usage\"".utf8)) != nil else { return } + guard let object = try? JSONSerialization.jsonObject(with: line) as? [String: Any], + let message = object["message"] as? [String: Any] + else { return } + + if let model = message["model"] as? String { models.insert(model) } + guard let usage = message["usage"] as? [String: Any] else { return } + + messages += 1 + input += (usage["input_tokens"] as? Int) ?? 0 + input += (usage["cache_read_input_tokens"] as? Int) ?? 0 + input += (usage["cache_creation_input_tokens"] as? Int) ?? 0 + output += (usage["output_tokens"] as? Int) ?? 0 + } + + let newline = UInt8(ascii: "\n") + while let chunk = try? handle.read(upToCount: chunkSize), !chunk.isEmpty { + var buffer = carry + buffer.append(chunk) + carry = Data() + + var start = buffer.startIndex + while let index = buffer[start...].firstIndex(of: newline) { + consume(buffer[start.. [String: FileUsage] { + guard let data = try? Data(contentsOf: cacheURL), + let decoded = try? JSONDecoder().decode([String: FileUsage].self, from: data) + else { return [:] } + return decoded + } + + static func saveCache(_ cache: [String: FileUsage]) { + let directory = cacheURL.deletingLastPathComponent() + try? FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true + ) + guard let data = try? JSONEncoder().encode(cache) else { return } + try? data.write(to: cacheURL, options: .atomic) + } +} diff --git a/HubKit/Sources/HubUI/ApprovalModel.swift b/HubKit/Sources/HubUI/ApprovalModel.swift new file mode 100644 index 0000000..0cef931 --- /dev/null +++ b/HubKit/Sources/HubUI/ApprovalModel.swift @@ -0,0 +1,262 @@ +import Foundation +import HubIPC + +/// 一条待决策的审批请求。 +public struct ApprovalRequest: Identifiable, Sendable { + public let id: String + public let sessionId: String + public let projectName: String + public let cwd: String + public let toolName: String + public let command: String + public let risk: RiskLevel + public let createdAt: Date + /// 发起这次调用的 hook 进程。用来检测对端是否还活着,见 + /// `ApprovalCoordinator.startOrphanSweep()`。 + public let clientPid: pid_t + + public init( + id: String, sessionId: String, projectName: String, cwd: String, + toolName: String, command: String, risk: RiskLevel, + clientPid: pid_t = 0, createdAt: Date = Date() + ) { + self.id = id + self.sessionId = sessionId + self.projectName = projectName + self.cwd = cwd + self.toolName = toolName + self.command = command + self.risk = risk + self.clientPid = clientPid + self.createdAt = createdAt + } + + /// L3 的「允许」需要按住确认,L2 只需要等解锁。 + public var requiresHoldToConfirm: Bool { risk == .irreversible } +} + +/// 审批日志的一条记录。这份数据现在完全没留存,但它很值钱 —— +/// 用户看一周就知道该不该把 `dangerous` 一档也开启拦截。 +public struct ApprovalLogEntry: Codable, Identifiable, Sendable { + public let id: String + public let timestamp: Date + public let projectName: String + public let toolName: String + public let command: String + public let risk: RiskLevel + public let verdict: String + /// 是否真的弹了岛。`dangerous` 默认只记录不拦截,这时是 false。 + public let intercepted: Bool +} + +/// 岛上审批的协调器。 +/// +/// hook 线程会在 `await` 上阻塞直到用户点击或超时,所以这里用 continuation +/// 而不是轮询 —— hook 那边每多等 100ms 都是用户在等。 +@Observable +@MainActor +public final class ApprovalCoordinator { + + /// 当前排队中的请求。第一条显示在岛上,其余用堆叠卡片暗示。 + public private(set) var queue: [ApprovalRequest] = [] + public private(set) var log: [ApprovalLogEntry] = [] + + /// 拦截门槛。默认只拦不可逆操作,见 RiskClassifier 里的理由。 + public var interceptThreshold: RiskLevel = .irreversible + + private var pending: [String: CheckedContinuation] = [:] + private var timeoutTasks: [String: Task] = [:] + + /// 用户决策的时限。到点自动拒绝。 + /// + /// 默认 deny 而不是 allow:用户全局开着 auto + skip-permissions,这是唯一的刹车。 + /// 而且 deny 是可恢复的(Claude 会告诉用户被拒了,重跑即可),allow 不可逆。 + /// + /// 这是超时阶梯里**最内层**的一环,见 `HookTimeouts`。 + private let timeout = HookTimeouts.userDecision + + private var orphanSweep: Task? + + /// 审批日志的落盘位置。 + /// + /// 这份数据的**唯一用途**就是"看一周再决定要不要把 dangerous 一档也拦上"—— + /// 只存在内存里的话 app 一重启就清零,这个功能等于不存在。 + public static var defaultLogURL: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/claude-hub/approval-log.json") + } + + /// nil = 不落盘。 + /// + /// **必须可注入。** 把路径写死在类型里会让测试直接读写用户的真实日志: + /// 测试之间互相污染是小事,往用户数据里写测试垃圾是真问题。 + private let logURL: URL? + + public init(logURL: URL? = defaultLogURL) { + self.logURL = logURL + log = Self.loadLog(from: logURL) + } + + private static func loadLog(from url: URL?) -> [ApprovalLogEntry] { + guard let url, + let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode([ApprovalLogEntry].self, from: data) + else { return [] } + return decoded + } + + /// 每次追加都落盘。 + /// + /// 频率极低(只有被拦截或被记录的高风险操作才走到这里), + /// 而"崩溃/退出后丢掉最后几条"恰恰是最不能接受的 —— 用户想看的就是 + /// 刚才那次到底发生了什么。所以不做批量延迟写。 + private func persistLog() { + guard let logURL else { return } + try? FileManager.default.createDirectory( + at: logURL.deletingLastPathComponent(), withIntermediateDirectories: true + ) + guard let data = try? JSONEncoder().encode(log) else { return } + try? data.write(to: logURL, options: .atomic) + } + + public var current: ApprovalRequest? { queue.first } + + // MARK: - 僵尸请求清理 + + /// 定期清掉「发起方已经死了」的审批卡。 + /// + /// 真实场景:Claude 被 Ctrl-C、会话被关、hook 进程超时被杀 —— 这些情况下 + /// hubctl 没了,但服务端仍然阻塞在等决策上,岛上那张卡会一直挂着。 + /// 用户要么被迫点一下一个**早已无人接收**的决策,要么它会一直挡住 + /// 后面真正需要处理的请求(队列是先进先出的)。 + /// + /// 判据用 `HookEvent.clientPid` —— 这个字段本来就一路传过来了。 + /// 判 deny 而不是 allow:反正没人收,但万一对端还活着只是暂时不可见, + /// 拒绝是可恢复的那一侧。 + public func startOrphanSweep() { + orphanSweep?.cancel() + orphanSweep = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(2)) + await self?.dropOrphans() + } + } + } + + public func stopOrphanSweep() { + orphanSweep?.cancel() + orphanSweep = nil + } + + private func dropOrphans() { + for request in queue where request.clientPid > 0 { + // kill(pid, 0):EPERM 说明进程存在但不属于我们,也算活着。 + guard kill(request.clientPid, 0) != 0, errno == ESRCH else { continue } + resolve( + id: request.id, + decision: HookDecision(verdict: .deny, reason: "发起方已退出"), + verdictLabel: "orphaned" + ) + } + } + + // MARK: - hook 侧入口 + + /// 提交一次审批请求并等待结论。**会阻塞调用方直到有结果。** + public func requestDecision(for request: ApprovalRequest) async -> HookDecision { + queue.append(request) + + let task = Task { [weak self] in + try? await Task.sleep(for: .seconds(self?.timeout ?? 60)) + guard !Task.isCancelled else { return } + await self?.resolve( + id: request.id, + decision: HookDecision(verdict: .deny, reason: "Claude Hub:60 秒未响应,已自动拒绝"), + verdictLabel: "timeout" + ) + } + timeoutTasks[request.id] = task + + return await withCheckedContinuation { continuation in + pending[request.id] = continuation + } + } + + // MARK: - UI 侧动作 + + public func allow(_ request: ApprovalRequest) { + resolve( + id: request.id, + decision: HookDecision(verdict: .allow), + verdictLabel: "allow" + ) + } + + public func deny(_ request: ApprovalRequest) { + resolve( + id: request.id, + decision: HookDecision(verdict: .deny, reason: "用户在 Claude Hub 上拒绝了这次操作"), + verdictLabel: "deny" + ) + } + + /// 跳过:把当前这条挪到队尾,先处理别的。**不代表拒绝。** + public func skip(_ request: ApprovalRequest) { + guard queue.count > 1, let index = queue.firstIndex(where: { $0.id == request.id }) + else { return } + let item = queue.remove(at: index) + queue.append(item) + } + + private func resolve(id: String, decision: HookDecision, verdictLabel: String) { + timeoutTasks[id]?.cancel() + timeoutTasks[id] = nil + + guard let continuation = pending.removeValue(forKey: id) else { return } + let request = queue.first { $0.id == id } + queue.removeAll { $0.id == id } + continuation.resume(returning: decision) + + if let request { + appendLog(request: request, verdict: verdictLabel, intercepted: true) + } + } + + /// 记一笔没有拦截的高风险操作(`dangerous` 档默认走这里)。 + public func recordWithoutIntercepting( + projectName: String, toolName: String, command: String, risk: RiskLevel + ) { + log.insert( + ApprovalLogEntry( + id: UUID().uuidString, timestamp: Date(), projectName: projectName, + toolName: toolName, command: command, risk: risk, + verdict: "auto-allow", intercepted: false + ), + at: 0 + ) + trimLog() + } + + private func appendLog(request: ApprovalRequest, verdict: String, intercepted: Bool) { + log.insert( + ApprovalLogEntry( + id: request.id, timestamp: Date(), projectName: request.projectName, + toolName: request.toolName, command: request.command, risk: request.risk, + verdict: verdict, intercepted: intercepted + ), + at: 0 + ) + trimLog() + } + + private func trimLog() { + if log.count > 500 { log.removeLast(log.count - 500) } + persistLog() + } + + /// 清空审批日志(设置页用)。 + public func clearLog() { + log = [] + persistLog() + } +} diff --git a/HubKit/Sources/HubUI/ApprovalView.swift b/HubKit/Sources/HubUI/ApprovalView.swift new file mode 100644 index 0000000..abc67e2 --- /dev/null +++ b/HubKit/Sources/HubUI/ApprovalView.swift @@ -0,0 +1,295 @@ +import HubCore +import HubIPC +import SwiftUI + +/// 事件闯入的内容。 +public struct IntrusionEvent: Equatable, Sendable { + public enum Kind: Equatable, Sendable { + case finished // Claude 完成一轮 + case needsInput // 等待输入或授权 + } + + public let kind: Kind + public let sessionId: String + public let title: String + public let detail: String + + public init(kind: Kind, sessionId: String, title: String, detail: String) { + self.kind = kind + self.sessionId = sessionId + self.title = title + self.detail = detail + } + + var accent: Color { + switch kind { + case .finished: return IslandTheme.busy + case .needsInput: return IslandTheme.waiting + } + } + + var symbol: String { + switch kind { + case .finished: return "checkmark.circle.fill" + case .needsInput: return "exclamationmark.circle.fill" + } + } +} + +/// 闯入态视图。 +struct IntrusionContent: View { + let event: IntrusionEvent + let session: AgentSession? + + var body: some View { + HStack(spacing: 12) { + if let session { + SpriteScreen(cornerRadius: 7) { + PixelSprite(session: session, pixelSize: 2, gridSize: 16) + } + } else { + Image(systemName: event.symbol) + .font(.system(size: 20)) + .foregroundStyle(event.accent) + .frame(width: 32, height: 32) + } + + VStack(alignment: .leading, spacing: 2) { + Text(event.title) + .font(IslandTheme.label(13, .semibold)) + .foregroundStyle(.white) + .lineLimit(1) + Text(event.detail) + .font(IslandTheme.label(11)) + .foregroundStyle(.white.opacity(0.62)) + .lineLimit(1) + } + + Spacer(minLength: 8) + + Image(systemName: event.symbol) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(event.accent) + } + .padding(.horizontal, 14) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +/// 审批态视图。 +/// +/// 这是整个 app 里**唯一一个做错了会真的删掉代码**的地方,所以防误点护栏是冗余的: +/// 六重保护叠加,任何单点失效都不至于误放行。 +struct ApprovalContent: View { + let request: ApprovalRequest + let queueCount: Int + let onAllow: () -> Void + let onDeny: () -> Void + let onSkip: () -> Void + + /// ① 时间锁:面板出现后 700ms 内「允许」不可用。 + /// + /// 这同时也覆盖掉了「闯入态膨胀时指针恰好停在展开区域」的误击窗口 —— + /// 岛是会主动弹出来的,用户的手可能正好在那儿。 + @State private var unlocked = false + /// ② 长按进度(仅 L3)。 + @State private var holdProgress: Double = 0 + @State private var holdTask: Task? + /// ③ 指针冷却:进入面板后 120ms 内的点击全丢弃。 + @State private var pointerSettledAt: Date? + + private var allowEnabled: Bool { + guard unlocked else { return false } + guard let settled = pointerSettledAt else { return true } + return Date().timeIntervalSince(settled) > 0.12 + } + + var body: some View { + VStack(spacing: 0) { + header + commandBlock + Spacer(minLength: 0) + buttons + } + .padding(.vertical, 12) + .onAppear { + Task { + try? await Task.sleep(for: .milliseconds(700)) + unlocked = true + } + } + .onContinuousHover { phase in + if case .active = phase, pointerSettledAt == nil { + pointerSettledAt = Date() + } + } + } + + private var header: some View { + HStack(spacing: 10) { + Image(systemName: request.risk == .irreversible + ? "exclamationmark.octagon.fill" : "exclamationmark.triangle.fill") + .font(.system(size: 18)) + .foregroundStyle(riskColor) + + VStack(alignment: .leading, spacing: 1) { + Text(request.projectName) + .font(IslandTheme.label(13, .semibold)) + .foregroundStyle(.white) + Text(request.toolName) + .font(IslandTheme.label(11)) + .foregroundStyle(.white.opacity(0.62)) + } + + Spacer(minLength: 6) + + if queueCount > 1 { + Text("1/\(queueCount)") + .font(IslandTheme.label(10, .semibold)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.62)) + .padding(.horizontal, 6) + .frame(height: 18) + .background(.white.opacity(0.10), in: .capsule) + .onTapGesture(perform: onSkip) + } + + Text(riskLabel) + .font(IslandTheme.label(10, .bold)) + .foregroundStyle(.black) + .padding(.horizontal, 7) + .frame(height: 20) + .background(riskColor, in: .capsule) + } + .padding(.horizontal, 16) + .frame(height: 48) + } + + private var commandBlock: some View { + ScrollView { + Text(request.command) + .font(IslandTheme.mono(12)) + .foregroundStyle(.white.opacity(0.92)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + } + .scrollIndicators(.never) + .frame(height: 84) + .background( + // 这块是我们自己压暗的深色底,所以下面放什么材质都可控。 + Color.black.opacity(0.48), + in: .rect(cornerRadius: 10, style: .continuous) + ) + .padding(.horizontal, 16) + } + + private var buttons: some View { + VStack(spacing: 6) { + HStack(spacing: 0) { + Button(action: onDeny) { + Text("拒绝") + } + // 不用系统的 .glass:岛的 panel 基本不是 key window, + // AppKit 会把非活跃窗口里的系统按钮整体去饱和,两个按钮都变灰 —— + // 在唯一需要立刻决策的地方显示成"点不了"是不可接受的。 + .buttonStyle(IslandButtonStyle( + emphasis: .secondary, width: 120, height: 34 + )) + // ⑤ 默认动作是拒绝。⏎ 和 ⎋ 都拒绝,「允许」必须 ⌘⏎。 + // **永远不要让 Return 触发允许。** + .keyboardShortcut(.defaultAction) + + // ① 空间隔离:24pt 间距,故意大于 GlassEffectContainer 的 22pt 融合阈值, + // 让两个按钮保持独立、不粘连成一块玻璃(粘连会助长误点)。 + Spacer().frame(width: 24) + + allowButton + } + + // 键盘提示。 + // + // 这个 app 是 .accessory + nonactivatingPanel,面板未必拿得到 key —— + // 拿不到时 SwiftUI 的 .keyboardShortcut 是死的,由 IslandController 的 + // 局部按键监听接管。两条路的键位语义完全一致,所以提示始终成立。 + Text(request.requiresHoldToConfirm + ? "⏎ / ⎋ 拒绝 · 允许必须按住" + : "⏎ / ⎋ 拒绝 · ⌘⏎ 允许") + .font(IslandTheme.label(9)) + .foregroundStyle(.white.opacity(0.38)) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + .padding(.top, 10) + } + + @ViewBuilder + private var allowButton: some View { + let label = request.requiresHoldToConfirm + ? (holdProgress > 0 ? "按住…" : "按住确认") + : "允许" + + Button(action: { + // ④ L3 必须长按,普通点击不生效。 + guard !request.requiresHoldToConfirm else { return } + guard allowEnabled else { return } + onAllow() + }) { + ZStack { + if request.requiresHoldToConfirm { + GeometryReader { proxy in + Rectangle() + .fill(.black.opacity(0.28)) + .frame(width: proxy.size.width * holdProgress) + } + } + Text(label) + } + } + .buttonStyle(IslandButtonStyle( + emphasis: .prominent, tint: riskColor, width: 120, height: 34 + )) + .keyboardShortcut(.return, modifiers: .command) + .disabled(!allowEnabled) + .opacity(allowEnabled ? 1 : 0.45) + .overlay { + // 时间锁的可视化:一圈进度环,让用户知道为什么点不了。 + if !unlocked { + Circle() + .stroke(.white.opacity(0.35), lineWidth: 1.5) + .frame(width: 16, height: 16) + } + } + .simultaneousGesture(holdGesture) + } + + private var holdGesture: some Gesture { + DragGesture(minimumDistance: 0) + .onChanged { _ in + guard request.requiresHoldToConfirm, allowEnabled, holdTask == nil else { return } + holdTask = Task { + let steps = 30 + for step in 1...steps { + try? await Task.sleep(for: .milliseconds(30)) + if Task.isCancelled { return } + holdProgress = Double(step) / Double(steps) + } + if !Task.isCancelled { onAllow() } + } + } + .onEnded { _ in + // 松手过早:取消并把进度弹回去。 + holdTask?.cancel() + holdTask = nil + withAnimation(.spring(response: 0.25)) { holdProgress = 0 } + } + } + + private var riskColor: Color { + request.risk == .irreversible ? IslandTheme.danger : IslandTheme.waiting + } + + private var riskLabel: String { + request.risk == .irreversible ? "不可逆" : "危险操作" + } +} diff --git a/HubKit/Sources/HubUI/HookCoordinator.swift b/HubKit/Sources/HubUI/HookCoordinator.swift new file mode 100644 index 0000000..fed2a8c --- /dev/null +++ b/HubKit/Sources/HubUI/HookCoordinator.swift @@ -0,0 +1,217 @@ +import Foundation +import HubCore +import HubIPC +import HubProjects + +/// 把 hook 事件接到 UI 上。 +/// +/// 链路:`hubctl` → Unix socket → 这里 → 通知 / 灵动岛闯入 / 审批。 +@MainActor +public final class HookCoordinator { + + private let store: SessionStore + private let approvals: ApprovalCoordinator + private let notifications: HubNotificationCenter + private let projects: ProjectStore + private var server: HubSocketServer? + + /// 触发灵动岛闯入。 + public var onIntrusion: ((IntrusionEvent) -> Void)? + /// 需要弹审批面板。 + public var onApprovalNeeded: (() -> Void)? + + /// 闯入防轰炸:记录每个会话上次闯入的时间。 + /// + /// 9 个会话同时收工的时候这条至关重要 —— 不限流的话岛会连续膨胀九次, + /// 从"有用的提醒"变成"必须关掉的骚扰"。 + private var lastIntrusionAt: [String: Date] = [:] + private let intrusionCooldown: TimeInterval = 60 + + private let classifier = RiskClassifier() + + public init( + store: SessionStore, + approvals: ApprovalCoordinator, + notifications: HubNotificationCenter, + projects: ProjectStore + ) { + self.store = store + self.approvals = approvals + self.notifications = notifications + self.projects = projects + } + + public func start() { + let server = HubSocketServer { [weak self] event in + // 这个闭包跑在 socket 的工作线程上。preToolUse 要同步返回决策, + // 而决策来自 MainActor 上的 UI,所以用信号量把异步结果桥回来。 + // 阻塞的是这条连接自己的线程,不影响其他连接。 + guard let self else { return .allow } + return self.handle(event) + } + + do { + try server.start() + self.server = server + NSLog("[ClaudeHub] hook socket 已监听:\(HubSocket.path)") + } catch { + NSLog("[ClaudeHub] hook socket 启动失败:\(error) —— hook 将全部放行") + } + } + + public func stop() { + server?.stop() + server = nil + } + + // MARK: - 事件分发 + + private nonisolated func handle(_ event: HookEvent) -> HookDecision { + switch event.kind { + case .stop: + Task { @MainActor in self.handleStop(event) } + return .allow + + case .notification: + Task { @MainActor in self.handleNotification(event) } + return .allow + + case .sessionEnd: + Task { @MainActor in self.store.refresh() } + return .allow + + case .preToolUse: + return handlePreToolUseSynchronously(event) + } + } + + @MainActor + private func handleStop(_ event: HookEvent) { + store.refresh() + + let project = projects.projectName(forPath: event.cwd) ?? event.fallbackProjectName + let session = store.sessions.first { $0.sessionId == event.sessionId } + let title = session?.name ?? project + + // 正文用 Claude 自己的最终回复,替掉旧实现那 5 条随机中文文案 + // ("搞定了,来看看" 之类,信息量为零,用户还得切过去才知道干了什么)。 + let body = Self.summarize(event.lastAssistantMessage) ?? "完成了一轮回复" + + notifications.post(title: "✅ \(title)", body: body, sessionId: event.sessionId) + + if allowIntrusion(for: event.sessionId) { + onIntrusion?( + IntrusionEvent( + kind: .finished, sessionId: event.sessionId, + title: title, detail: "\(project) · \(body)" + ) + ) + } + } + + @MainActor + private func handleNotification(_ event: HookEvent) { + store.refresh() + + let project = projects.projectName(forPath: event.cwd) ?? event.fallbackProjectName + let session = store.sessions.first { $0.sessionId == event.sessionId } + let title = session?.name ?? project + let needsUser = event.notificationType == "permission_prompt" + || event.notificationType == "idle_prompt" + || event.notificationType == "elicitation_dialog" + + notifications.post( + title: needsUser ? "⚠️ \(title)" : title, + body: event.message ?? "需要你处理", + sessionId: event.sessionId + ) + + guard needsUser, allowIntrusion(for: event.sessionId) else { return } + onIntrusion?( + IntrusionEvent( + kind: .needsInput, sessionId: event.sessionId, + title: title, detail: "\(project) · \(event.message ?? "等待你的输入")" + ) + ) + } + + /// PreToolUse 必须**同步**返回 —— hubctl 那头在等这条连接的应答。 + /// + /// 用信号量把 MainActor 上的异步审批结果桥回当前线程。这是少数几个 + /// 用信号量阻塞是正确做法的场景:调用方本来就是一条专用的连接线程, + /// 它的全部职责就是等这个结果。 + private nonisolated func handlePreToolUseSynchronously(_ event: HookEvent) -> HookDecision { + let risk = classifier.classify(toolName: event.toolName, summary: event.toolSummary) + guard risk != .normal else { return .allow } + + guard classifier.shouldIntercept(risk) else { + // dangerous 档默认只记录不拦截:用户全局开着 auto + skip-permissions, + // 拦上这一档每天要弹 3–8 次,结果必然是他把整个功能关掉。 + Task { @MainActor in + let project = self.projects.projectName(forPath: event.cwd) + ?? event.fallbackProjectName + self.approvals.recordWithoutIntercepting( + projectName: project, + toolName: event.toolName ?? "?", + command: event.toolSummary ?? "", + risk: risk + ) + } + return .allow + } + + let semaphore = DispatchSemaphore(value: 0) + // nonisolated(unsafe) 是安全的:只有下面的 Task 写、只有 wait 之后才读, + // 信号量提供了 happens-before 关系。 + nonisolated(unsafe) var result = HookDecision.allow + + Task { @MainActor in + let project = self.projects.projectName(forPath: event.cwd) + ?? event.fallbackProjectName + let request = ApprovalRequest( + id: event.requestId, + sessionId: event.sessionId, + projectName: project, + cwd: event.cwd, + toolName: event.toolName ?? "?", + command: event.toolSummary ?? "(无参数)", + risk: risk, + clientPid: event.clientPid ?? 0 + ) + self.onApprovalNeeded?() + result = await self.approvals.requestDecision(for: request) + semaphore.signal() + } + + // 见 HookTimeouts 的超时阶梯:这一层要比 ApprovalCoordinator 的 + // 用户决策超时长,但比 hubctl 的读超时短。 + if semaphore.wait(timeout: .now() + HookTimeouts.serverBridge) == .timedOut { + HubLog.ipc.error("审批桥接超时,按拒绝处理") + return HookDecision(verdict: .deny, reason: "Claude Hub 审批超时") + } + HubLog.ipc.notice("用户决策:\(result.verdict.rawValue, privacy: .public)") + return result + } + + // MARK: - 辅助 + + private func allowIntrusion(for sessionId: String) -> Bool { + let now = Date() + if let last = lastIntrusionAt[sessionId], now.timeIntervalSince(last) < intrusionCooldown { + return false + } + lastIntrusionAt[sessionId] = now + return true + } + + /// 把 Claude 的最终回复压成一行通知正文。 + static func summarize(_ message: String?) -> String? { + guard let message else { return nil } + let cleaned = message + .replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleaned.isEmpty else { return nil } + if cleaned.count <= 90 { return cleaned } + return String(cleaned.prefix(88)) + "…" + } +} diff --git a/HubKit/Sources/HubUI/IslandBackdrop.swift b/HubKit/Sources/HubUI/IslandBackdrop.swift new file mode 100644 index 0000000..1244979 --- /dev/null +++ b/HubKit/Sources/HubUI/IslandBackdrop.swift @@ -0,0 +1,60 @@ +import AppKit +import SwiftUI + +/// 灵动岛的背景材质。 +/// +/// ## 为什么不用 Liquid Glass +/// +/// 这是一个**刻意放弃苹果官方 `.glassEffect` API** 的决定,理由是一条物理约束: +/// **岛的上半截必须和刘海严丝合缝,而刘海恒为纯黑。** +/// +/// Liquid Glass 的设计目标是"盖在任何东西上,上面的内容都可读", +/// 所以它会**跟着背景亮度双向补偿** —— 背景亮它变亮,背景暗它**也**变亮。 +/// 实测(`.clear` + 黑 tint): +/// +/// | 背景 | 玻璃输出 | 压 0.72 黑罩之后 | 刘海 | +/// |---|---|---|---| +/// | 白底 Finder | ≈ 1.0 | 0.28(中灰) | 0.0 | +/// | 纯黑终端 | ≈ 0.45 | 0.13 | 0.0 | +/// +/// 白底下要压到刘海那个黑需要 scrim ≈ 0.94,那时候"玻璃"已经完全不存在了。 +/// 换句话说,**"任何背景下都贴合刘海" 和 "自适应玻璃" 在物理上互斥**, +/// 参数怎么调都不可能同时成立。用户选了前者。 +/// +/// ## 用什么代替 +/// +/// `NSVisualEffectView` + **强制暗色外观**。它做的是背景模糊 + 固定的暗色染色, +/// 不做那套亮度补偿,所以无论背后是什么,输出都稳定落在暗部。 +/// +/// 玻璃感并没有丢,只是换了来源: +/// - **背景模糊**由这一层提供(背后的形状会柔和地透出来); +/// - **边缘高光**(`IslandTheme.edgeHighlight`)—— 真实玻璃的边缘因折射比中间亮, +/// 这是"看起来像玻璃"最关键的一笔; +/// - **轻微透光**——完全不透就成了塑料。 +/// +/// 这正是真实刘海周围那块材质的样子:一块黑玻璃,而不是一块会变色的果冻。 +struct IslandBackdrop: NSViewRepresentable { + + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + // .hudWindow 在暗色外观下是最接近"黑玻璃"的系统材质: + // 比 .popover / .menu 暗得多,而且不带那层浅色底。 + view.material = .hudWindow + // .behindWindow 才能采样到**窗口后面**的桌面/别的 app。 + // .withinWindow 只会采样本窗口内的内容,对一个全透明的 panel 等于什么都没有。 + view.blendingMode = .behindWindow + // app 是 .accessory 且从不激活,用 .followsWindowActiveState 的话 + // 材质会长期停在"非活跃"的淡化状态。必须钉死 .active。 + view.state = .active + // 强制暗色:不跟随系统浅色/深色。刘海在浅色模式下也是纯黑。 + view.appearance = NSAppearance(named: .darkAqua) + view.autoresizingMask = [.width, .height] + return view + } + + func updateNSView(_ view: NSVisualEffectView, context: Context) { + // 外观和材质都是常量,没有需要随状态更新的东西。 + // 形状裁切交给 SwiftUI 侧的 .clipShape —— 让 NSView 自己做 mask + // 会和 SwiftUI 的形变动画脱节。 + } +} diff --git a/HubKit/Sources/HubUI/IslandController.swift b/HubKit/Sources/HubUI/IslandController.swift new file mode 100644 index 0000000..08efaeb --- /dev/null +++ b/HubKit/Sources/HubUI/IslandController.swift @@ -0,0 +1,593 @@ +import AppKit +import HubCore +import HubProjects +import SwiftUI + +/// 灵动岛的生命周期与交互控制。 +/// +/// 负责:建窗口、按屏幕几何定位、随形态更新命中路径、悬停探测、状态机迁移。 +/// +/// **形态本身不存在这里,存在 `IslandModel` 里。** 控制器只是唯一的写入口 —— +/// 这样命中路径、tracking area、外部点击监听这些 AppKit 侧的东西 +/// 才有机会跟着形态一起更新。 +@MainActor +public final class IslandController { + + private let store: SessionStore + private let approvals: ApprovalCoordinator + private let projects: ProjectStore + private let model = IslandModel() + + private var panel: IslandPanel? + private var hostingView: IslandHostingView? + private var geometry: NotchGeometry? + + private var intrusionDismissTask: Task? + /// 闯入之前是什么形态,回落时要还原。 + private var stateBeforeIntrusion: IslandState = .rest + + /// 悬停探测的兜底链路(主链路是宿主视图上的 `NSTrackingArea`)。 + private var mouseMonitor: Any? + private var hoverExitWorkItem: DispatchWorkItem? + private var hoverEnterWorkItem: DispatchWorkItem? + + /// 展开前要求指针在岛上**停留**多久。 + /// + /// 没有这个延迟的话,鼠标沿菜单栏横穿去点右上角图标时岛就会弹出来 —— + /// 用户的原话是"还没接近就立刻出来,很奇怪"。 + /// 250ms 足够滤掉路过,又不至于让有意悬停的人觉得迟钝。 + private static let hoverEnterDelay: TimeInterval = 0.25 + + /// 展开态下点击岛外任意处收回。只在展开时安装,避免常驻全局监听。 + private var outsideClickMonitor: Any? + + /// 审批态的按键处理(⌘⏎ / ⎋)。 + private var keyMonitor: Any? + + /// 收缩命中区的延迟任务,见 `updateHitPath`。 + private var hitPathShrinkWork: DispatchWorkItem? + + /// 显示器配置变化的监听。插拔外接屏、改缩放都要重新对齐。 + private var screenObserver: NSObjectProtocol? + + /// 从岛上启动一个项目。由 app 层注入(要用到 TerminalDispatch)。 + public var onLaunch: ((Project, LaunchMode) -> Void)? + + public init( + store: SessionStore, + approvals: ApprovalCoordinator, + projects: ProjectStore + ) { + self.store = store + self.approvals = approvals + self.projects = projects + } + + // MARK: - 生命周期 + + public func show() { + guard let screen = NotchGeometry.preferredScreen() else { + HubLog.island.error("找不到可用屏幕,灵动岛未启动") + return + } + let geo = NotchGeometry(screen: screen) + geometry = geo + + let hosting = IslandHostingView(rootView: AnyView(EmptyView())) + hosting.frame = CGRect(origin: .zero, size: IslandMetrics.panelSize) + hostingView = hosting + + let panel = IslandPanel(contentView: hosting) + self.panel = panel + + position(panel, on: geo) + // rootView **只设这一次**。之后全部靠 @Observable 驱动重绘 —— + // 反复替换 rootView 会让动画无从发生、@State 每次归零。 + installRootView(geometry: geo) + hosting.onPointerMove = { [weak self] point in + self?.handlePointer(at: point) + } + updateHitPath() + + panel.orderFrontRegardless() + installMouseMonitor() + observeScreenChanges() + + HubLog.island.notice(""" + 已启动 —— 屏幕 \(screen.localizedName, privacy: .public),\ + 刘海 \(Int(geo.notchWidth), privacy: .public)×\ + \(Int(geo.notchHeight), privacy: .public)pt,\ + 中心 X \(geo.centerX, privacy: .public) + """) + + applyPinnedStateIfNeeded() + } + + public func hide() { + if let screenObserver { NotificationCenter.default.removeObserver(screenObserver) } + screenObserver = nil + removeMouseMonitor() + removeOutsideClickMonitor() + removeKeyMonitor() + cancelHoverEnter() + hoverExitWorkItem?.cancel() + hitPathShrinkWork?.cancel() + intrusionDismissTask?.cancel() + panel?.orderOut(nil) + panel = nil + hostingView = nil + } + + // MARK: - 定位 + + /// 窗口水平居中于刘海中心,顶边贴屏幕顶。 + /// + /// 窗口尺寸恒为 `IslandMetrics.panelSize`,形态切换只改内部布局 —— + /// 动 window frame 会让 CoreAnimation 的隐式事务和 SwiftUI 的 spring 打架。 + private func position(_ panel: IslandPanel, on geo: NotchGeometry) { + let size = IslandMetrics.panelSize + panel.setFrame( + CGRect( + // 取整到整数点。刘海中心可能是 863.5(左右可用区不等宽), + // 半点的窗口原点会让整块内容在 2x 屏上落到半像素,边缘发虚。 + // 岛内部的形状仍以 panel 中心对称,所以这里的取整不会引入偏移。 + x: (geo.centerX - size.width / 2).rounded(), + y: (geo.topY - size.height).rounded(), + width: size.width, + height: size.height + ), + display: false + ) + } + + /// 显示器配置变了就重新对齐。 + /// + /// 以前只在 `show()` 里定位一次 —— 插拔外接屏、改分辨率缩放、 + /// 甚至把内建屏在排列里挪个位置,岛都会留在旧坐标上, + /// 表现就是"岛歪了 / 跑偏了",而且**再也不会自己回去**。 + private func observeScreenChanges() { + screenObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in self?.realign() } + } + } + + private func realign() { + guard let panel, let screen = NotchGeometry.preferredScreen() else { return } + let geo = NotchGeometry(screen: screen) + guard geo != geometry else { return } + + geometry = geo + position(panel, on: geo) + installRootView(geometry: geo) + updateHitPath() + HubLog.island.notice(""" + 显示器变化,已重新对齐 —— \(screen.localizedName, privacy: .public),\ + 刘海 \(Int(geo.notchWidth), privacy: .public)×\ + \(Int(geo.notchHeight), privacy: .public)pt + """) + } + + // MARK: - 内容 + + private func installRootView(geometry geo: NotchGeometry) { + hostingView?.rootView = AnyView( + IslandRootView( + store: store, + approvals: approvals, + projects: projects, + model: model, + geometry: geo, + onToggleExpand: { [weak self] in self?.toggleExpand() }, + onSelect: { [weak self] session in self?.select(session) }, + onFocus: { [weak self] session in self?.focus(session) }, + onCollapse: { [weak self] in self?.collapse() }, + onLaunch: { [weak self] project, mode in self?.onLaunch?(project, mode) } + ) + ) + } + + /// 开发期把形态钉死,方便在没法可靠控制鼠标的环境里截图核对布局。 + /// + /// `HUB_ISLAND_STATE=hover|expanded`。生产不会设这个变量。 + private var pinnedState: IslandState? { + switch ProcessInfo.processInfo.environment["HUB_ISLAND_STATE"] { + case "hover": return .hover + case "expanded", "projects": return .expanded + case "rest": return .rest + default: return nil + } + } + + private func applyPinnedStateIfNeeded() { + guard let pinnedState else { return } + // `projects` 顺便把展开态切到项目栏 —— 那一栏也需要能在没有鼠标的 + // 环境里截图核对。 + if ProcessInfo.processInfo.environment["HUB_ISLAND_STATE"] == "projects" { + model.tab = .projects + } + model.transition(to: pinnedState) + updateHitPath() + HubLog.island.notice( + "形态被环境变量钉住:\(String(describing: pinnedState), privacy: .public)" + ) + } + + /// 所有形态迁移的唯一入口 —— 迁移之后必须同步 AppKit 侧的命中区。 + /// + /// 钉住时**忽略一切迁移请求**。以前只是在启动时设一次, + /// 紧接着悬停监听发现指针不在岛上就把它收了回去 —— 这个调试开关等于是坏的, + /// 而它恰恰是"没法用鼠标验证时"唯一的手段。 + private func setState(_ new: IslandState) { + guard pinnedState == nil else { return } + guard new != model.state else { return } + model.transition(to: new) + updateHitPath() + } + + /// 命中路径 = 岛当前形状本身(折叠态额外外扩一点作为悬停热区)。 + /// + /// 用形状而不是包围盒做命中测试很关键:折叠态上半段严格只有刘海那么宽, + /// 用包围盒的话两侧多出的 8pt 会压在菜单栏上,把输入法、电量图标的点击吃掉。 + /// + /// **变大立即生效,变小等动画放完。** 展开动画有 0.42s,如果命中区在动画 + /// 开始时还是折叠态的大小,用户点不到正在长出来的部分;反过来收起时 + /// 如果命中区立刻缩小,退场动画的后半段就变成了不可点的"幽灵"。 + private func updateHitPath() { + guard let hostingView, let geometry else { return } + hitPathShrinkWork?.cancel() + hitPathShrinkWork = nil + + let target = hitPath(for: model.state, geometry: geometry) + let currentArea = hostingView.hitPath.map(\.boundingBox).map { $0.width * $0.height } ?? 0 + let targetArea = target.boundingBox.width * target.boundingBox.height + + if targetArea >= currentArea { + hostingView.hitPath = target + } else { + let work = DispatchWorkItem { [weak self] in + Task { @MainActor in + guard let self, let hostingView = self.hostingView else { return } + hostingView.hitPath = target + self.hitPathShrinkWork = nil + } + } + hitPathShrinkWork = work + // 0.46s 是 IslandAnimation 里最长的一条转场(rest → expanded)。 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.46, execute: work) + } + } + + private func hitPath(for state: IslandState, geometry geo: NotchGeometry) -> CGPath { + let metrics = IslandMetrics.metrics( + for: state, geometry: geo, sessionCount: store.sessions.count + ) + // 折叠态把可交互高度**只往下**多给 3pt,让那条 14pt 的细缝好点一些。 + // + // 以前是向四周各扩 6pt(`bodyWidth + growth * 2`),加上 tracking area + // 用包围盒近似,等效热区宽到覆盖刘海两侧的菜单栏 —— 鼠标路过就触发。 + // 横向一点都不能扩:那个方向紧挨着的就是菜单栏图标。 + let growth: CGFloat = state == .rest ? 3 : 0 + + let shape = IslandShape( + notchWidth: geo.notchWidth, + notchHeight: geo.notchHeight, + bodyWidth: metrics.bodyWidth, + bodyHeight: metrics.bodyHeight + growth, + bottomRadius: metrics.bottomRadius + ) + let rect = CGRect(origin: .zero, size: IslandMetrics.panelSize) + return shape.path(in: rect).cgPath + } + + // MARK: - 悬停 + + /// 来自宿主视图 `NSTrackingArea` 的指针位置(左上原点视图坐标;nil 表示离开)。 + /// + /// 这是悬停的**主链路**。`.activeAlways` 让它在 app 未激活时照样投递, + /// 而全局 `NSEvent` monitor 只是兜底 —— 两条链路都汇到这里。 + private func handlePointer(at point: CGPoint?) { + guard model.state == .rest || model.state == .hover else { return } + + // **形状二次判定。** `NSTrackingArea` 只能是矩形,用的是岛形状的包围盒 —— + // 折叠态那是 217×52pt 的一整块,横跨刘海两侧的菜单栏。只判"进没进矩形" + // 就等于把菜单栏整条都当成了热区,鼠标去点右上角图标必然误触发。 + let inside = point.map { hostingView?.hitPath?.contains($0) ?? false } ?? false + + guard inside else { + cancelHoverEnter() + scheduleHoverExit() + return + } + + hoverExitWorkItem?.cancel() + hoverExitWorkItem = nil + if model.state == .rest { scheduleHoverEnter() } + } + + /// 指针停留够久才展开。已经排队的不重复排。 + private func scheduleHoverEnter() { + guard hoverEnterWorkItem == nil else { return } + let work = DispatchWorkItem { [weak self] in + Task { @MainActor in + guard let self, self.model.state == .rest else { return } + self.hoverEnterWorkItem = nil + // 再确认一次指针还在岛上 —— 这 250ms 里它完全可能已经走了。 + guard self.pointerIsOverIsland() else { return } + self.setState(.hover) + } + } + hoverEnterWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + Self.hoverEnterDelay, execute: work) + } + + private func cancelHoverEnter() { + hoverEnterWorkItem?.cancel() + hoverEnterWorkItem = nil + } + + /// 当前鼠标位置是否落在岛的形状里。用屏幕坐标现算,不依赖事件。 + private func pointerIsOverIsland() -> Bool { + guard let panel, let hitPath = hostingView?.hitPath else { return false } + let screenPoint = NSEvent.mouseLocation + let frame = panel.frame + let local = CGPoint( + x: screenPoint.x - frame.minX, + y: frame.maxY - screenPoint.y // Cocoa 向上 → 视图向下 + ) + return hitPath.contains(local) + } + + private func installMouseMonitor() { + removeMouseMonitor() + // 兜底链路。全局 mouseMoved 监听不需要辅助功能权限(那是键盘事件才要的), + // 但它拿不到发给本 app 自己的事件,所以单靠它会有盲区 —— 主链路是 tracking area。 + mouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.mouseMoved]) { + [weak self] _ in + Task { @MainActor in self?.handleGlobalMouseMoved() } + } + } + + private func removeMouseMonitor() { + if let mouseMonitor { NSEvent.removeMonitor(mouseMonitor) } + mouseMonitor = nil + } + + private func handleGlobalMouseMoved() { + guard let geometry else { return } + // 展开、闯入、审批时不受悬停控制 —— 那几个态有各自的退出条件, + // 尤其审批绝不能因为鼠标移开就消失。 + guard model.state == .rest || model.state == .hover else { return } + + let mouse = NSEvent.mouseLocation + + // 粗筛:只有指针在屏幕顶部一小条带里才继续算,其余直接返回。 + // 这只是省算力,真正的判据是下面的形状测试。 + let bandHeight = geometry.notchHeight + 48 + guard mouse.y >= geometry.topY - bandHeight, mouse.y <= geometry.topY else { + cancelHoverEnter() + scheduleHoverExit() + return + } + + // 和 tracking area 那条链路走同一套判据(形状 + 停留延迟), + // 两条链路用不同的热区定义只会让手感变得不可预测。 + if pointerIsOverIsland() { + hoverExitWorkItem?.cancel() + hoverExitWorkItem = nil + if model.state == .rest { scheduleHoverEnter() } + } else { + cancelHoverEnter() + scheduleHoverExit() + } + } + + /// 离开热区不立刻收回:指针从岛移向岛下方内容时会短暂离开, + /// 立刻收会闪。220ms 的延迟正好盖住这种穿越。 + private func scheduleHoverExit() { + guard model.state == .hover, hoverExitWorkItem == nil else { return } + let work = DispatchWorkItem { [weak self] in + Task { @MainActor in + guard let self, self.model.state == .hover else { return } + self.model.hoveredSessionId = nil + self.setState(.rest) + self.hoverExitWorkItem = nil + } + } + hoverExitWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.22, execute: work) + } + + // MARK: - 展开 + + private func toggleExpand() { + // 审批态点击不该收起面板 —— 那等于绕过了决策。 + guard model.state != .approval else { return } + + if model.state == .expanded { + collapse() + } else { + // 没有明确选中时默认聚焦最需要处理的那个会话。 + if model.selectedSessionId == nil { + model.selectedSessionId = (store.mostUrgent ?? store.sessions.first)?.sessionId + } + setState(.expanded) + installOutsideClickMonitor() + } + } + + /// 点击 hover 态的某个小人 → 展开并显示它的详情。 + private func focus(_ session: AgentSession) { + guard model.state != .approval else { return } + model.selectedSessionId = session.sessionId + setState(.expanded) + installOutsideClickMonitor() + } + + private func collapse() { + guard model.state != .approval else { return } + setState(.rest) + removeOutsideClickMonitor() + } + + public func expand() { + guard model.state != .approval else { return } + if model.selectedSessionId == nil { + model.selectedSessionId = (store.mostUrgent ?? store.sessions.first)?.sessionId + } + setState(.expanded) + installOutsideClickMonitor() + } + + private func installOutsideClickMonitor() { + removeOutsideClickMonitor() + outsideClickMonitor = NSEvent.addGlobalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown] + ) { [weak self] event in + let location = event.locationInWindow // 全局监听里这已经是屏幕坐标 + Task { @MainActor in + guard let self, self.model.state == .expanded else { return } + // **必须排除落在岛自己身上的点击。** + // + // 全局 monitor 的契约是"发给其它 app 的事件",但本 app 是 .accessory + // 且从不激活,点岛的事件在系统看来并不属于某个活跃 app,于是这里照样 + // 会收到 —— 实测结果就是"点列表任何一行,岛立刻收起",展开态等于不可用。 + guard !self.islandContains(screenPoint: location) else { return } + self.collapse() + } + } + } + + /// 屏幕坐标的点是否落在岛当前的形状里。 + /// + /// 复用宿主视图那份 `hitPath`(左上原点),保证"能点到"和"算作岛内" + /// 用的是同一套几何 —— 两处各算一遍迟早会漂。 + private func islandContains(screenPoint: CGPoint) -> Bool { + guard let panel, let hitPath = hostingView?.hitPath else { return false } + let frame = panel.frame + let local = CGPoint( + x: screenPoint.x - frame.minX, + y: frame.maxY - screenPoint.y // Cocoa 向上 → 视图向下 + ) + return hitPath.contains(local) + } + + private func removeOutsideClickMonitor() { + if let outsideClickMonitor { NSEvent.removeMonitor(outsideClickMonitor) } + outsideClickMonitor = nil + } + + // MARK: - 闯入 + + /// 事件闯入:短暂膨胀提示,几秒后回落到原来的形态。 + public func presentIntrusion(_ event: IntrusionEvent) { + // 审批优先级最高,不能被普通事件挤掉。 + guard model.state != .approval else { return } + + intrusionDismissTask?.cancel() + if model.state != .intrusion { stateBeforeIntrusion = model.state } + model.intrusion = event + setState(.intrusion) + + // 需要用户行动的停久一点;纯"干完了"的通知快速划过就行。 + let duration: Duration = event.kind == .needsInput ? .seconds(2.6) : .seconds(2.0) + intrusionDismissTask = Task { [weak self] in + try? await Task.sleep(for: duration) + guard !Task.isCancelled else { return } + await MainActor.run { + guard let self, self.model.state == .intrusion else { return } + self.model.intrusion = nil + self.setState(self.stateBeforeIntrusion == .expanded ? .expanded : .rest) + } + } + } + + // MARK: - 审批 + + /// 弹出审批面板。**不会自动回落**,必须等用户决策或超时。 + public func presentApproval() { + intrusionDismissTask?.cancel() + model.intrusion = nil + removeOutsideClickMonitor() + setState(.approval) + + // 审批要接键盘(⌘⏎ 允许 / ⏎ ⎋ 拒绝),先试着让面板成为 key window。 + // .nonactivatingPanel 保证这不会把整个 app 激活到前台。 + panel?.makeKeyAndOrderFront(nil) + installKeyMonitor() + } + + /// 队列空了就收回去。 + public func dismissApprovalIfDone() { + guard model.state == .approval, approvals.current == nil else { return } + removeKeyMonitor() + panel?.resignKey() + setState(.rest) + } + + /// 审批态的键盘兜底。 + /// + /// app 是 `.accessory` 且从不激活,`makeKeyAndOrderFront` 未必真能让 + /// 这个 nonactivating panel 拿到 key —— 拿不到的话 SwiftUI 的 + /// `.keyboardShortcut` 就是死的。这里补一条**局部**监听 + /// (`addLocalMonitorForEvents` 不需要辅助功能权限)。 + /// + /// 键位语义和面板上写的一致,且**绝不让 ⏎ 触发允许**: + /// ⏎ / ⎋ 拒绝,⌘⏎ 允许。 + private func installKeyMonitor() { + removeKeyMonitor() + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { + [weak self] event in + guard let self, self.model.state == .approval, + let request = self.approvals.current + else { return event } + + let isReturn = event.keyCode == 36 || event.keyCode == 76 // Return / 小键盘 Enter + let isEscape = event.keyCode == 53 + + if isReturn, event.modifierFlags.contains(.command) { + // L3 要求长按,键盘不提供快捷放行。 + guard !request.requiresHoldToConfirm else { return nil } + self.approvals.allow(request) + return nil + } + if isReturn || isEscape { + self.approvals.deny(request) + return nil + } + return event + } + } + + private func removeKeyMonitor() { + if let keyMonitor { NSEvent.removeMonitor(keyMonitor) } + keyMonitor = nil + } + + // MARK: - 动作 + + /// 跳转。**只有真的跳到了才收起岛。** + /// + /// 无脑收起的话,跳转失败(会话不在任何终端里、iTerm 没开、绑定丢了)时 + /// 岛会连同失败原因一起消失,用户只知道"点了没反应"。留在展开态、 + /// 让详情卡把结果说清楚,才有下一步可走。 + private func select(_ session: AgentSession) { + model.selectedSessionId = session.sessionId + store.jump(to: session.sessionId) { [weak self] outcome in + guard let self else { return } + if case .focused = outcome { self.collapse() } + } + } + + /// 通知点击后的跳转入口。 + public func jumpAndCollapse(sessionId: String) { + store.jump(to: sessionId) + intrusionDismissTask?.cancel() + model.intrusion = nil + model.selectedSessionId = sessionId + if model.state != .approval { setState(.rest) } + } +} diff --git a/HubKit/Sources/HubUI/IslandModel.swift b/HubKit/Sources/HubUI/IslandModel.swift new file mode 100644 index 0000000..eb76bca --- /dev/null +++ b/HubKit/Sources/HubUI/IslandModel.swift @@ -0,0 +1,77 @@ +import AppKit +import SwiftUI + +/// 灵动岛的可观察状态源。 +/// +/// 存在的理由:以前形态切换是靠 `hostingView.rootView = AnyView(...)` 整棵树重建的。 +/// 那种做法有两个致命后果,换成 `@Observable` 之后一起消失: +/// +/// 1. **动画不可能发生。** 替换 rootView 是一次性的树替换,SwiftUI 看到的是 +/// "两棵不相干的树",`withAnimation` 无从下手,`.glassEffectID` 想做的 +/// 「同一滴玻璃在形变」也失去了前提。写好的 `IslandAnimation` 因此一次都没被调用过。 +/// 2. **所有 `@State` 归零。** 审批面板的 700ms 时间锁、长按进度、 +/// `TimelineView` 的时钟,每次形态一变就被重置。 +/// 展开态的两栏。 +public enum IslandTab: String, CaseIterable, Sendable, Identifiable { + /// 正在跑的会话。 + case sessions = "会话" + /// 全部项目 —— 包括**没在跑**的,可以直接从这里启动。 + case projects = "项目" + + public var id: String { rawValue } +} + +@Observable +@MainActor +public final class IslandModel { + + public private(set) var state: IslandState = .rest + /// 上一个形态。转场动画要靠 (from, to) 这一对来选参数。 + public private(set) var previousState: IslandState = .rest + + public var intrusion: IntrusionEvent? + + /// 悬停中的小人。hover 态下方那行字实时显示它的名字和状态。 + public var hoveredSessionId: String? + + /// 被点开的会话。展开态顶部的详情卡显示它。 + public var selectedSessionId: String? + + /// 展开态看哪一栏。 + /// + /// 加"项目"栏的理由:岛原来只能看**正在跑**的会话,想开一个新项目 + /// 或者切到一个没在跑的项目,必须先从菜单栏打开主窗口 —— + /// 而"开发 xxx"恰恰是这个工具最高频的动作,不该要求绕一圈。 + public var tab: IslandTab = .sessions + + public init() {} + + /// 系统的"减弱动态效果"。 + /// + /// 这里读 `NSWorkspace` 而不是 SwiftUI 的 `@Environment`: + /// 转场动画是在**视图之外**(模型的 setter 里)选的,那个位置拿不到 environment。 + private var reduceMotion: Bool { + NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + } + + /// 形态迁移。**唯一**允许改 `state` 的入口。 + public func transition(to new: IslandState) { + guard new != state else { return } + let animation = IslandAnimation.transition( + from: state, to: new, reduceMotion: reduceMotion + ) + previousState = state + withAnimation(animation) { state = new } + } + + /// 展开并聚焦某个会话 —— 「点击小人看它的具体信息」这条路径。 + public func expand(focusing sessionId: String?) { + if let sessionId { selectedSessionId = sessionId } + transition(to: .expanded) + } + + /// 内容层的淡入淡出动画。形状先到位,内容晚 80ms 浮现。 + public var contentAnimation: Animation { + IslandAnimation.contentIn(reduceMotion: reduceMotion) + } +} diff --git a/HubKit/Sources/HubUI/IslandPanel.swift b/HubKit/Sources/HubUI/IslandPanel.swift new file mode 100644 index 0000000..86e7c4d --- /dev/null +++ b/HubKit/Sources/HubUI/IslandPanel.swift @@ -0,0 +1,164 @@ +import AppKit +import SwiftUI + +/// 承载灵动岛的窗口。 +/// +/// 几个设置都不是可选项: +/// - `.borderless` + `.nonactivatingPanel`:点岛不该把 Claude Hub 变成前台 app, +/// 否则用户点一下就丢了当前 app 的焦点。 +/// - `level = .statusBar`(25):压过 `.mainMenu`(24),这样岛能盖在菜单栏之上。 +/// - `.fullScreenAuxiliary`:别的 app 全屏时岛依然可见 —— 你在全屏 IDE 里写代码时 +/// 恰恰最需要看到 agent 状态。 +/// - `hasShadow = false`:系统阴影会绕着整个 640×720 的透明矩形画,必须自己画。 +public final class IslandPanel: NSPanel { + + public init(contentView: NSView) { + super.init( + contentRect: CGRect(origin: .zero, size: IslandMetrics.panelSize), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + isOpaque = false + backgroundColor = .clear + hasShadow = false + isMovable = false + isMovableByWindowBackground = false + hidesOnDeactivate = false + isFloatingPanel = true + animationBehavior = .none + // 没有这行,窗口根本收不到 mouseMoved —— NSTrackingArea 的 .mouseMoved + // 选项也就成了摆设。默认是 false。 + acceptsMouseMovedEvents = true + // 岛不参与窗口循环,也不该出现在 Mission Control 的窗口列表里。 + collectionBehavior = [ + .canJoinAllSpaces, .stationary, .fullScreenAuxiliary, .ignoresCycle, + ] + level = .statusBar + + self.contentView = contentView + } + + /// 审批态要接 ⌘⏎ / ⎋,所以必须能成为 key window。 + /// 但 `.nonactivatingPanel` 保证成为 key 不会把整个 app 激活到前台。 + public override var canBecomeKey: Bool { true } + public override var canBecomeMain: Bool { false } +} + +/// 做逐像素点击穿透的宿主视图。 +/// +/// 窗口是 640×720 的大透明块。不处理的话,它会吞掉整个屏幕顶部中间区域的所有点击 —— +/// 用户点菜单栏、点下面的窗口全都失灵。 +/// +/// 这里不用矩形近似,直接**拿岛当前的形状路径做命中测试**: +/// 折叠态上半段严格只有刘海那么宽,两侧的菜单栏图标依然可点; +/// 形状变了命中区自动跟着变,不用维护两套几何。 +public final class IslandHostingView: NSHostingView { + + /// 当前可交互区域(左上原点的 SwiftUI 坐标系)。由控制器随形态更新。 + /// 为 nil 时整个窗口穿透。 + public var hitPath: CGPath? { + didSet { rebuildTrackingArea() } + } + + /// 指针进出岛的回调。悬停探测的**主链路**。 + /// + /// 参数是指针在视图坐标系(左上原点)里的位置;离开时为 nil。 + public var onPointerMove: ((CGPoint?) -> Void)? + + private var trackingArea: NSTrackingArea? + + // MARK: - 点击派发 + + /// **整个「岛点不动」问题的解药。** + /// + /// AppKit 的规则:点击一个**非 key 窗口**时,如果目标视图的 + /// `acceptsFirstMouse` 返回 false,这次点击只用来切换 key 状态, + /// **不会派发给视图**。`NSHostingView` 默认就是返回 false。 + /// + /// 而这个 app 是 `.accessory`(永远不是活跃 app)、panel 是 + /// `.nonactivatingPanel`(点它不激活也基本不成为 key), + /// 于是**每一次点击都算 "first mouse",全部被丢弃** —— + /// 展开、列表行、审批按钮,一个都点不动。 + /// + /// 返回 true 让点击直接进 SwiftUI,同时保持「不抢焦点」这个前提不变。 + public override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + + public override func hitTest(_ point: NSPoint) -> NSView? { + guard let hitPath else { return nil } + // hitTest 收到的点在 superview 坐标系里。 + let local = convert(point, from: superview) + guard hitPath.contains(topLeftOrigin(local)) else { return nil } + return super.hitTest(point) + } + + /// 把本视图坐标系里的点换算成 `hitPath` 所在的左上原点坐标系。 + /// + /// **不能假设 `isFlipped` 恒为 true。** SDK 里 `NSHostingView.isFlipped` 是 + /// 一个 `final` 的**可读写**属性,既不能覆写、默认值也不属于契约的一部分。 + /// 猜错的话命中区会整体翻到窗口底部 —— 720pt 高的窗口,岛在顶部 46pt, + /// 翻过来就落在屏幕中间的空气里,整个岛完全点不动,且没有任何报错。 + private func topLeftOrigin(_ local: CGPoint) -> CGPoint { + isFlipped ? local : CGPoint(x: local.x, y: bounds.height - local.y) + } + + /// 事件里的 `locationInWindow` → 左上原点坐标。 + private func pointerLocation(of event: NSEvent) -> CGPoint { + topLeftOrigin(convert(event.locationInWindow, from: nil)) + } + + // MARK: - 悬停 + + /// 悬停探测的主链路是 `NSTrackingArea`,不是全局 `NSEvent` monitor。 + /// + /// 关键在 `.activeAlways`:它让 mouseEntered / mouseExited / mouseMoved + /// 在**本 app 未激活**时照样投递 —— 这正是菜单栏类 app 的标准做法。 + /// 默认的 `.activeInKeyWindow` 对一个永远不是 key 的 panel 等于什么都不做。 + /// + /// 控制器里那个全局 monitor 保留着,但降级为兜底:两条链路都指向同一个 + /// 状态机,只用一条就是单点失效。 + private func rebuildTrackingArea() { + if let trackingArea { removeTrackingArea(trackingArea) } + trackingArea = nil + + guard let hitPath else { return } + // 用路径包围盒做 tracking rect(tracking area 只支持矩形)。 + // 精确的形状判定仍然在 hitTest 里做,所以这里宽松一点不会造成误点击 —— + // 只是让"接近岛"就能开始探测,本来就是想要的行为。 + var rect = hitPath.boundingBox + guard !rect.isEmpty else { return } + if !isFlipped { + rect.origin.y = bounds.height - rect.maxY + } + + let area = NSTrackingArea( + rect: rect, + options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways], + owner: self, + userInfo: nil + ) + addTrackingArea(area) + trackingArea = area + } + + public override func updateTrackingAreas() { + super.updateTrackingAreas() + rebuildTrackingArea() + } + + public override func mouseEntered(with event: NSEvent) { + super.mouseEntered(with: event) + onPointerMove?(pointerLocation(of: event)) + } + + public override func mouseMoved(with event: NSEvent) { + super.mouseMoved(with: event) + onPointerMove?(pointerLocation(of: event)) + } + + public override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + onPointerMove?(nil) + } +} diff --git a/HubKit/Sources/HubUI/IslandRootView.swift b/HubKit/Sources/HubUI/IslandRootView.swift new file mode 100644 index 0000000..07130fc --- /dev/null +++ b/HubKit/Sources/HubUI/IslandRootView.swift @@ -0,0 +1,1116 @@ +import HubCore +import HubProjects +import SwiftUI + +/// 灵动岛的根视图。 +/// +/// 结构:`玻璃外壳` → `scrim` → `内容`。 +/// +/// 形态来自 `IslandModel`(`@Observable`),**不是**通过替换 rootView 切换的 —— +/// 那样做的话 SwiftUI 看到的是两棵不相干的树,动画无从发生、`@State` 每次归零。 +public struct IslandRootView: View { + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + + private let store: SessionStore + private let approvals: ApprovalCoordinator + private let projects: ProjectStore + private let model: IslandModel + private let geometry: NotchGeometry + private let onToggleExpand: () -> Void + private let onSelect: (AgentSession) -> Void + private let onFocus: (AgentSession) -> Void + private let onCollapse: () -> Void + private let onLaunch: (Project, LaunchMode) -> Void + + @Namespace private var glassNamespace + + public init( + store: SessionStore, + approvals: ApprovalCoordinator, + projects: ProjectStore, + model: IslandModel, + geometry: NotchGeometry, + onToggleExpand: @escaping () -> Void, + onSelect: @escaping (AgentSession) -> Void, + onFocus: @escaping (AgentSession) -> Void, + onCollapse: @escaping () -> Void, + onLaunch: @escaping (Project, LaunchMode) -> Void + ) { + self.store = store + self.approvals = approvals + self.projects = projects + self.model = model + self.geometry = geometry + self.onToggleExpand = onToggleExpand + self.onSelect = onSelect + self.onFocus = onFocus + self.onCollapse = onCollapse + self.onLaunch = onLaunch + } + + private var state: IslandState { model.state } + + private var metrics: IslandMetrics { + IslandMetrics.metrics( + for: state, geometry: geometry, sessionCount: store.sessions.count + ) + } + + private var shape: IslandShape { + IslandShape( + notchWidth: geometry.notchWidth, + notchHeight: geometry.notchHeight, + bodyWidth: metrics.bodyWidth, + bodyHeight: metrics.bodyHeight, + bottomRadius: metrics.bottomRadius + ) + } + + /// 内容区顶部让出的高度。有刘海时让出刘海本身(那块被物理遮挡,画什么都看不见), + /// 无刘海时让出胶囊的悬浮间距。 + private var contentTopInset: CGFloat { + geometry.hasNotch ? geometry.notchHeight : 8 + } + + /// 开发期定位用:HUB_DEBUG_CONTENT=1 时给内容框描一层可见底色, + /// 一眼看出它到底被放在哪、有没有被裁掉。 + private var debugTint: Color { + ProcessInfo.processInfo.environment["HUB_DEBUG_CONTENT"] == "1" + ? Color.red.opacity(0.25) + : Color.clear + } + + public var body: some View { + ZStack(alignment: .top) { + shell + contentLayer + } + .frame( + width: IslandMetrics.panelSize.width, + height: IslandMetrics.panelSize.height, + alignment: .topLeading + ) + // 岛永远暗色,不跟随系统。不是审美偏好,是物理约束:岛的上半截必须和刘海 + // 严丝合缝,而刘海在浅色模式下也是纯黑 —— 做浅色岛会在刘海下沿留一条 + // 永远消不掉的黑白硬边。 + .environment(\.colorScheme, .dark) + } + + // MARK: - 外壳 + + @ViewBuilder + private var shell: some View { + ZStack(alignment: .topLeading) { + if reduceTransparency { + // 完全兜底:关掉透明度后材质没有意义,换成接近不透明的实心底。 + shape + .fill(Color.black.opacity(0.97)) + .overlay(shape.stroke(.white.opacity(0.14), lineWidth: 1)) + } else { + // 自控的深色材质,**不是** Liquid Glass —— 完整理由见 IslandBackdrop。 + IslandBackdrop() + .clipShape(shape) + + // 固定的暗色基底。这一层决定了岛的颜色,**不参与任何自适应**。 + // + // 绕不开的算术:任何"能透出背景"的材质,在白底上都必然比在黑底上亮。 + // 试过 Liquid Glass(`.regular` / `.clear` + tint)和 + // NSVisualEffectView 的多种 material,白底下输出都落在 0.5~0.7, + // 差别只是程度。**要在任何背景下都贴近刘海,就只能接近不透明。** + // + // 最终亮度 ≈ 背景亮度 × (1 − IslandTheme.baseOpacity) + // 白底 (1.0) → 0.14 黑底 (0.0) → 0.0 刘海 → 0.0 + // + // 留下的这 14% 正是"还能看出背后有东西"的量:透光感还在, + // 但不足以把岛拉离刘海的色系。玻璃质感由背景模糊 + 边缘高光承担。 + shape + .fill(Color.black.opacity(IslandTheme.baseOpacity)) + .allowsHitTesting(false) + } + + // 闯入 / 审批的状态色。 + // + // 染在材质**之上**而不是作为材质的 tint,这样它不受背景亮度影响, + // 任何壁纸下都是同一个颜色 —— 状态色的作用是传达"出事了", + // 它必须是可预期的常量。 + if let shellTint { + shape.fill(shellTint).allowsHitTesting(false) + } + + // scrim:可读性主力,见 IslandTheme.scrim 的说明。 + shape + .fill(IslandTheme.scrim(for: state)) + .allowsHitTesting(false) + + // 刘海 → 岛的过渡。 + // + // 这一层专治"岛和刘海之间有一条明显分界线":纯黑一直铺到刘海下沿, + // 再往下用 34pt 渐隐掉。没有它的话,不透光的刘海和半透明的玻璃 + // 直接相接,边界处的亮度落差会把"岛是另外贴上去的一块东西"暴露无遗。 + if geometry.hasNotch { + shape + .fill(IslandTheme.notchBlend( + notchHeight: geometry.notchHeight, + panelHeight: IslandMetrics.panelSize.height + )) + .allowsHitTesting(false) + } + + // 边缘高光。玻璃感的关键一笔:真实玻璃的边缘因折射而比中间亮, + // 下沿尤其明显。没有这条边,再透明也只会被读成一块半透明色块。 + shape + .stroke(IslandTheme.edgeHighlight, lineWidth: 1) + .allowsHitTesting(false) + + // 刘海遮蔽:物理刘海就是纯黑,这块区域画玻璃只会露馅(玻璃会透出 + // 桌面的颜色,而刘海是不透光的实体,两者对不上就会看出破绽)。 + // 画在最上层,顺便盖掉边缘高光在刘海段可能漏出的那一道亮线。 + if geometry.hasNotch { + Rectangle() + .fill(.black) + .frame(width: geometry.notchWidth, height: geometry.notchHeight) + .offset( + x: (IslandMetrics.panelSize.width - geometry.notchWidth) / 2, + y: 0 + ) + .allowsHitTesting(false) + } + } + .frame( + width: IslandMetrics.panelSize.width, + height: IslandMetrics.panelSize.height, + alignment: .topLeading + ) + .contentShape(shape) + .onTapGesture { onToggleExpand() } + } + + // MARK: - 内容 + + /// 内容层。 + /// + /// 顶部那块让位用的 `Color.clear` 必须 `allowsHitTesting(false)` —— + /// `Color.clear` 是**参与命中测试**的,一块 640pt 宽的透明色压在外壳之上, + /// 会把那一带的点击全部吃掉,外壳的 `onTapGesture` 永远不触发。 + private var contentLayer: some View { + VStack(spacing: 0) { + Color.clear + .frame(height: contentTopInset) + .allowsHitTesting(false) + + content + .frame(width: metrics.bodyWidth, height: metrics.bodyHeight) + .background(debugTint) + // 裁到岛主体的形状(下沿有圆角),而不是一个方框 —— + // 方框会让内容在圆角处露到玻璃外面。 + .clipShape( + .rect( + bottomLeadingRadius: metrics.bottomRadius, + bottomTrailingRadius: metrics.bottomRadius, + style: .continuous + ) + ) + // 形态一换就整块换掉,配合下面的 transition 做淡入淡出。 + .id(state) + .transition(.opacity) + + Spacer(minLength: 0) + .allowsHitTesting(false) + } + .frame( + width: IslandMetrics.panelSize.width, + height: IslandMetrics.panelSize.height, + alignment: .top + ) + // 内容比形状晚 80ms 出现(见 IslandAnimation.contentIn)。 + // + // 这个 .animation 会**覆盖**外层 withAnimation 传下来的事务动画, + // 从而让形状和内容用两条不同的时间曲线 —— 形状先到位、内容再浮现。 + // 这是 Dynamic Island 手感的核心,去掉它整个动效会显得廉价。 + .animation(IslandAnimation.contentIn(reduceMotion: reduceMotion), value: state) + } + + @ViewBuilder + private var content: some View { + switch state { + case .rest: + RestContent(store: store) + .contentShape(Rectangle()) + .onTapGesture { onToggleExpand() } + case .hover: + HoverContent(store: store, model: model, onFocus: onFocus) + case .expanded: + ExpandedContent( + store: store, + projects: projects, + model: model, + onSelect: onSelect, + onCollapse: onCollapse, + onLaunch: onLaunch + ) + case .intrusion: + if let intrusion = model.intrusion { + IntrusionContent( + event: intrusion, + session: store.sessions.first { $0.sessionId == intrusion.sessionId } + ) + } + case .approval: + if let request = approvals.current { + ApprovalContent( + request: request, + queueCount: approvals.queue.count, + onAllow: { approvals.allow(request) }, + onDeny: { approvals.deny(request) }, + onSkip: { approvals.skip(request) } + ) + } + } + } + + /// 闯入/审批时给外壳染上状态色,让人一眼知道这不是普通展开。 + private var shellTint: Color? { + switch state { + case .intrusion: + return model.intrusion?.accent.opacity(0.26) + case .approval: + guard let risk = approvals.current?.risk else { return nil } + return risk == .irreversible + ? IslandTheme.danger.opacity(0.28) + : IslandTheme.waiting.opacity(0.24) + default: + return nil + } + } +} + +// MARK: - 折叠态 + +/// 折叠常驻内容:会话数 + 状态点阵 + waiting 徽章。 +/// +/// 这里**不画像素小人** —— 14 pt 高的条里放 16×16 的像素画只会糊成一坨, +/// 而且 9 个小人持续动画既是视觉噪音也是无谓能耗。小人从 hover 才出场, +/// 这也让悬停有了"奖励感"。 +struct RestContent: View { + let store: SessionStore + + var body: some View { + HStack(spacing: 10) { + // 左侧的等宽隐形占位。 + // + // 没有它的话,徽章一出现,「计数 + 状态点」这一组就会被整体挤向左 —— + // 整个 HStack 是居中的,但视觉重心(那排点)不在岛的对称轴上, + // 读起来就是"歪了"。用 `.hidden()` 而不是写死一个宽度: + // 徽章两位数时会变宽,隐形副本会跟着变,永远对得上。 + if store.waitingCount > 0 { + WaitingBadge(count: store.waitingCount).hidden() + } + + Text("\(store.sessions.count)") + .font(IslandTheme.label(10, .semibold)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.78)) + + HStack(spacing: 4) { + ForEach(store.sessions.prefix(12)) { session in + StatusDot(status: session.status, size: 6) + } + } + + if store.waitingCount > 0 { + // 折叠态**也**要能在余光里被看见。岛收起来的时候正是用户 + // 注意力在别处的时候,一个静止的小徽章会被完全忽略。 + WaitingBadge(count: store.waitingCount) + .transition(.scale(scale: 0.4).combined(with: .opacity)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +/// 折叠态的待办徽章。带呼吸光晕。 +struct WaitingBadge: View { + let count: Int + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var breathing = false + + var body: some View { + Text("\(count)") + .font(IslandTheme.label(9, .bold)) + .monospacedDigit() + .foregroundStyle(.black) + .padding(.horizontal, 5) + .frame(height: 12) + .background(IslandTheme.waiting, in: .capsule) + .shadow( + color: IslandTheme.waiting.opacity(breathing ? 0.85 : 0.25), + radius: breathing ? 5 : 2 + ) + .onAppear { + guard !reduceMotion else { return } + withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { + breathing = true + } + } + } +} + +// MARK: - 悬停态 + +/// 悬停态:一排像素小人,指到谁谁抬起来并显示名字,点一下展开它的详情。 +/// +/// 全部小人画在**同一个 Canvas**(`SpriteRow`)里,交互靠上面叠的一层透明命中格 —— +/// 这样既保住了"单 Canvas 单时钟"的能耗设计,又拿到了逐个小人的悬停和点击。 +/// 给每个小人套一个独立视图的话就是 N 个 Canvas + N 个 TimelineView。 +struct HoverContent: View { + let store: SessionStore + let model: IslandModel + let onFocus: (AgentSession) -> Void + + private var sessions: [AgentSession] { + Array(store.sessions.prefix(IslandMetrics.hoverSpriteLimit)) + } + + private var hovered: AgentSession? { + sessions.first { $0.sessionId == model.hoveredSessionId } + } + + private var spriteSide: CGFloat { IslandMetrics.hoverSpriteSide } + private var spacing: CGFloat { IslandMetrics.hoverSpriteSpacing } + /// 命中格的高度 = 小人 + 抬升余量 + 状态条那一块,整格都可点。 + private var cellHeight: CGFloat { + spriteSide + IslandMetrics.hoverLift + IslandMetrics.hoverStatusBarBlock + } + + var body: some View { + VStack(spacing: IslandMetrics.hoverGap) { + SpriteScreen { screen } + caption + } + .padding(.vertical, IslandMetrics.hoverVerticalInset) + .frame(maxWidth: .infinity, maxHeight: .infinity) + // 点小人之间的空隙、点摘要文字,同样要能展开 —— 否则 380pt 宽的岛 + // 只有四个 32pt 的小方块可点,其余全是死区。 + .contentShape(Rectangle()) + .onTapGesture { + onFocus(hovered ?? store.mostUrgent ?? sessions.first ?? placeholder) + } + } + + /// 一个会话都没有时的兜底,避免 `onTapGesture` 里出现可选解包。 + private var placeholder: AgentSession { + AgentSession( + sessionId: "", pid: 0, cwd: NSHomeDirectory(), name: nil, + status: .unknown, rawStatus: "", kind: .unknown + ) + } + + private var screen: some View { + ZStack(alignment: .topLeading) { + // ① 待办高亮。**必须和命中层分开。** + // + // 之前把它塞进命中格里,结果那格点不动了 —— 它带 shadow 和 + // repeatForever 动画,会被合成成一个离屏层,把点击吞掉。 + // 装饰归装饰、命中归命中,中间不要互相插手。 + haloLayer + + VStack(spacing: 0) { + SpriteRow( + sessions: sessions, + pixelSize: 2, + gridSize: 16, + spacing: spacing, + useMicroArt: false, + supportsLift: true, + liftedSessionId: model.hoveredSessionId + ) + Spacer(minLength: 0) + // 每个小人下方一条状态色横条:比色点更能在 32pt 网格下形成节律。 + HStack(spacing: spacing) { + ForEach(sessions) { session in + RoundedRectangle(cornerRadius: 1.5, style: .continuous) + .fill(barColor(for: session)) + .frame(width: barWidth(for: session), height: 3) + .frame(width: spriteSide) + } + } + } + .frame(height: cellHeight) + .allowsHitTesting(false) + + hitGrid + } + .frame( + width: IslandMetrics.hoverRowWidth(sessionCount: store.sessions.count), + height: cellHeight + ) + } + + /// 待办高亮层。**纯装饰,完全不参与命中测试。** + /// + /// 光有下方那条状态色横条是不够的 —— 用户的原话是 + /// "通知来了有个要处理,没有高亮提醒,就不知道哪个需要我进行下一步操作"。 + /// "该你了"必须在余光里就能被抓到,不能要求先逐个去认颜色。 + private var haloLayer: some View { + HStack(spacing: spacing) { + ForEach(sessions) { session in + Group { + if session.status == .waiting { + WaitingHalo() + } else { + Color.clear + } + } + .frame(width: spriteSide, height: cellHeight) + } + } + .frame(height: cellHeight) + .allowsHitTesting(false) + } + + /// 叠在最上面的命中格。每格对应一个小人。 + /// + /// 两条约束: + /// + /// 1. **这一层只有 `Color.clear`**,不放任何绘制内容。带 shadow 或常驻动画的 + /// 视图会被合成成离屏层,进而把点击吃掉 —— 待办高亮塞进来时就是这样, + /// 高亮的那一格反而点不动了。 + /// 2. **格子之间不留缝。** 视觉上小人之间有 8pt 间距,但那 8pt 如果不属于 + /// 任何一格就是死区;再加上高亮光晕本来就会向外溢出, + /// "看起来高亮的地方"有一圈根本不可点。所以命中格按 `小人宽 + 间距` + /// 连续平铺,间距归两侧各一半。 + private var hitGrid: some View { + HStack(spacing: 0) { + ForEach(sessions) { session in + Color.clear + .frame(width: spriteSide + spacing, height: cellHeight) + .contentShape(Rectangle()) + .onHover { inside in + if inside { + model.hoveredSessionId = session.sessionId + } else if model.hoveredSessionId == session.sessionId { + model.hoveredSessionId = nil + } + } + .onTapGesture { onFocus(session) } + .help(session.name ?? session.fallbackProjectName) + } + } + // 平铺后总宽比小人排多出一个 spacing,向两侧各退半格,保持同轴。 + .padding(.horizontal, -spacing / 2) + } + + private func barColor(for session: AgentSession) -> Color { + let base = IslandTheme.color(for: session.status) + if session.sessionId == model.hoveredSessionId { return base } + return base.opacity(session.status == .idle ? 0.35 : 0.85) + } + + /// 被指到的那条横条变长,作为"选中"的第二编码通道(不只靠亮度)。 + private func barWidth(for session: AgentSession) -> CGFloat { + session.sessionId == model.hoveredSessionId ? spriteSide - 4 : 16 + } + + // MARK: 名字行 + + @ViewBuilder + private var caption: some View { + if let hovered { + VStack(spacing: 1) { + Text(hovered.name ?? hovered.fallbackProjectName) + .font(IslandTheme.label(13, .semibold)) + .foregroundStyle(.white) + .lineLimit(1) + .truncationMode(.middle) + HStack(spacing: 5) { + StatusDot(status: hovered.status, size: 5) + Text(statusText(for: hovered)) + .font(IslandTheme.label(11)) + .foregroundStyle(IslandTheme.color(for: hovered.status).opacity(0.95)) + Text("·") + .font(IslandTheme.label(11)) + .foregroundStyle(.white.opacity(0.35)) + Text(hovered.fallbackProjectName) + .font(IslandTheme.label(11)) + .foregroundStyle(.white.opacity(0.62)) + .lineLimit(1) + } + } + .frame(height: IslandMetrics.hoverCaptionHeight) + .padding(.horizontal, 12) + } else if let urgent = store.mostUrgent { + // **有人在等你时,默认就把它的名字顶出来。** + // + // 之前这里只显示 "1 忙 · 1 待办 · 1 命令 · 1 闲" 这种汇总 —— + // 它回答了"有几个在等",却没回答"是哪一个",用户还是得逐个去指。 + // 而"哪个需要我下一步操作"恰恰是打开这个岛最主要的原因。 + VStack(spacing: 1) { + HStack(spacing: 5) { + Image(systemName: "exclamationmark.circle.fill") + .font(.system(size: 10)) + Text(urgent.name ?? urgent.fallbackProjectName) + .font(IslandTheme.label(13, .semibold)) + .lineLimit(1) + .truncationMode(.middle) + Text("在等你") + .font(IslandTheme.label(11)) + .foregroundStyle(IslandTheme.waiting.opacity(0.75)) + } + .foregroundStyle(IslandTheme.waiting) + + Text(summary) + .font(IslandTheme.label(10)) + .foregroundStyle(.white.opacity(0.45)) + } + .frame(height: IslandMetrics.hoverCaptionHeight) + .padding(.horizontal, 12) + } else { + VStack(spacing: 1) { + Text(summary) + .font(IslandTheme.label(12, .medium)) + .foregroundStyle(.white.opacity(0.82)) + Text("指一个小人看它在干嘛,点一下展开") + .font(IslandTheme.label(10)) + .foregroundStyle(.white.opacity(0.42)) + } + .frame(height: IslandMetrics.hoverCaptionHeight) + } + } + + private func statusText(for session: AgentSession) -> String { + switch session.status { + case .busy: return "工作中" + case .waiting: return session.waitingFor ?? "等你输入" + case .shell: return "跑命令" + case .idle: return "空闲" + case .unknown: return session.rawStatus + } + } + + private var summary: String { + var parts: [String] = [] + if store.busyCount > 0 { parts.append("\(store.busyCount) 忙") } + if store.waitingCount > 0 { parts.append("\(store.waitingCount) 待办") } + if store.shellCount > 0 { parts.append("\(store.shellCount) 命令") } + if store.idleCount > 0 { parts.append("\(store.idleCount) 闲") } + return parts.isEmpty ? "没有会话" : parts.joined(separator: " · ") + } +} + +/// waiting 小人身后的呼吸光晕。 +/// +/// 这是"该你了"的主要视觉抓取手段。三条叠加: +/// 琥珀色是全套配色里最跳的、**只有它会动**、而且带外发光。 +/// +/// 呼吸周期给到 1.6 秒并且幅度克制 —— 快速闪烁在余光里是"警报", +/// 会让人焦虑;慢呼吸是"这里有件事",不打断当前的注意力。 +struct WaitingHalo: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var breathing = false + + var body: some View { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(IslandTheme.waiting.opacity(breathing ? 0.26 : 0.13)) + .overlay { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(IslandTheme.waiting.opacity(breathing ? 0.75 : 0.4), lineWidth: 1) + } + // 光晕控制在 4pt 以内:它要落在命中格里面。溢出去的那一圈 + // 看着是高亮、点下去却没反应,比不高亮更让人困惑。 + .shadow(color: IslandTheme.waiting.opacity(breathing ? 0.55 : 0.2), radius: 4) + .onAppear { + guard !reduceMotion else { return } + withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) { + breathing = true + } + } + } +} + +// MARK: - 展开态 + +struct ExpandedContent: View { + let store: SessionStore + let projects: ProjectStore + let model: IslandModel + let onSelect: (AgentSession) -> Void + let onCollapse: () -> Void + let onLaunch: (Project, LaunchMode) -> Void + + private var selected: AgentSession? { + store.sessions.first { $0.sessionId == model.selectedSessionId } + ?? store.mostUrgent + ?? store.sessions.first + } + + var body: some View { + VStack(spacing: 0) { + header + tabPicker + + switch model.tab { + case .sessions: sessionsBody + case .projects: ProjectListContent( + projects: projects, store: store, onJump: onSelect, onLaunch: onLaunch + ) + } + } + // 终端定位要跑 AppleScript、git 要跑子进程,只在真的展开时解析(内部有节流)。 + .onAppear { + store.resolveTerminalLabels() + store.resolveWorkspaceGit(for: store.sessions.map(\.cwd)) + } + } + + /// 会话 / 项目 切换。 + /// + /// 不用系统的 `.segmented` Picker:那个控件在非活跃窗口里会整体去饱和, + /// 和岛上其它按钮是同一个毛病(见 `IslandButtonStyle`)。 + private var tabPicker: some View { + HStack(spacing: 4) { + ForEach(IslandTab.allCases) { tab in + let selected = model.tab == tab + Button { + withAnimation(.snappy(duration: 0.18)) { model.tab = tab } + } label: { + HStack(spacing: 5) { + Text(tab.rawValue) + Text(tab == .sessions + ? "\(store.sessions.count)" + : "\(projects.projects.count)") + .monospacedDigit() + .foregroundStyle(.white.opacity(selected ? 0.55 : 0.35)) + } + .font(IslandTheme.label(12, .semibold)) + .foregroundStyle(.white.opacity(selected ? 0.95 : 0.5)) + .padding(.horizontal, 10) + .frame(height: 24) + .background( + .white.opacity(selected ? 0.14 : 0), + in: .rect(cornerRadius: 7, style: .continuous) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + Spacer() + } + .padding(.horizontal, 14) + .padding(.bottom, 6) + } + + private var sessionsBody: some View { + VStack(spacing: 0) { + if let selected { + SessionDetailCard( + session: selected, + workspace: store.workspaceGit[selected.cwd], + terminalLabel: store.terminalLabels[selected.sessionId], + jumpOutcome: store.lastJump?.sessionId == selected.sessionId + ? store.lastJump?.outcome : nil, + onJump: { onSelect(selected) } + ) + .padding(.horizontal, 12) + .padding(.bottom, 8) + } + + Divider().opacity(0.22) + + ScrollView { + LazyVStack(spacing: 3) { + ForEach(store.sessions) { session in + SessionRowView( + session: session, + branch: store.workspaceGit[session.cwd]?.info.branch, + isSelected: session.sessionId == selected?.sessionId, + // 点行 = 看详情。点行内的 ⇱ 按钮 = 直接跳转。 + // 两个动作各有入口,不用"先选中再点第二下"。 + onSelect: { model.selectedSessionId = session.sessionId }, + onJump: { onSelect(session) } + ) + } + } + .padding(.vertical, 6) + .padding(.horizontal, 10) + } + .scrollIndicators(.never) + } + } + + private var header: some View { + HStack(spacing: 8) { + Text("Claude Hub") + .font(IslandTheme.label(13, .semibold)) + .foregroundStyle(.white) + Text("\(store.sessions.count) 会话") + .font(IslandTheme.label(11)) + .foregroundStyle(.white.opacity(0.5)) + Spacer() + if store.waitingCount > 0 { + Label("\(store.waitingCount)", systemImage: "exclamationmark.circle.fill") + .font(IslandTheme.label(11, .semibold)) + .foregroundStyle(IslandTheme.waiting) + } + Button(action: onCollapse) { + Image(systemName: "chevron.up") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.white.opacity(0.6)) + .frame(width: 22, height: 22) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("收起") + } + .padding(.horizontal, 16) + .frame(height: IslandMetrics.expandedHeaderHeight) + } +} + +// MARK: - 项目栏 + +/// 岛上的项目列表。 +/// +/// 存在的理由不是"也能浏览项目",而是**把「开发 xxx」这个动作收进岛里**。 +/// 在这之前,想开一个还没在跑的项目必须:点菜单栏 → 开主窗口 → 找到项目 → 启动。 +/// 而这是这个工具最高频的动作。 +/// +/// 排序:**有会话在跑的排前面**(点它多半是想切过去),其余按名字。 +/// 不做搜索框:岛的 panel 基本不是 key window,文本框拿不到键盘焦点, +/// 放一个点不动的搜索框比没有更糟。要搜索去主窗口。 +struct ProjectListContent: View { + let projects: ProjectStore + let store: SessionStore + let onJump: (AgentSession) -> Void + let onLaunch: (Project, LaunchMode) -> Void + + /// 这个项目下正在跑的会话(cwd 落在项目目录里就算)。 + private func sessions(of project: Project) -> [AgentSession] { + let root = project.expandedPath + return store.sessions.filter { $0.cwd == root || $0.cwd.hasPrefix(root + "/") } + } + + private var ordered: [Project] { + projects.projects.sorted { lhs, rhs in + let l = sessions(of: lhs).count + let r = sessions(of: rhs).count + if (l > 0) != (r > 0) { return l > 0 } + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + } + + var body: some View { + Group { + if projects.projects.isEmpty { + emptyState + } else { + ScrollView { + LazyVStack(spacing: 3) { + ForEach(ordered) { project in + ProjectRowView( + project: project, + running: sessions(of: project), + git: projects.git(for: project), + onOpen: { open(project) }, + onLaunch: { onLaunch(project, $0) } + ) + } + } + .padding(.vertical, 6) + .padding(.horizontal, 10) + } + .scrollIndicators(.never) + } + } + .onAppear { projects.refreshGit() } + } + + /// 点一行的默认动作: + /// **有会话在跑就跳过去,没有就直接起一个。** + /// 这两件事用户想要的其实是同一个 —— "让我到这个项目上去"。 + private func open(_ project: Project) { + if let session = sessions(of: project).min(by: { $0.status < $1.status }) { + onJump(session) + } else { + onLaunch(project, .claude) + } + } + + private var emptyState: some View { + VStack(spacing: 6) { + Text("没有读到项目") + .font(IslandTheme.label(13, .semibold)) + .foregroundStyle(IslandTheme.waiting) + Text(projects.loadDiagnostic ?? "projects.yaml 里没有条目") + .font(IslandTheme.label(11)) + .foregroundStyle(.white.opacity(0.55)) + if let path = projects.sourceURL?.path { + Text(path) + .font(IslandTheme.mono(9)) + .foregroundStyle(.white.opacity(0.35)) + .lineLimit(1) + .truncationMode(.head) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 20) + } +} + +/// 项目列表的一行。 +struct ProjectRowView: View { + let project: Project + let running: [AgentSession] + let git: GitInfo? + let onOpen: () -> Void + let onLaunch: (LaunchMode) -> Void + + @State private var isHovered = false + + /// 这个项目当前"最需要注意"的状态:拿在跑的会话里优先级最高的那个。 + private var status: SessionStatus? { + running.min(by: { $0.status < $1.status })?.status + } + + var body: some View { + HStack(spacing: 0) { + RoundedRectangle(cornerRadius: 1, style: .continuous) + .fill(status.map(IslandTheme.color(for:)) ?? .white.opacity(0.14)) + .frame(width: 2, height: 28) + + // 有会话在跑就把那个人画出来 —— 一眼看出"这个项目现在有人在忙"。 + Group { + if let session = running.min(by: { $0.status < $1.status }) { + PixelSprite(session: session, pixelSize: 2, gridSize: 8, useMicroArt: true) + } else { + Image(systemName: "folder") + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.3)) + .frame(width: 16, height: 16) + } + } + .padding(.leading, 8) + + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 6) { + Text(project.name) + .font(IslandTheme.label(13, .semibold)) + .foregroundStyle(.white.opacity(running.isEmpty ? 0.78 : 1)) + .lineLimit(1) + if running.count > 0 { + Text("\(running.count) 个会话") + .font(IslandTheme.label(10, .medium)) + .foregroundStyle(status.map(IslandTheme.color(for:)) ?? .white) + } + } + HStack(spacing: 5) { + if let branch = git?.branch { + Text(branch) + .font(IslandTheme.mono(10)) + .foregroundStyle(.white.opacity(0.45)) + .lineLimit(1) + .truncationMode(.middle) + } + if let changes = git?.changeCount, changes > 0 { + Text("\(changes)△") + .font(IslandTheme.mono(10, .semibold)) + .foregroundStyle(IslandTheme.waiting.opacity(0.8)) + } + } + } + .padding(.leading, 10) + + Spacer(minLength: 8) + + // 启动模式菜单。默认动作(点整行)已经覆盖了绝大多数情况, + // 这里给的是"我要换一种开法"——继续上次会话、跳过权限、只开终端等等。 + Menu { + ForEach(LaunchMode.allCases, id: \.self) { mode in + if mode != .resumeSession { + Button(mode.label) { onLaunch(mode) } + } + } + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white.opacity(0.7)) + .frame(width: 26, height: 22) + .contentShape(Rectangle()) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .opacity(isHovered ? 1 : 0.3) + .padding(.trailing, 8) + } + .frame(height: IslandMetrics.expandedProjectRowHeight) + .background { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(.white.opacity(isHovered ? 0.08 : 0.028)) + } + .contentShape(Rectangle()) + .onTapGesture(perform: onOpen) + .onHover { isHovered = $0 } + .help(project.expandedPath) + } +} + +/// 展开态的一行。 +/// +/// 两个交互目标: +/// 1. **整行都可点**,不是只有文字可点。 +/// 2. 右侧常驻快捷按钮 —— 常见操作(跳转、Finder)不该要求"先选中再点第二下"。 +struct SessionRowView: View { + let session: AgentSession + var branch: String? + var isSelected = false + var onSelect: () -> Void = {} + var onJump: () -> Void = {} + + @State private var isHovered = false + + var body: some View { + content + // 背景**必须常驻**(哪怕近乎透明)。 + // + // 踩过的坑:以前只有 selected / waiting 的行才有背景, + // 结果普通行的空白区域没有任何可命中的内容,用户只能点到文字上 —— + // 一行 580pt 宽,真正能点的只有左边那一小截文字。 + .background(rowBackground) + .contentShape(Rectangle()) + .onTapGesture(perform: onSelect) + .onHover { isHovered = $0 } + } + + @ViewBuilder + private var rowBackground: some View { + let shape = RoundedRectangle(cornerRadius: 10, style: .continuous) + ZStack { + if isSelected { + shape.fill(.white.opacity(0.12)) + shape.stroke(.white.opacity(0.18), lineWidth: 1) + } else if session.status == .waiting { + // waiting 行常驻高亮,扫一眼就能定位。 + shape.fill(IslandTheme.waiting.opacity(isHovered ? 0.16 : 0.10)) + } else { + shape.fill(.white.opacity(isHovered ? 0.08 : 0.028)) + } + } + } + + private var content: some View { + HStack(spacing: 0) { + // 左边条编码 kind:interactive 实线,bg 半透明 —— 比塞一个图标省地方。 + RoundedRectangle(cornerRadius: 1, style: .continuous) + .fill(IslandTheme.color(for: session.status) + .opacity(session.kind == .bg ? 0.4 : 1.0)) + .frame(width: 2, height: 36) + + // 列表行用 8×8 的简化剪影(Micro art),不是把 16×16 缩小 —— + // 那样眼睛、手指这些 1px 细节会退化成噪点。 + PixelSprite(session: session, pixelSize: 2, gridSize: 8, useMicroArt: true) + .padding(.leading, 8) + + StatusDot(status: session.status, size: 6) + .padding(.leading, 6) + + VStack(alignment: .leading, spacing: 2) { + Text(session.name ?? session.sessionId) + .font(IslandTheme.label(13, .semibold)) + .foregroundStyle(.white) + .lineLimit(1) + // 会话名截中间:`o3-ui-memory-idempotent-fixes` 这种 + // 名字截尾就完全认不出是哪个了。 + .truncationMode(.middle) + + HStack(spacing: 5) { + Text(session.fallbackProjectName) + .font(IslandTheme.label(11)) + .foregroundStyle(.white.opacity(0.62)) + .lineLimit(1) + // 分支要在**列表里**就看得到,不能只在详情卡里。 + // 同一个项目多开时,行与行之间的唯一区别往往就是分支。 + if let branch { + Text(branch) + .font(IslandTheme.mono(10)) + .foregroundStyle(.white.opacity(0.42)) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + .padding(.leading, 10) + + Spacer(minLength: 8) + + if let waitingFor = session.waitingFor { + Text(waitingFor) + .font(IslandTheme.label(10, .semibold)) + .foregroundStyle(IslandTheme.waiting) + .lineLimit(1) + .padding(.trailing, 8) + } + + Text(RelativeTime.short(from: session.updatedAt)) + .font(IslandTheme.label(11)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.42)) + .frame(width: 30, alignment: .trailing) + + quickActions + } + .frame(height: IslandMetrics.expandedRowHeight) + } + + /// 行内快捷按钮。 + /// + /// 常驻占位(不是 hover 才出现):宽度恒定,行不会因为指针经过而重排; + /// 未悬停时压低透明度,不跟会话名抢注意力。 + private var quickActions: some View { + HStack(spacing: 4) { + rowButton("arrow.up.forward.app", help: "跳到这个会话的终端", action: onJump) + rowButton("folder", help: "在 Finder 中打开") { + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: session.cwd) + } + } + .opacity(isHovered || isSelected ? 1 : 0.34) + .padding(.leading, 8) + .padding(.trailing, 10) + } + + private func rowButton( + _ symbol: String, help: String, action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Image(systemName: symbol) + .font(.system(size: 10, weight: .medium)) + } + .buttonStyle(IslandButtonStyle(emphasis: .secondary, width: 26, height: 22)) + .help(help) + } +} + +/// 相对时间。岛和主窗口共用,避免两处各写一份导致格式漂移。 +public enum RelativeTime { + public static func short(from date: Date?, now: Date = Date()) -> String { + guard let date else { return "—" } + let seconds = Int(now.timeIntervalSince(date)) + if seconds < 60 { return "\(max(0, seconds))s" } + if seconds < 3600 { return "\(seconds / 60)m" } + if seconds < 86400 { return "\(seconds / 3600)h" } + return "\(seconds / 86400)d" + } + + /// 已运行时长,用于详情卡。 + public static func duration(since date: Date?, now: Date = Date()) -> String { + guard let date else { return "—" } + let total = max(0, Int(now.timeIntervalSince(date))) + let hours = total / 3600 + let minutes = (total % 3600) / 60 + if hours > 0 { return "\(hours)h \(minutes)m" } + if minutes > 0 { return "\(minutes)m" } + return "\(total)s" + } +} diff --git a/HubKit/Sources/HubUI/IslandShape.swift b/HubKit/Sources/HubUI/IslandShape.swift new file mode 100644 index 0000000..a27cad3 --- /dev/null +++ b/HubKit/Sources/HubUI/IslandShape.swift @@ -0,0 +1,168 @@ +import SwiftUI + +/// 岛的外形。 +/// +/// 两种形态共用一个 `Shape` 实现,靠 `notchHeight == 0` 区分 —— 这样 +/// 主屏和外接屏走的是同一套状态机和同一条动画路径,只是几何参数不同。 +/// +/// ## 主屏(有刘海):下唇形 +/// +/// 关键约束是**上窄下宽**:`y ∈ [0, notchHeight]` 这一段宽度严格等于刘海宽, +/// 一分不多 —— 多出来就会压住菜单栏两侧的图标。超过刘海高度之后才向两侧扩张, +/// 中间用一个向外凸的反向圆角过渡,做出"从刘海里挤出来"的液体感。 +/// +/// 上半段被物理刘海完全遮住,画什么都看不见,但**必须画**:Liquid Glass 的 +/// 采样和形变需要一个连续的形状,从 y=0 开始才能让岛看起来是长在刘海上而不是贴在下面。 +/// +/// ## 外接屏(无刘海):独立胶囊 +/// +/// 不做"半个下唇" —— 没有刘海托着,缺失的上半截会被读成渲染 bug。 +/// 改成距屏幕顶 `pillTopInset` 悬浮的独立圆角矩形,是个自洽的设计。 +public struct IslandShape: Shape, Sendable { + /// 刘海宽。0 表示这块屏没有刘海,走胶囊形态。 + public var notchWidth: CGFloat + /// 刘海高(= safeAreaInsets.top)。 + public var notchHeight: CGFloat + /// 岛主体宽度(下半段)。 + public var bodyWidth: CGFloat + /// 岛主体高度(刘海以下露出来的部分)。 + public var bodyHeight: CGFloat + /// 下沿圆角。 + public var bottomRadius: CGFloat + /// 无刘海时的顶部悬浮间距。 + public var pillTopInset: CGFloat + + /// 反向圆角半径。比刘海物理底角略小,视觉上才像"融进刘海"; + /// 比它大会读成两个东西叠在一起。 + private let inverseRadius: CGFloat = 6 + + public init( + notchWidth: CGFloat, + notchHeight: CGFloat, + bodyWidth: CGFloat, + bodyHeight: CGFloat, + bottomRadius: CGFloat, + pillTopInset: CGFloat = 8 + ) { + self.notchWidth = notchWidth + self.notchHeight = notchHeight + self.bodyWidth = bodyWidth + self.bodyHeight = bodyHeight + self.bottomRadius = bottomRadius + self.pillTopInset = pillTopInset + } + + /// 让 SwiftUI 能对形状本身做插值动画。 + /// + /// 这是形变连续的保底手段:即使 `.glassEffectID` 的形变没有按预期生效, + /// 形状本身也会平滑过渡,不会出现跳变。 + public var animatableData: AnimatablePair< + AnimatablePair, + AnimatablePair + > { + get { + AnimatablePair( + AnimatablePair(bodyWidth, bodyHeight), + AnimatablePair(bottomRadius, notchWidth) + ) + } + set { + bodyWidth = newValue.first.first + bodyHeight = newValue.first.second + bottomRadius = newValue.second.first + notchWidth = newValue.second.second + } + } + + public func path(in rect: CGRect) -> Path { + notchHeight > 0 && notchWidth > 0 + ? notchedPath(in: rect) + : pillPath(in: rect) + } + + // MARK: - 有刘海 + + private func notchedPath(in rect: CGRect) -> Path { + let cx = rect.midX + let halfTop = notchWidth / 2 + let halfBody = max(halfTop, bodyWidth / 2) + + let yTop = rect.minY + let yShoulder = yTop + notchHeight // 刘海下沿,开始扩张的位置 + let yBottom = yShoulder + bodyHeight + + // 主体比刘海宽出来的量。它决定反向圆角能画多大。 + let overhang = halfBody - halfTop + // 反向圆角不能超过可用的水平/垂直空间,否则路径会自交,渲染出诡异的尖刺。 + let ir = max(0, min(inverseRadius, min(overhang, bodyHeight / 2))) + // 下沿圆角同理受限于主体尺寸。 + let br = max(0, min(bottomRadius, min(halfBody, bodyHeight))) + + var path = Path() + + // 左上:从刘海左沿垂直下来(这一段严格贴着刘海宽度,不侵占菜单栏) + path.move(to: CGPoint(x: cx - halfTop, y: yTop)) + path.addLine(to: CGPoint(x: cx - halfTop, y: yShoulder - ir)) + + // 反向圆角:向左外凸,把垂直边过渡到水平的肩线 + if ir > 0 { + path.addQuadCurve( + to: CGPoint(x: cx - halfTop - ir, y: yShoulder), + control: CGPoint(x: cx - halfTop, y: yShoulder) + ) + } + + // 肩线向左延伸到主体左沿 + path.addLine(to: CGPoint(x: cx - halfBody, y: yShoulder)) + // 左侧竖边 + path.addLine(to: CGPoint(x: cx - halfBody, y: yBottom - br)) + // 左下圆角 + if br > 0 { + path.addQuadCurve( + to: CGPoint(x: cx - halfBody + br, y: yBottom), + control: CGPoint(x: cx - halfBody, y: yBottom) + ) + } + // 下沿 + path.addLine(to: CGPoint(x: cx + halfBody - br, y: yBottom)) + // 右下圆角 + if br > 0 { + path.addQuadCurve( + to: CGPoint(x: cx + halfBody, y: yBottom - br), + control: CGPoint(x: cx + halfBody, y: yBottom) + ) + } + // 右侧竖边回到肩线 + path.addLine(to: CGPoint(x: cx + halfBody, y: yShoulder)) + // 肩线向右收回到刘海右沿 + path.addLine(to: CGPoint(x: cx + halfTop + ir, y: yShoulder)) + // 右侧反向圆角 + if ir > 0 { + path.addQuadCurve( + to: CGPoint(x: cx + halfTop, y: yShoulder - ir), + control: CGPoint(x: cx + halfTop, y: yShoulder) + ) + } + // 回到起点 + path.addLine(to: CGPoint(x: cx + halfTop, y: yTop)) + path.closeSubpath() + + return path + } + + // MARK: - 无刘海 + + private func pillPath(in rect: CGRect) -> Path { + let width = max(1, bodyWidth) + let height = max(1, bodyHeight) + let box = CGRect( + x: rect.midX - width / 2, + y: rect.minY + pillTopInset, + width: width, + height: height + ) + // .continuous 是 macOS 26 的圆角语言;老式的 .circular 会显得生硬。 + let radius = min(bottomRadius, min(width, height) / 2) + return Path(roundedRect: box, cornerRadius: radius, style: .continuous) + } +} diff --git a/HubKit/Sources/HubUI/IslandState.swift b/HubKit/Sources/HubUI/IslandState.swift new file mode 100644 index 0000000..ef10c7d --- /dev/null +++ b/HubKit/Sources/HubUI/IslandState.swift @@ -0,0 +1,229 @@ +import SwiftUI + +/// 岛的形态。 +/// +/// 先落地三个基础态;`intrusion`(事件闯入)和 `approval`(审批)在后续阶段接入, +/// 它们复用同一套几何与动画机制,只是内容和触发方式不同。 +public enum IslandState: Equatable, Sendable { + /// 折叠常驻。贴着刘海只露一条"下唇"。 + case rest + /// 悬停摘要。露出像素小人排和一行统计。 + case hover + /// 完全展开。会话列表 + 操作。 + case expanded + /// 事件闯入。某会话完成或需要输入时主动膨胀提示,几秒后回落。 + case intrusion + /// 审批。高风险操作拦截,等用户决策。**不会自动回落。** + case approval +} + +/// 某个形态在某块屏幕上的具体尺寸。 +/// +/// 全部由 `NotchGeometry` 推导,没有任何写死的绝对坐标 —— 换机器、改缩放、 +/// 插拔外接屏都不需要动代码。 +public struct IslandMetrics: Equatable, Sendable { + public let bodyWidth: CGFloat + public let bodyHeight: CGFloat + public let bottomRadius: CGFloat + + /// 窗口固定尺寸。 + /// + /// 窗口**永远是这么大**,形态切换只改 SwiftUI 内部布局,绝不调 `setFrame`: + /// 窗口 frame 动画走 CoreAnimation 隐式事务,和 SwiftUI 的 spring 不同步, + /// 展开时会看到内容被裁掉一两帧。 + public static let panelSize = CGSize(width: 640, height: 720) + + /// 折叠态主体比刘海每侧宽出的量。这点溢出正是"液体挤出"的形态语言。 + private static let restBleedPerSide: CGFloat = 8 + + // MARK: - 悬停态几何 + // + // 这几个常量同时被 `IslandMetrics`(决定岛多大)和 `HoverContent`(决定画多大)读, + // **必须共用同一份**。以前两边各写各的,结果内容算出来 113.5pt 却塞进 96pt 的容器, + // 外面还套着 clipShape,直接被裁掉 17.5pt —— 这就是用户看到的"信息显示不全"。 + + /// 悬停态一个小人占的边长(16×16 art × 2pt)。 + public static let hoverSpriteSide: CGFloat = 32 + /// 小人之间的间距。 + public static let hoverSpriteSpacing: CGFloat = 8 + /// 悬停态最多摆几个小人。超出的部分在展开态的列表里看。 + public static let hoverSpriteLimit = 9 + /// "屏"的内边距(必须和 `SpriteScreen` 的 padding 一致)。 + public static let hoverScreenPadding = CGSize(width: 8, height: 4) + /// 被指到的小人抬起的高度。Canvas 顶部要预留这么多余量,否则会被裁掉。 + public static let hoverLift: CGFloat = 2 + /// 小人下方那条状态色横条的高度 + 它与小人之间的间距。 + public static let hoverStatusBarBlock: CGFloat = 7 + /// 岛边缘到"屏"的留白(左右各一份)。 + public static let hoverSideInset: CGFloat = 20 + /// 名字行(会话名 13pt + 副行 11pt + 行距)。 + public static let hoverCaptionHeight: CGFloat = 32 + /// 内容区上下留白。 + public static let hoverVerticalInset: CGFloat = 10 + /// "屏"与名字行之间的间距。 + public static let hoverGap: CGFloat = 8 + + /// 悬停态摆 n 个小人时,那一排的实际宽度。 + public static func hoverRowWidth(sessionCount: Int) -> CGFloat { + let n = max(1, min(sessionCount, hoverSpriteLimit)) + return CGFloat(n) * hoverSpriteSide + CGFloat(n - 1) * hoverSpriteSpacing + } + + /// 悬停态"屏"的整体尺寸。 + public static func hoverScreenSize(sessionCount: Int) -> CGSize { + CGSize( + width: hoverRowWidth(sessionCount: sessionCount) + hoverScreenPadding.width * 2, + height: hoverSpriteSide + hoverLift + hoverStatusBarBlock + + hoverScreenPadding.height * 2 + ) + } + + /// 悬停态内容的最小高度。岛的 `bodyHeight` 不得小于它。 + public static var hoverContentHeight: CGFloat { + hoverVerticalInset * 2 + + hoverScreenSize(sessionCount: 1).height + + hoverGap + + hoverCaptionHeight + } + + /// 悬停态宽度**跟着会话数走**。 + /// + /// 写死 380 的问题:只有 3 个会话时中间空一大块,会读成"加载失败"; + /// 而 9 个会话时又刚好卡满没有呼吸感。下限保证名字行有地方放。 + public static func hoverWidth(sessionCount: Int) -> CGFloat { + let ideal = hoverScreenSize(sessionCount: sessionCount).width + hoverSideInset * 2 + return min(max(ideal, 264), 520) + } + + public static func metrics( + for state: IslandState, + geometry: NotchGeometry, + sessionCount: Int = 0 + ) -> IslandMetrics { + let hoverW = hoverWidth(sessionCount: sessionCount) + let hoverH = hoverContentHeight + + if geometry.hasNotch { + switch state { + case .rest: + return IslandMetrics( + bodyWidth: geometry.notchWidth + restBleedPerSide * 2, + bodyHeight: 14, + bottomRadius: 14 + ) + case .hover: + return IslandMetrics(bodyWidth: hoverW, bodyHeight: hoverH, bottomRadius: 26) + case .expanded: + return IslandMetrics( + bodyWidth: 580, + bodyHeight: expandedHeight(sessionCount: sessionCount), + bottomRadius: 28 + ) + case .intrusion: + return IslandMetrics(bodyWidth: 360, bodyHeight: 62, bottomRadius: 22) + case .approval: + return IslandMetrics(bodyWidth: 470, bodyHeight: 248, bottomRadius: 26) + } + } else { + // 无刘海:折叠态是个独立胶囊,不是"半个下唇"。 + switch state { + case .rest: + return IslandMetrics(bodyWidth: 132, bodyHeight: 26, bottomRadius: 13) + case .hover: + return IslandMetrics(bodyWidth: hoverW, bodyHeight: hoverH, bottomRadius: 24) + case .expanded: + return IslandMetrics( + bodyWidth: 580, + bodyHeight: expandedHeight(sessionCount: sessionCount), + bottomRadius: 28 + ) + case .intrusion: + return IslandMetrics(bodyWidth: 360, bodyHeight: 58, bottomRadius: 22) + case .approval: + return IslandMetrics(bodyWidth: 470, bodyHeight: 244, bottomRadius: 26) + } + } + } + + // MARK: - 展开态几何 + + /// 展开态各段高度。 + public static let expandedHeaderHeight: CGFloat = 44 + public static let expandedDetailHeight: CGFloat = 146 + public static let expandedRowHeight: CGFloat = 52 + /// 项目行比会话行矮:它只有名字 + 分支两行,没有小人和时间戳。 + /// 而项目通常有二十来个,行矮一点一屏能多看几个。 + public static let expandedProjectRowHeight: CGFloat = 40 + /// 会话 / 项目 切换栏。 + public static let expandedTabHeight: CGFloat = 30 + /// 展开态最高能长到多少(列表更长时内部滚动)。 + public static let expandedMaxHeight: CGFloat = 512 + + /// 展开态高度**跟着会话数走**。 + /// + /// 固定 512 的问题:只有 3、4 个会话时下面挂着一大片空玻璃, + /// 读起来像"加载失败"或"内容被吞了"。列表长了再涨到上限并转为内部滚动。 + public static func expandedHeight(sessionCount: Int) -> CGFloat { + let list = CGFloat(max(1, sessionCount)) * expandedRowHeight + 24 + let ideal = expandedHeaderHeight + expandedTabHeight + + expandedDetailHeight + 8 + list + return min(ideal, expandedMaxHeight) + } + + /// 审批态内容的最小高度(header + 命令块 + 按钮 + 上下留白)。 + public static let approvalContentHeight: CGFloat = 48 + 84 + 44 + 24 + 20 +} + +/// 形态转场动画。 +/// +/// 参数不是随手填的: +/// - 进入 hover 几乎不回弹(`extraBounce` 极小),要跟手; +/// - 展开明显有分量感(`bouncy`),跨度越大越慢; +/// - 收回一律 `smooth`,干脆利落不留恋。 +public enum IslandAnimation { + public static func transition( + from old: IslandState, + to new: IslandState, + reduceMotion: Bool + ) -> Animation { + if reduceMotion { + return .easeInOut(duration: 0.2) + } + switch (old, new) { + case (.rest, .hover): + return .snappy(duration: 0.30, extraBounce: 0.02) + case (.hover, .rest): + return .smooth(duration: 0.24) + case (.hover, .expanded): + return .bouncy(duration: 0.42, extraBounce: 0.12) + case (.rest, .expanded): + return .bouncy(duration: 0.46, extraBounce: 0.14) + case (.expanded, _): + return .smooth(duration: 0.28) + case (_, .intrusion): + // 欠阻尼,回弹是为了吸引注意 —— 这是唯一一个主动打扰用户的形态。 + return .spring(response: 0.28, dampingFraction: 0.66) + case (.intrusion, .approval): + return .snappy(duration: 0.34, extraBounce: 0.06) + case (.intrusion, _): + return .easeOut(duration: 0.22) + case (_, .approval): + return .spring(response: 0.32, dampingFraction: 0.72) + case (.approval, _): + return .smooth(duration: 0.30) + default: + return .smooth(duration: 0.26) + } + } + + /// 内容淡入永远比形状晚 80 ms —— 形状先到位,内容再"浮现"。 + /// 这是 Dynamic Island 手感的核心,去掉它整个动效会显得廉价。 + public static func contentIn(reduceMotion: Bool) -> Animation { + reduceMotion ? .easeInOut(duration: 0.15) : .easeOut(duration: 0.16).delay(0.08) + } + + /// 退出时反过来:内容先走,形状后收。 + public static func contentOut(reduceMotion: Bool) -> Animation { + reduceMotion ? .easeInOut(duration: 0.1) : .easeIn(duration: 0.10) + } +} diff --git a/HubKit/Sources/HubUI/IslandTheme.swift b/HubKit/Sources/HubUI/IslandTheme.swift new file mode 100644 index 0000000..747a95e --- /dev/null +++ b/HubKit/Sources/HubUI/IslandTheme.swift @@ -0,0 +1,249 @@ +import SwiftUI +import HubCore + +/// 岛的视觉常量。 +public enum IslandTheme { + + // MARK: - 状态色 + + /// waiting 用琥珀而不是红:红要留给「破坏性/不可逆」(审批的 L3、进程崩溃)。 + /// waiting 表达的是"该你了",不是"出事了"。而且琥珀在暗背景上比红更跳 —— + /// 红容易被深色壁纸吞掉。 + public static let busy = Color(red: 0.31, green: 0.66, blue: 1.00) // #4FA8FF + public static let waiting = Color(red: 1.00, green: 0.69, blue: 0.13) // #FFB020 + public static let shell = Color(red: 0.20, green: 0.84, blue: 0.29) // #32D74B + public static let danger = Color(red: 1.00, green: 0.27, blue: 0.23) // #FF453A + + /// idle 用的中性灰。 + /// + /// 实测教训:系统的 `#8E8E93` 放在压过 scrim 的深色玻璃上几乎完全看不见 —— + /// 那个值是给浅色背景设计的。这里提亮到接近白,靠**透明度**而不是**灰度** + /// 来表达"不活跃",才能在暗底上既可见又克制。 + public static let idle = Color(red: 0.85, green: 0.86, blue: 0.88) + + public static func color(for status: SessionStatus) -> Color { + switch status { + case .busy: return busy + case .waiting: return waiting + case .shell: return shell + case .idle, .unknown: return idle + } + } + + // MARK: - 可读性 + + /// 岛的固定暗度。**决定岛的颜色,不参与任何背景自适应。** + /// + /// 绕不开的算术:任何"能透出背景"的材质,在白底上都必然比在黑底上亮。 + /// 实测过 Liquid Glass(`.regular` / `.clear` + 黑 tint)和 + /// NSVisualEffectView 的多种 material,白底下输出都落在 0.5~0.7 之间, + /// 差别只是程度 —— 而刘海恒为 0。**要在任何背景下都贴合刘海,只能接近不透明。** + /// + /// 最终亮度 ≈ 背景亮度 × (1 − baseOpacity) + /// 白底 (1.0) → 0.14 黑底 (0.0) → 0.0 刘海 → 0.0 + /// + /// 0.86 是"看得出背后有东西"和"看不出和刘海有色差"的交界: + /// 再低白底下就开始发灰,再高就成了一块死黑的塑料。 + public static let baseOpacity: Double = 0.86 + + /// 玻璃之上、内容之下的遮罩。 + /// + /// 这是文字可读性的**主力手段**,不是逐字加阴影。Liquid Glass 高度透明, + /// 壁纸一换文字就可能完全读不出来;scrim 随形状一起裁切,成本近乎为零, + /// 效果远好于给每个 Text 挂 shadow(那还会在半透明背景上糊出一圈脏边)。 + /// + /// 但**压太狠玻璃就没了**:初版这里是 0.42→0.16,配上纯黑的刘海遮蔽区, + /// 整个岛看起来像一块深色塑料而不是玻璃。现在压到刚够读的程度, + /// 剩下的可读性交给 `edgeHighlight` 的边缘高光和文字本身的字重。 + public static func scrim(for state: IslandState) -> LinearGradient { + // 配的是 `.clear` 玻璃(不做亮度自适应),所以这一层同时承担两件事: + // 保证文字可读,以及把岛的亮度**钉在刘海附近**。数值偏重是有意的。 + // 这一层现在**只管可读性**,不再兼职"把材质压暗" —— + // 岛的颜色由 `baseOpacity` 那层固定基底决定,和背景无关。 + // + // 展开和审批要重一些:那两个形态要**读密集文字**, + // 背后窗口透过来的那 14% 亮度会和小字打架(实测岛飘在 Finder 上时 + // 文件名的轮廓会浮在会话名下面)。 + let (top, bottom): (Double, Double) = switch state { + case .rest: (0.12, 0.06) + case .hover: (0.14, 0.06) + case .expanded: (0.28, 0.20) + case .intrusion: (0.16, 0.08) + case .approval: (0.34, 0.26) + } + return LinearGradient( + colors: [.black.opacity(top), .black.opacity(bottom)], + startPoint: .top, + endPoint: .bottom + ) + } + + /// 刘海与岛之间的过渡。 + /// + /// **消除硬边用的。** 物理刘海是纯黑不透光的实体,岛是半透明玻璃; + /// 两者直接相接时会在 `y = notchHeight` 处留下一条黑 / 亮的硬边, + /// 一眼就能看出"岛是贴上去的另一个东西",而不是"从刘海里流出来的"。 + /// + /// 解法是让纯黑往下渐隐 `blendDepth` 那么长的一段。渐隐区间必须足够长 + /// (比刘海本身还高),太短会从"一条硬边"变成"一条软边",问题性质没变。 + public static func notchBlend(notchHeight: CGFloat, panelHeight: CGFloat) -> LinearGradient { + let blendDepth: CGFloat = 44 + let solidEnd = notchHeight / panelHeight + let fadeEnd = (notchHeight + blendDepth) / panelHeight + return LinearGradient( + stops: [ + .init(color: .black, location: 0), + .init(color: .black, location: solidEnd), + .init(color: .black.opacity(0), location: fadeEnd), + .init(color: .black.opacity(0), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + } + + /// 玻璃边缘的高光。 + /// + /// 这是"看起来像玻璃"最关键的一笔:真实玻璃的边缘会因为折射而比中间亮, + /// 下沿尤其明显(光从上方进入、在底面全反射)。没有这条边,再透明的 + /// 材质也只会被读成"一块半透明色块"。 + /// + /// 顶部给 0:那一段被纯黑的刘海遮蔽区盖着,画了也看不见, + /// 反而可能在遮蔽区边缘漏出一道亮线。 + public static let edgeHighlight = LinearGradient( + stops: [ + .init(color: .white.opacity(0), location: 0), + .init(color: .white.opacity(0.10), location: 0.35), + .init(color: .white.opacity(0.30), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + + // MARK: - 字体 + + /// 正文用 SF Rounded:它和 Liquid Glass 的圆润语言同源, + /// 且圆体在半透明背景上的字重感知比 SF Pro 更实。 + public static func label(_ size: CGFloat, _ weight: Font.Weight = .regular) -> Font { + .system(size: size, weight: weight, design: .rounded) + } + + /// 路径、分支名、命令一律等宽。 + public static func mono(_ size: CGFloat, _ weight: Font.Weight = .regular) -> Font { + .system(size: size, weight: weight, design: .monospaced) + } +} + +/// 岛上按钮的样式。 +/// +/// **不能用系统的 `.glass` / `.glassProminent`。** 岛的 panel 是 +/// `.nonactivatingPanel` 且这个 app 是 `.accessory`,绝大多数时候都不是 key window, +/// 而 AppKit 会把非活跃窗口里的系统按钮**整体去饱和** —— 实测「跳转终端」和 +/// 审批的「允许/拒绝」都会显示成灰色,看上去像被禁用了。 +/// +/// 审批面板尤其不能这样:用户看到两个灰按钮的第一反应是"点不了", +/// 而那正是唯一需要他立刻决策的地方。所以外观必须完全由我们自己控制。 +public struct IslandButtonStyle: ButtonStyle { + public enum Emphasis { case prominent, secondary } + + let emphasis: Emphasis + let tint: Color + var width: CGFloat? + var height: CGFloat = 30 + + @Environment(\.isEnabled) private var isEnabled + + public init( + emphasis: Emphasis = .secondary, + tint: Color = IslandTheme.busy, + width: CGFloat? = nil, + height: CGFloat = 30 + ) { + self.emphasis = emphasis + self.tint = tint + self.width = width + self.height = height + } + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(IslandTheme.label(12, .semibold)) + .foregroundStyle(foreground) + .frame(width: width, height: height) + .background(background(pressed: configuration.isPressed)) + .contentShape(Rectangle()) + .opacity(isEnabled ? 1 : 0.4) + .animation(.easeOut(duration: 0.12), value: configuration.isPressed) + } + + private var foreground: Color { + switch emphasis { + // 实心按钮上用黑字:状态色都是高亮度的琥珀/蓝/绿,白字在上面对比度不够。 + case .prominent: return .black + case .secondary: return .white.opacity(0.92) + } + } + + private func background(pressed: Bool) -> some View { + let shape = RoundedRectangle(cornerRadius: 8, style: .continuous) + return ZStack { + switch emphasis { + case .prominent: + shape.fill(tint.opacity(pressed ? 0.78 : 1)) + case .secondary: + shape.fill(.white.opacity(pressed ? 0.20 : 0.12)) + shape.stroke(.white.opacity(0.16), lineWidth: 1) + } + } + } +} + +/// 状态点。 +/// +/// **形状是第二编码通道**,不只是颜色:6 pt 尺度下空心 vs 实心的区分度 +/// 强过任何颜色差,色觉障碍用户也能分辨。 +/// - busy:实心圆角方 +/// - waiting:实心圆 + 外环(最显眼) +/// - shell:实心硬方(圆角更小,视觉上更"硬") +/// - idle:空心 +public struct StatusDot: View { + public let status: SessionStatus + public let size: CGFloat + + public init(status: SessionStatus, size: CGFloat = 6) { + self.status = status + self.size = size + } + + public var body: some View { + let color = IslandTheme.color(for: status) + Group { + switch status { + case .waiting: + Circle() + .fill(color) + .frame(width: size * 0.7, height: size * 0.7) + .overlay( + Circle() + .stroke(color.opacity(0.85), lineWidth: 1) + .frame(width: size, height: size) + ) + case .busy: + RoundedRectangle(cornerRadius: size * 0.33, style: .continuous) + .fill(color) + .frame(width: size, height: size) + case .shell: + RoundedRectangle(cornerRadius: size * 0.1, style: .continuous) + .fill(color) + .frame(width: size, height: size) + case .idle, .unknown: + // 空心是 idle 的形状编码。描边要够亮才能在 6pt 尺度下被看见, + // 但整体透明度压低,保证它不和活跃状态抢注意力。 + RoundedRectangle(cornerRadius: size * 0.33, style: .continuous) + .stroke(color.opacity(0.6), lineWidth: 1.2) + .frame(width: size - 0.6, height: size - 0.6) + } + } + .frame(width: size, height: size) + } +} diff --git a/HubKit/Sources/HubUI/MainWindow.swift b/HubKit/Sources/HubUI/MainWindow.swift new file mode 100644 index 0000000..eed38f7 --- /dev/null +++ b/HubKit/Sources/HubUI/MainWindow.swift @@ -0,0 +1,705 @@ +import AppKit +import HubCore +import HubIPC +import HubProjects +import SwiftUI + +/// 主窗口的分区。 +public enum DashboardSection: String, CaseIterable, Identifiable, Sendable { + case sessions = "会话" + case projects = "项目" + case usage = "用量" + case approvals = "审批日志" + case settings = "设置" + + public var id: String { rawValue } + + var symbol: String { + switch self { + case .sessions: return "bolt.horizontal.circle" + case .projects: return "folder" + case .usage: return "chart.bar" + case .approvals: return "checkmark.shield" + case .settings: return "gearshape" + } + } +} + +/// 主窗口。 +/// +/// 分工:**岛 = 一瞥**(零点击获取状态);**主窗口 = 深挖**(用量趋势、 +/// 项目管理、审批日志)。两者共享同一个 `SessionStore` 和同一套原子组件 +/// (`PixelSprite` / `StatusDot` / `GitChip`),这是设计一致性的技术保障 —— +/// 维护两套组件的话它们必然会漂移。 +/// +/// 视觉上和岛刻意相反:岛强制暗色(因为要贴合恒为黑的刘海), +/// 主窗口完全跟随系统。岛是"设备的一部分",窗口是"一个 app"。 +public struct MainWindowView: View { + @Bindable var store: SessionStore + @Bindable var projects: ProjectStore + @Bindable var approvals: ApprovalCoordinator + let onJump: (String) -> Void + let onLaunch: (Project, LaunchMode) -> Void + + @State private var section: DashboardSection = .sessions + @State private var search = "" + + public init( + store: SessionStore, + projects: ProjectStore, + approvals: ApprovalCoordinator, + onJump: @escaping (String) -> Void, + onLaunch: @escaping (Project, LaunchMode) -> Void + ) { + self.store = store + self.projects = projects + self.approvals = approvals + self.onJump = onJump + self.onLaunch = onLaunch + } + + public var body: some View { + NavigationSplitView { + List(DashboardSection.allCases, selection: $section) { item in + Label(item.rawValue, systemImage: item.symbol) + .tag(item) + } + .navigationSplitViewColumnWidth(min: 190, ideal: 210, max: 260) + } detail: { + detail + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + // 单一强调色,和岛里 busy 的蓝一致,形成跨形态的一致性。 + .tint(IslandTheme.busy) + } + + @ViewBuilder + private var detail: some View { + switch section { + case .sessions: + SessionsPane(store: store, projects: projects, onJump: onJump) + case .projects: + ProjectsPane(projects: projects, store: store, onLaunch: onLaunch) + case .usage: + UsagePane() + case .approvals: + ApprovalLogPane(approvals: approvals) + case .settings: + SettingsPane(approvals: approvals, projects: projects) + } + } +} + +// MARK: - 通用零件 + +/// 页面大标题。28pt rounded 是 iOS 26 的大标题语言。 +struct PaneHeader: View { + let title: String + let subtitle: String? + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: 28, weight: .bold, design: .rounded)) + if let subtitle { + Text(subtitle) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 12) + } +} + +/// git 状态芯片。 +struct GitChip: View { + let info: GitInfo? + + var body: some View { + guard let info else { return AnyView(EmptyView()) } + return AnyView( + HStack(spacing: 6) { + if let branch = info.branch { + Label(branch, systemImage: "arrow.triangle.branch") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + if info.changeCount > 0 { + Text("+\(info.changeCount)") + .font(.system(size: 11, weight: .medium)).monospacedDigit() + .foregroundStyle(IslandTheme.waiting) + } + if info.ahead > 0 { + Text("↑\(info.ahead)") + .font(.system(size: 11)).monospacedDigit() + .foregroundStyle(IslandTheme.busy) + } + if info.behind > 0 { + Text("↓\(info.behind)") + .font(.system(size: 11)).monospacedDigit() + .foregroundStyle(IslandTheme.busy) + } + if !info.hasUpstream { + Text("无远端") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + } + } + ) + } +} + +/// 内容区卡片。**实心,不用玻璃** —— 大面积玻璃在长时间阅读区会疲劳, +/// 而且每张卡片都要采样一次背景,9 张就是 9 次。玻璃留给 sidebar 和 toolbar +/// (那是系统自己给的)。 +struct Card: View { + @ViewBuilder let content: Content + + var body: some View { + content + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + .background.secondary, + in: .rect(cornerRadius: 14, style: .continuous) + ) + } +} + +// MARK: - 会话 + +struct SessionsPane: View { + let store: SessionStore + let projects: ProjectStore + let onJump: (String) -> Void + + var body: some View { + VStack(spacing: 0) { + PaneHeader( + title: "会话", + subtitle: "\(store.sessions.count) 个运行中 · \(store.waitingCount) 个等待你" + ) + ScrollView { + LazyVStack(spacing: 8) { + ForEach(store.sessions) { session in + Card { + HStack(spacing: 12) { + SpriteScreen(cornerRadius: 8) { + PixelSprite(session: session, pixelSize: 2, gridSize: 16) + } + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + StatusDot(status: session.status, size: 7) + Text(session.name ?? session.sessionId) + .font(.system(size: 14, weight: .semibold, + design: .rounded)) + .lineLimit(1) + .truncationMode(.middle) + if session.kind == .bg { + Text("后台") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(.quaternary, in: .capsule) + } + } + Text(projectLabel(for: session)) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + if let waitingFor = session.waitingFor { + Text(waitingFor) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(IslandTheme.waiting) + } + } + + Spacer(minLength: 8) + + GitChip(info: gitInfo(for: session)) + + Button("跳转") { onJump(session.sessionId) } + .buttonStyle(.borderless) + .font(.system(size: 12, weight: .medium)) + } + } + } + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + } + } + + private func projectLabel(for session: AgentSession) -> String { + projects.projectName(forPath: session.cwd) ?? session.fallbackProjectName + } + + private func gitInfo(for session: AgentSession) -> GitInfo? { + guard let name = projects.projectName(forPath: session.cwd), + let project = projects.projects.first(where: { $0.name == name }) + else { return nil } + return projects.git(for: project) + } +} + +// MARK: - 项目 + +struct ProjectsPane: View { + let projects: ProjectStore + let store: SessionStore + let onLaunch: (Project, LaunchMode) -> Void + + @State private var search = "" + + private var filtered: [Project] { + guard !search.isEmpty else { return projects.projects } + let needle = search.lowercased() + return projects.projects.filter { + $0.name.lowercased().contains(needle) + || $0.path.lowercased().contains(needle) + || $0.aliases.contains { $0.lowercased().contains(needle) } + || ($0.description?.lowercased().contains(needle) ?? false) + } + } + + /// 这个项目下有没有正在跑的会话。 + private func runningCount(_ project: Project) -> Int { + let root = project.expandedPath + return store.sessions.count { $0.cwd == root || $0.cwd.hasPrefix(root + "/") } + } + + var body: some View { + VStack(spacing: 0) { + PaneHeader(title: "项目", subtitle: "\(projects.projects.count) 个项目") + + HStack(spacing: 10) { + TextField("搜索项目…", text: $search) + .textFieldStyle(.roundedBorder) + Button { + projects.scan() + } label: { + Label("扫描", systemImage: "arrow.clockwise") + } + .disabled(projects.isScanning) + } + .padding(.horizontal, 20) + .padding(.bottom, 12) + + if projects.projects.isEmpty { + emptyState + Spacer() + } + + ScrollView { + LazyVStack(spacing: 8) { + ForEach(filtered) { project in + Card { + HStack(spacing: 12) { + Circle() + .fill(runningCount(project) > 0 + ? IslandTheme.busy : Color.secondary.opacity(0.3)) + .frame(width: 8, height: 8) + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 8) { + Text(project.name) + .font(.system(size: 14, weight: .semibold, + design: .rounded)) + if runningCount(project) > 0 { + Text("\(runningCount(project)) 个会话") + .font(.system(size: 10)) + .foregroundStyle(IslandTheme.busy) + } + } + if let description = project.description { + Text(description) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + Text(project.path) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.head) + } + + Spacer(minLength: 8) + GitChip(info: projects.git(for: project)) + + Menu { + ForEach(LaunchMode.allCases, id: \.self) { mode in + if mode != .resumeSession { + Button(mode.label) { onLaunch(project, mode) } + } + } + } label: { + Image(systemName: "play.circle") + } + .menuStyle(.borderlessButton) + .fixedSize() + } + } + } + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + } + } + + /// 一个项目都没有时的空状态。 + /// + /// **必须说清楚在读哪个文件。** 这次的事故是 app 读的 projects.yaml 和 + /// 脚本维护的那份不是同一个,而界面上只有一个孤零零的"0 个项目", + /// 完全没有线索。空状态要给出路径、原因和下一步动作。 + private var emptyState: some View { + Card { + VStack(alignment: .leading, spacing: 10) { + Label("没有读到项目", systemImage: "exclamationmark.triangle") + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundStyle(IslandTheme.waiting) + + if let diagnostic = projects.loadDiagnostic { + Text(diagnostic) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 3) { + Text("数据文件") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + Text(projects.sourceURL?.path ?? "(找不到 projects.yaml)") + .font(.system(size: 11, design: .monospaced)) + .textSelection(.enabled) + } + + HStack(spacing: 8) { + if let url = projects.sourceURL { + Button("在 Finder 中显示") { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + } + Button("重新加载") { projects.load() } + Button("扫描 ~/Documents/code") { projects.scan() } + .disabled(projects.isScanning) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + .padding(.horizontal, 20) + } +} + +// MARK: - 用量 + +struct UsagePane: View { + /// 默认 7 天而不是全部。 + /// + /// `~/.claude/projects` 实测有近 1 GB / 341 个 jsonl,全量首扫要好几十秒。 + /// 打开页面就卡住不出数,用户读到的是"这个功能没做"。7 天视图靠 mtime + /// 过滤掉绝大多数文件,首屏两秒内出结果;想看全量再自己切。 + @State private var range: UsageRange = .week7 + @State private var summary: UsageSummary = .empty + @State private var loading = false + @State private var scanned = 0 + @State private var total = 0 + + var body: some View { + VStack(spacing: 0) { + PaneHeader( + title: "用量", + subtitle: "\(summary.totalSessions) 个会话 · \(summary.totalMessages) 条消息" + ) + + Picker("", selection: $range) { + ForEach(UsageRange.allCases, id: \.self) { Text($0.rawValue).tag($0) } + } + .pickerStyle(.segmented) + .labelsHidden() + .padding(.horizontal, 20) + .padding(.bottom, 12) + + // 全量首扫要读近 1 GB。没有进度的话用户没法区分"在算"和"坏了"。 + if loading { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text(total > 0 ? "正在统计 \(scanned)/\(total) 个会话文件…" : "正在统计…") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + .padding(.bottom, 10) + } + + HStack(spacing: 10) { + statTile("输入", summary.totalInput, IslandTheme.busy) + statTile("输出", summary.totalOutput, IslandTheme.shell) + } + .padding(.horizontal, 20) + .padding(.bottom, 12) + + ScrollView { + LazyVStack(spacing: 6) { + ForEach(summary.projects) { usage in + Card { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(usage.projectName) + .font(.system(size: 13, weight: .semibold, + design: .rounded)) + .lineLimit(1) + Spacer() + Text(format(usage.totalTokens)) + .font(.system(size: 12)).monospacedDigit() + .foregroundStyle(.secondary) + } + GeometryReader { proxy in + let maxTokens = summary.projects.first?.totalTokens ?? 1 + let ratio = max( + 0.01, Double(usage.totalTokens) / Double(max(maxTokens, 1)) + ) + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill( + LinearGradient( + colors: [ + IslandTheme.busy.opacity(0.85), + IslandTheme.busy.opacity(0.35), + ], + startPoint: .leading, endPoint: .trailing + ) + ) + .frame(width: proxy.size.width * ratio) + } + .frame(height: 6) + } + } + } + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + } + .task(id: range) { await reload() } + } + + private func statTile(_ label: String, _ value: Int, _ color: Color) -> some View { + Card { + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.system(size: 11)).foregroundStyle(.secondary) + Text(format(value)) + .font(.system(size: 22, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(color) + } + } + } + + private func format(_ value: Int) -> String { + if value >= 1_000_000 { return String(format: "%.1fM", Double(value) / 1_000_000) } + if value >= 1_000 { return String(format: "%.1fk", Double(value) / 1_000) } + return "\(value)" + } + + private func reload() async { + loading = true + scanned = 0 + total = 0 + let range = self.range + + // 解析要扫近 1 GB 的 jsonl,必须放后台;进度回调也从后台线程来, + // 所以要显式跳回 MainActor 再更新 @State。 + let result = await Task.detached(priority: .userInitiated) { + UsageStats.collect(range: range) { done, count in + Task { @MainActor in + scanned = done + total = count + } + } + }.value + + summary = result + loading = false + } +} + +// MARK: - 审批日志 + +struct ApprovalLogPane: View { + let approvals: ApprovalCoordinator + + var body: some View { + VStack(spacing: 0) { + PaneHeader( + title: "审批日志", + subtitle: "记录所有被识别为高风险的操作。看一周就知道该不该开启拦截。" + ) + + if approvals.log.isEmpty { + ContentUnavailableView( + "还没有记录", + systemImage: "checkmark.shield", + description: Text("高风险操作发生时会记在这里") + ) + } else { + ScrollView { + LazyVStack(spacing: 6) { + ForEach(approvals.log) { entry in + Card { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 8) { + Text(entry.risk == .irreversible ? "不可逆" : "危险") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.black) + .padding(.horizontal, 6).padding(.vertical, 2) + .background( + entry.risk == .irreversible + ? IslandTheme.danger : IslandTheme.waiting, + in: .capsule + ) + Text(entry.projectName) + .font(.system(size: 13, weight: .semibold)) + Text(entry.toolName) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + Spacer() + Text(verdictLabel(entry)) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(verdictColor(entry)) + Text(entry.timestamp, style: .time) + .font(.system(size: 11)).monospacedDigit() + .foregroundStyle(.tertiary) + } + Text(entry.command) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(2) + .textSelection(.enabled) + } + } + } + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + } + } + } + + private func verdictLabel(_ entry: ApprovalLogEntry) -> String { + switch entry.verdict { + case "allow": return "已允许" + case "deny": return "已拒绝" + case "timeout": return "超时拒绝" + default: return entry.intercepted ? entry.verdict : "未拦截" + } + } + + private func verdictColor(_ entry: ApprovalLogEntry) -> Color { + switch entry.verdict { + case "deny", "timeout": return IslandTheme.danger + case "allow": return IslandTheme.shell + default: return .secondary + } + } +} + +// MARK: - 设置 + +struct SettingsPane: View { + @Bindable var approvals: ApprovalCoordinator + let projects: ProjectStore + + @State private var hookStatus: String = "检查中…" + + var body: some View { + VStack(spacing: 0) { + PaneHeader(title: "设置", subtitle: nil) + + ScrollView { + VStack(spacing: 12) { + Card { + VStack(alignment: .leading, spacing: 10) { + Text("审批拦截").font(.system(size: 14, weight: .semibold)) + Picker("拦截门槛", selection: $approvals.interceptThreshold) { + Text("只拦不可逆操作(推荐)").tag(RiskLevel.irreversible) + Text("危险操作也拦").tag(RiskLevel.dangerous) + } + .pickerStyle(.radioGroup) + + Text(""" + 你全局用的是 auto 权限模式 + --dangerously-skip-permissions,\ + 图的就是不被打断。把「危险操作」一档也拦上,按日常用量每天会弹 3–8 次。\ + 建议先看一周审批日志再决定。 + """) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + Card { + VStack(alignment: .leading, spacing: 8) { + Text("Hook 连接").font(.system(size: 14, weight: .semibold)) + Text(hookStatus) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(.secondary) + Text("socket:\(HubIPCPath.display)") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.tertiary) + .textSelection(.enabled) + } + } + + Card { + VStack(alignment: .leading, spacing: 8) { + Text("项目配置").font(.system(size: 14, weight: .semibold)) + Text("\(projects.projects.count) 个项目") + .font(.system(size: 12)).foregroundStyle(.secondary) + + // 把实际读的文件路径摆出来。踩过的坑:脚本维护的是 + // 仓库里那份 projects.yaml,app 读的却是 + // Application Support 里另一份旧的,界面上完全没有线索。 + Text(projects.sourceURL?.path ?? "(找不到 projects.yaml)") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.tertiary) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.head) + + if let diagnostic = projects.loadDiagnostic { + Text(diagnostic) + .font(.system(size: 11)) + .foregroundStyle(IslandTheme.waiting) + } + + Button("重新加载 projects.yaml") { projects.load() } + } + } + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + } + .onAppear { + hookStatus = "✓ socket 正在监听,hook 事件会实时送达" + } + } +} + +/// 给设置页显示 socket 路径用。放这里避免 HubUI 直接暴露 HubIPC 的类型给外部。 +enum HubIPCPath { + static var display: String { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/claude-hub/hub.sock") + .path + } +} diff --git a/HubKit/Sources/HubUI/NotchGeometry.swift b/HubKit/Sources/HubUI/NotchGeometry.swift new file mode 100644 index 0000000..9b3e560 --- /dev/null +++ b/HubKit/Sources/HubUI/NotchGeometry.swift @@ -0,0 +1,78 @@ +import AppKit + +/// 一块屏幕的刘海几何。 +/// +/// **所有布局都必须写成这里几个属性的函数,绝不能出现字面量。** +/// 用户改显示分辨率缩放时刘海的 pt 尺寸会变;换机器更是完全不同的值。 +/// +/// 本机实测(16" M4 Max,默认缩放 1728×1117): +/// - 内建屏:notchWidth = 185,notchHeight = 32,菜单栏高 33,中心 X = 864 +/// - 外接 LG 4K(1920×1080):无刘海,safeAreaInsets.top = 0 +/// +/// 注意实测值和常见的"200×37"传闻并不一致 —— 这正是不能硬编码的理由。 +public struct NotchGeometry: Equatable, Sendable { + public let screenFrame: CGRect + public let notchWidth: CGFloat + public let notchHeight: CGFloat + /// 刘海的**真实**中心(屏幕坐标)。 + /// + /// 不能直接用 `screenFrame.midX`:本机实测左侧可用区宽 771、右侧 772, + /// 刘海中心是 863.5 而屏幕中心是 864 —— 差半个点。数值不大,但岛是**贴着** + /// 刘海画的,任何偏移都会在交界处露出一条不对称的边。既然系统把两侧的 + /// 真实宽度都给了,就没有理由去假设它对称。 + public let notchCenterX: CGFloat + + public var hasNotch: Bool { notchWidth > 0 && notchHeight > 0 } + + /// 岛的对称轴。有刘海时贴刘海中心,无刘海时用屏幕中心。 + public var centerX: CGFloat { hasNotch ? notchCenterX : screenFrame.midX } + + /// 屏幕顶边(Cocoa 坐标系,y 向上)。 + public var topY: CGFloat { screenFrame.maxY } + + public init( + screenFrame: CGRect, + notchWidth: CGFloat, + notchHeight: CGFloat, + notchCenterX: CGFloat? = nil + ) { + self.screenFrame = screenFrame + self.notchWidth = notchWidth + self.notchHeight = notchHeight + self.notchCenterX = notchCenterX ?? screenFrame.midX + } + + public init(screen: NSScreen) { + self.screenFrame = screen.frame + + // auxiliaryTopLeftArea / auxiliaryTopRightArea 是刘海**两侧**的可用菜单栏区域。 + // 两者都存在才说明有刘海;无刘海的屏幕这两个属性返回 nil。 + if let left = screen.auxiliaryTopLeftArea, + let right = screen.auxiliaryTopRightArea { + let width = max(0, screen.frame.width - left.width - right.width) + self.notchWidth = width + self.notchHeight = screen.safeAreaInsets.top + // 左侧可用区的右边界就是刘海的左沿,加半个刘海宽即中心。 + self.notchCenterX = left.maxX + width / 2 + } else { + self.notchWidth = 0 + self.notchHeight = 0 + self.notchCenterX = screen.frame.midX + } + } + + /// 当前应该承载岛的屏幕。 + /// + /// 默认取内建屏(有刘海那块)—— 心智模型是"岛长在刘海上", + /// 它不该因为鼠标移到副屏就跟着跑。找不到带刘海的屏幕就退回主屏。 + public static func preferredScreen() -> NSScreen? { + NSScreen.screens.first { $0.auxiliaryTopLeftArea != nil } ?? NSScreen.main + } + + /// 鼠标当前所在的屏幕。审批弹窗用它 —— 那种要用户立刻决策的东西 + /// 必须出现在用户正在看的地方,而不是固定在内建屏。 + public static func screenUnderCursor() -> NSScreen? { + let point = NSEvent.mouseLocation + return NSScreen.screens.first { $0.frame.contains(point) } ?? NSScreen.main + } +} diff --git a/HubKit/Sources/HubUI/NotificationCenter.swift b/HubKit/Sources/HubUI/NotificationCenter.swift new file mode 100644 index 0000000..3cc7115 --- /dev/null +++ b/HubKit/Sources/HubUI/NotificationCenter.swift @@ -0,0 +1,120 @@ +import AppKit +import Foundation +import UserNotifications + +/// 系统通知。 +/// +/// ## 为什么换掉 terminal-notifier +/// +/// 旧实现在 bash hook 里调 `terminal-notifier -activate com.googlecode.iterm2`。 +/// 两个致命问题: +/// 1. `-activate` **只能把 iTerm 整个 app 拉到前台,无法选中具体 tab** —— +/// 这正是用户抱怨"点了通知跳到 iTerm 但停在原来那个窗口"的直接原因; +/// 2. 外部进程发的通知,我们的 app 拿不到点击回调,想补救也补救不了。 +/// +/// 换成 app 内的 `UNUserNotificationCenter` 之后,点击会回到本进程, +/// 我们就能拿着 sessionId 去跑精准跳转。 +/// +/// ## 为什么 userInfo 里放 sessionId 而不是项目名 +/// +/// 项目名会重(多个 worktree 同名)、tmux 窗口名会被 automatic-rename 改掉。 +/// sessionId 是 UUID,全局唯一且在会话生命周期内不变。 +@MainActor +public final class HubNotificationCenter: NSObject, UNUserNotificationCenterDelegate { + + /// nonisolated:delegate 回调在非主线程读它。常量字符串本来就是并发安全的。 + public nonisolated static let sessionIdKey = "hub.sessionId" + + /// 点击通知时调用。参数是 sessionId。 + public var onActivate: ((String) -> Void)? + + private var authorized = false + + public override init() { + super.init() + UNUserNotificationCenter.current().delegate = self + } + + public func requestAuthorization() { + UNUserNotificationCenter.current().requestAuthorization( + options: [.alert, .sound] + ) { [weak self] granted, error in + Task { @MainActor in + self?.authorized = granted + if let error { + NSLog("[ClaudeHub] 通知授权失败:\(error.localizedDescription)") + } else if !granted { + NSLog("[ClaudeHub] 用户拒绝了通知权限,只有灵动岛会提示") + } + } + } + } + + public func post( + title: String, + body: String, + sessionId: String, + sound: Bool = true + ) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.userInfo = [Self.sessionIdKey: sessionId] + if sound { content.sound = .default } + + // 同一会话的通知用固定 identifier,新的会替换旧的。 + // 否则一个跑得快的会话能在通知中心堆几十条,用户直接全部划掉, + // 那真正重要的那条也就跟着没了。 + let request = UNNotificationRequest( + identifier: "hub.session.\(sessionId)", + content: content, + trigger: nil + ) + UNUserNotificationCenter.current().add(request) { error in + if let error { + NSLog("[ClaudeHub] 发送通知失败:\(error.localizedDescription)") + } + } + } + + // MARK: - UNUserNotificationCenterDelegate + + // 这两个必须是 nonisolated:协议要求本身不是 MainActor 隔离的, + // 而它们的参数(UNNotification 等)不是 Sendable,Swift 6 不允许跨隔离域传递。 + // 所以在这里就地取出需要的值(String 是 Sendable),再跳回主线程。 + + /// app 在前台时也要显示横幅。 + /// + /// Claude Hub 是 `.accessory` 类型,本来就不"在前台",但用户点开主窗口时 + /// 它会变成活跃 app,这时通知照样得显示 —— 用户要的是知道别的会话怎么样了。 + public nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: + @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound]) + } + + /// 点击通知 → 精准跳回对应会话的终端 tab。 + /// + /// 这就是本次重写要修的那个 bug:旧实现用 terminal-notifier, + /// 外部进程发的通知拿不到点击回调,`-activate` 又只能前置 app 不能选 tab。 + public nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let sessionId = response.notification.request.content + .userInfo[Self.sessionIdKey] as? String + + // completionHandler 不是 Sendable,不能带进 Task。系统只要求它被调用, + // 不要求等跳转做完 —— 跳转本来就是异步的(要跑 AppleScript)。 + completionHandler() + + guard let sessionId else { return } + Task { @MainActor in + self.onActivate?(sessionId) + } + } +} diff --git a/HubKit/Sources/HubUI/PixelSprite.swift b/HubKit/Sources/HubUI/PixelSprite.swift new file mode 100644 index 0000000..398fc04 --- /dev/null +++ b/HubKit/Sources/HubUI/PixelSprite.swift @@ -0,0 +1,208 @@ +import HubCore +import SwiftUI + +/// 全局像素时钟。 +/// +/// **所有小人共用一个 `TimelineView`。** 每个小人一个时间源的话,N 个小人 +/// 就是 N 个重绘源,开销线性放大,而且没法整体冻结。 +/// +/// `minimumInterval` 必须给:不给就跟着 display link 走,ProMotion 下是 120fps +/// 重绘 9 个 Canvas —— 真正的耗电来源不是绘制量,是**唤醒频率**。持续的高频 +/// 唤醒会让 CPU 无法进入深度 idle,这才是常驻 app 掉电的原因。 +struct SpriteClock: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + let isActive: Bool + let paused: Bool + @ViewBuilder let content: (Int) -> Content + + /// 主时钟 12fps。素材本身按 6fps / 4fps 设计,用 2–3 个 tick 一帧实现, + /// 这样不同状态可以有不同节奏而共享同一个时间源。 + private var interval: TimeInterval { + if ProcessInfo.processInfo.isLowPowerModeEnabled { return 1.0 / 6 } + return isActive ? 1.0 / 12 : 1.0 / 2 + } + + var body: some View { + TimelineView(.animation(minimumInterval: interval, paused: paused || reduceMotion)) { ctx in + content(Int(ctx.date.timeIntervalSinceReferenceDate / interval)) + } + } +} + +/// 一排像素小人,全部画在**同一个 Canvas** 里。 +/// +/// 选 Canvas 而不是 SpriteKit 有个硬理由:`SpriteView` 是不透明的 Metal 合成层, +/// 放进 `.glassEffect` 的容器里,玻璃无法采样它下方的内容,会在小人区域出现 +/// 一块方形的"玻璃断层"。那是调参解决不了的。 +struct SpriteRow: View { + let sessions: [AgentSession] + /// 每个 art 像素占多少 pt。**必须是整数** —— 用 1.5 pt/px 会产生半像素, + /// 边缘发灰,"像素风"立刻变成"低分辨率图片"。 + let pixelSize: CGFloat + /// art 网格边长(16 或 8)。 + let gridSize: Int + let spacing: CGFloat + let useMicroArt: Bool + + /// 这一排是否支持"指到谁谁抬起来"。为 true 时顶部预留 `liftAmount` 的余量, + /// 否则抬升会被 Canvas 的上边界裁掉。 + var supportsLift = false + + /// 被指到的那个会话。它的小人整体抬起 `liftAmount`,作为悬停反馈。 + /// + /// 抬升做在 Canvas 内部而不是给每个小人套一个 SwiftUI 视图 —— + /// 后者意味着 N 个 Canvas + N 个 TimelineView,正是这个类型要避免的。 + var liftedSessionId: String? + + private var liftAmount: CGFloat { supportsLift ? pixelSize : 0 } + + private var spriteSide: CGFloat { CGFloat(gridSize) * pixelSize } + + /// Canvas 的实际高度(含抬升余量)。 + var canvasHeight: CGFloat { spriteSide + liftAmount } + + private var totalWidth: CGFloat { + guard !sessions.isEmpty else { return 0 } + return CGFloat(sessions.count) * spriteSide + + CGFloat(sessions.count - 1) * spacing + } + + var body: some View { + SpriteClock( + isActive: sessions.contains { $0.status.isActive }, + paused: false + ) { tick in + canvas(tick: tick) + } + .frame(width: totalWidth, height: canvasHeight) + } + + private func canvas(tick: Int) -> some View { + ZStack { + // 辉光:把同一张画模糊后用 plusLighter 叠一层。 + // 连续柔和的光晕在硬边像素和柔和玻璃之间架了一座桥, + // 这是像素风能和 Liquid Glass 共处的第二个手段(第一个是那块"屏")。 + painter(tick: tick) + .blur(radius: pixelSize * 1.2) + .opacity(0.42) + .blendMode(.plusLighter) + painter(tick: tick) + } + // 用 compositingGroup 而不是 drawingGroup:后者会建离屏 buffer, + // 有可能打断下方 Liquid Glass 的背景采样链。 + .compositingGroup() + } + + private func painter(tick: Int) -> some View { + Canvas(opaque: false, rendersAsynchronously: false) { context, _ in + for (index, session) in sessions.enumerated() { + let originX = CGFloat(index) * (spriteSide + spacing) + // 未抬起的小人落在预留余量之下;被指到的那个升到顶。 + let originY = session.sessionId == liftedSessionId ? 0 : liftAmount + // 用**稳定的**哈希:Swift 的 `hashValue` 每次进程启动都加盐, + // 那样同一个会话每次重开 app 都会换发色和衣服,"记住橙头发那个" + // 这件事就不成立了。形象必须是会话的属性,不是本次运行的属性。 + let seed = SpriteSeed.stable(session.sessionId) + let character = SpriteCharacter(seed: seed, status: session.status) + let frame = useMicroArt + ? SpriteLibrary.micro(for: session.status, tick: tick, seed: seed) + : SpriteLibrary.frame(for: session.status, tick: tick, seed: seed) + + for run in frame.runs { + let rect = CGRect( + // 取整到整数 pt:任何半像素都会让边缘出现灰边。 + x: (originX + CGFloat(run.x) * pixelSize).rounded(), + y: (originY + CGFloat(run.y) * pixelSize).rounded(), + width: CGFloat(run.width) * pixelSize, + height: pixelSize + ) + context.fill( + Path(rect), + with: .color(run.ink.color( + character: character, status: session.status + )) + ) + } + } + } + .frame(width: totalWidth, height: canvasHeight) + } +} + +/// 单个小人。 +struct PixelSprite: View { + let session: AgentSession + let pixelSize: CGFloat + let gridSize: Int + let useMicroArt: Bool + + init( + session: AgentSession, + pixelSize: CGFloat = 2, + gridSize: Int = 16, + useMicroArt: Bool = false + ) { + self.session = session + self.pixelSize = pixelSize + self.gridSize = gridSize + self.useMicroArt = useMicroArt + } + + var body: some View { + SpriteRow( + sessions: [session], + pixelSize: pixelSize, + gridSize: gridSize, + spacing: 0, + useMicroArt: useMicroArt + ) + } +} + +/// 罩住小人的"显示屏"。 +/// +/// 这是调和像素风与 Liquid Glass 的**核心手段**,比辉光、调色板那些加起来都重要。 +/// +/// 矛盾是真实的:Liquid Glass 是连续、折射、无边界的;像素风是离散、硬边、有网格的。 +/// 直接把 8-bit 小人贴在毛玻璃上,观感像"在 Vision Pro 上贴了张 FC 卡带贴纸"。 +/// +/// 解法不是让两者妥协,而是给它们一个**自洽的物质叙事**:玻璃是外壳/取景框, +/// 像素是被这块玻璃罩住的发光点阵屏。真实世界里这是成立的(老式设备的玻璃面板 +/// 下面就是点阵屏),所以大脑接受。 +struct SpriteScreen: View { + var cornerRadius: CGFloat = 8 + @ViewBuilder let content: Content + + var body: some View { + content + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background { + // 比岛本身**亮**一点。 + // + // 以前这里是近黑(0.03,0.04,0.06),而小人的描边也是近黑 —— + // 描边完全溶进底板,人物之间、五官之间全糊在一起。 + // 现在角色是彩色的、描边是深色的,底板必须落在两者中间, + // 这样描边才有得可读。视觉上它仍然是一块凹进去的点阵屏。 + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(Color(red: 0.13, green: 0.14, blue: 0.18).opacity(0.92)) + .overlay { + // 内阴影:上暗下亮,模拟凹陷。 + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke( + LinearGradient( + colors: [ + .black.opacity(0.55), + .white.opacity(0.07), + ], + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 1 + ) + .blur(radius: 0.6) + } + } + } +} diff --git a/HubKit/Sources/HubUI/SessionDetailCard.swift b/HubKit/Sources/HubUI/SessionDetailCard.swift new file mode 100644 index 0000000..60491bb --- /dev/null +++ b/HubKit/Sources/HubUI/SessionDetailCard.swift @@ -0,0 +1,344 @@ +import AppKit +import HubCore +import HubJump +import HubProjects +import SwiftUI + +/// 用哪个编辑器打开会话目录。 +/// +/// 按用户实际会用的顺序探测,**只在 app 真的装了的时候才显示按钮** —— +/// 点了没反应比没有这个按钮更糟。 +enum EditorLauncher { + struct Editor { + let name: String + let bundleId: String + + func open(_ path: String) { + guard let app = NSWorkspace.shared + .urlForApplication(withBundleIdentifier: bundleId) else { return } + NSWorkspace.shared.open( + [URL(fileURLWithPath: path)], + withApplicationAt: app, + configuration: NSWorkspace.OpenConfiguration() + ) + } + } + + private static let candidates: [Editor] = [ + Editor(name: "Cursor", bundleId: "com.todesktop.230313mzl4w4u92"), + Editor(name: "VS Code", bundleId: "com.microsoft.VSCode"), + Editor(name: "Zed", bundleId: "dev.zed.Zed"), + Editor(name: "Xcode", bundleId: "com.apple.dt.Xcode"), + ] + + /// 首个已安装的编辑器。查一次就缓存 —— `urlForApplication` 会打 LaunchServices。 + static let preferred: Editor? = candidates.first { + NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0.bundleId) != nil + } +} + +/// 展开态顶部的会话详情卡。 +/// +/// 这是「点击小人显示具体信息以及运作状态」的落点:把一个会话**当下**的全部 +/// 可知事实摊开 —— 它是谁、在哪个目录、哪条分支、最近提交了什么、干了多久、 +/// 人在哪个终端能找到它。 +/// +/// 目录和分支不是装饰:**同一个项目经常多开**(几个会话跑在不同 worktree 或 +/// 不同分支上),只显示项目名的话它们看起来一模一样,根本分不清点哪个。 +/// +/// 数据全部来自已经采集好的缓存,卡片本身不发起任何子进程调用, +/// 所以切换选中不会有任何卡顿。 +struct SessionDetailCard: View { + let session: AgentSession + var workspace: WorkspaceGit? + var terminalLabel: String? + var jumpOutcome: JumpOutcome? + let onJump: () -> Void + + private var accent: Color { IslandTheme.color(for: session.status) } + + var body: some View { + HStack(alignment: .top, spacing: 12) { + portrait + + VStack(alignment: .leading, spacing: 5) { + titleRow + locationRow + factsRow + Spacer(minLength: 2) + commitLog + } + + Spacer(minLength: 4) + + actions + } + .padding(12) + .frame(height: IslandMetrics.expandedDetailHeight) + .background { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(.white.opacity(0.06)) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(accent.opacity(session.status == .idle ? 0.18 : 0.34), lineWidth: 1) + } + } + } + + // MARK: 大号小人 + + /// 详情卡用 3pt/px(48pt)而不是把 32pt 的放大 —— `pixelSize` 必须是整数, + /// 非整数会产生半像素,边缘发灰,像素风立刻变成"低分辨率图片"。 + private var portrait: some View { + VStack(spacing: 6) { + SpriteScreen(cornerRadius: 10) { + PixelSprite(session: session, pixelSize: 3, gridSize: 16) + } + Text(session.kind == .bg ? "后台" : "交互") + .font(IslandTheme.label(9, .medium)) + .foregroundStyle(.white.opacity(0.4)) + } + } + + // MARK: 标题 + + private var titleRow: some View { + HStack(spacing: 8) { + Text(session.name ?? session.sessionId) + .font(IslandTheme.label(14, .semibold)) + .foregroundStyle(.white) + .lineLimit(1) + .truncationMode(.middle) + + statusPill + } + } + + private var statusPill: some View { + HStack(spacing: 4) { + StatusDot(status: session.status, size: 6) + Text(statusText) + .font(IslandTheme.label(10, .semibold)) + .foregroundStyle(accent) + } + .padding(.horizontal, 6) + .frame(height: 18) + .background(accent.opacity(0.14), in: .capsule) + } + + private var statusText: String { + switch session.status { + case .busy: return "工作中" + case .waiting: return session.waitingFor ?? "等你输入" + case .shell: return "跑命令" + case .idle: return "空闲" + case .unknown: return session.rawStatus + } + } + + // MARK: 位置:目录 + 分支 + + /// 当前目录和分支放同一行,因为它们回答的是同一个问题: + /// **这个会话到底在哪个工作区上干活。** 一个项目多开时这是唯一的区分手段。 + private var locationRow: some View { + HStack(spacing: 8) { + HStack(spacing: 3) { + Image(systemName: "folder") + .font(.system(size: 8)) + Text(displayPath) + .font(IslandTheme.mono(10)) + .lineLimit(1) + // 路径截中间:截尾的话最有信息量的那一段(项目名)正好没了。 + .truncationMode(.middle) + } + .foregroundStyle(.white.opacity(0.58)) + // 路径比分支胶囊更该拿到宽度:分支名通常短且完整, + // 路径被截中间的话"到底是哪个 worktree"就看不出来了。 + .layoutPriority(1) + .help(session.cwd) + + if let branch = workspace?.info.branch { + HStack(spacing: 3) { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 8, weight: .semibold)) + Text(branch) + .font(IslandTheme.mono(10, .medium)) + if let changes = workspace?.info.changeCount, changes > 0 { + Text("\(changes)△") + .font(IslandTheme.mono(10, .semibold)) + .foregroundStyle(IslandTheme.waiting) + } + if let ahead = workspace?.info.ahead, ahead > 0 { + Text("↑\(ahead)") + .font(IslandTheme.mono(10, .semibold)) + .foregroundStyle(IslandTheme.busy) + } + } + .foregroundStyle(.white.opacity(0.7)) + .padding(.horizontal, 6) + .frame(height: 17) + .background(.white.opacity(0.08), in: .capsule) + .fixedSize() + } + } + } + + /// cwd 相对仓库根的位置。 + /// + /// 会话开在子目录时,`~/code/foo/packages/api` 里真正有区分度的是尾部; + /// 直接显示全路径会被中间那一大段吃掉宽度。仓库根之外的部分单独标出来。 + private var displayPath: String { + let cwd = (session.cwd as NSString).abbreviatingWithTildeInPath + guard let root = workspace?.root, session.cwd != root else { return cwd } + let rootName = (root as NSString).lastPathComponent + let suffix = session.cwd.hasPrefix(root) + ? String(session.cwd.dropFirst(root.count)) + : "" + return suffix.isEmpty ? cwd : rootName + suffix + } + + // MARK: 事实行 + + private var factsRow: some View { + HStack(spacing: 12) { + fact("运行", RelativeTime.duration(since: session.startedAt)) + fact("最后活动", RelativeTime.short(from: session.updatedAt) + "前") + fact("PID", "\(session.pid)") + if let terminalLabel { + VStack(alignment: .leading, spacing: 1) { + Text("终端") + .font(IslandTheme.label(9)) + .foregroundStyle(.white.opacity(0.38)) + Text(terminalLabel) + .font(IslandTheme.label(11, .medium)) + .foregroundStyle(.white.opacity(0.82)) + .lineLimit(1) + .truncationMode(.middle) + } + .help(terminalLabel) + } + } + } + + private func fact(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(label) + .font(IslandTheme.label(9)) + .foregroundStyle(.white.opacity(0.38)) + Text(value) + .font(IslandTheme.label(11, .medium)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.82)) + } + .fixedSize() + } + + // MARK: 最近提交 + + /// 最近三条提交。 + /// + /// 这是判断"这个会话到底干出了什么"最直接的证据 —— 状态只说它在忙, + /// 提交历史说它忙出了什么。同项目多开时也是区分谁在推进哪条线的依据。 + @ViewBuilder + private var commitLog: some View { + if let commits = workspace?.recent, !commits.isEmpty { + VStack(alignment: .leading, spacing: 2) { + ForEach(commits.prefix(2)) { commit in + HStack(spacing: 6) { + Text(commit.hash) + .font(IslandTheme.mono(9, .semibold)) + .foregroundStyle(IslandTheme.busy.opacity(0.85)) + Text(commit.subject) + .font(IslandTheme.label(10)) + .foregroundStyle(.white.opacity(0.6)) + .lineLimit(1) + Text(commit.relativeDate) + .font(IslandTheme.label(9)) + .foregroundStyle(.white.opacity(0.3)) + .fixedSize() + } + } + } + } + } + + // MARK: 操作 + + private var actions: some View { + VStack(alignment: .trailing, spacing: 6) { + Button(action: onJump) { + Label("跳转终端", systemImage: "arrow.up.forward.app") + } + .buttonStyle(IslandButtonStyle( + emphasis: .prominent, tint: accent, width: 96, height: 28 + )) + + HStack(spacing: 5) { + iconButton("folder", help: "在 Finder 中显示") { + NSWorkspace.shared.selectFile( + nil, inFileViewerRootedAtPath: session.cwd + ) + } + if let editor = EditorLauncher.preferred { + iconButton("chevron.left.forwardslash.chevron.right", + help: "在 \(editor.name) 中打开") { + editor.open(session.cwd) + } + } + // 复制的是**能直接跑的一整条命令**,不是裸的 sessionId。 + // + // 原来这里是"复制 sessionId",实际没有用处:sessionId 是 UUID, + // 唯一的消费者就是 `claude --resume`,而没人会把 UUID 抄进命令行。 + // 直接给出可粘贴执行的命令,才真的省掉一步。 + iconButton("terminal", help: "复制恢复这个会话的命令") { + copy("cd \(session.cwd) && claude --resume \(session.sessionId)") + } + } + + jumpFeedback + } + } + + /// 跳转结果回显。 + /// + /// 跳转是异步的(AppleScript 跨进程),不回显的话用户点完只能自己扭头 + /// 去看终端有没有动。尤其是 `bg` 会话根本不在任何终端里,必须说清楚。 + @ViewBuilder + private var jumpFeedback: some View { + if let jumpOutcome { + switch jumpOutcome { + case .focused: + feedback("已切到该会话", IslandTheme.shell, "checkmark.circle.fill") + case .terminalActivatedOnly: + feedback("只前置了终端", IslandTheme.waiting, "exclamationmark.circle.fill") + case .failed: + feedback("没能定位", IslandTheme.danger, "xmark.circle.fill") + } + } + } + + private func feedback(_ text: String, _ color: Color, _ symbol: String) -> some View { + HStack(spacing: 3) { + Image(systemName: symbol).font(.system(size: 8)) + Text(text).font(IslandTheme.label(9, .medium)) + } + .foregroundStyle(color.opacity(0.9)) + } + + private func copy(_ value: String) { + let board = NSPasteboard.general + board.clearContents() + board.setString(value, forType: .string) + } + + private func iconButton( + _ symbol: String, help: String, action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Image(systemName: symbol) + .font(.system(size: 10, weight: .medium)) + } + .buttonStyle(IslandButtonStyle(emphasis: .secondary, width: 28, height: 22)) + .help(help) + } +} diff --git a/HubKit/Sources/HubUI/SessionStore.swift b/HubKit/Sources/HubUI/SessionStore.swift new file mode 100644 index 0000000..a72fe42 --- /dev/null +++ b/HubKit/Sources/HubUI/SessionStore.swift @@ -0,0 +1,263 @@ +import Foundation +import HubCore +import HubJump +import HubProbe +import HubProjects + +/// 全部 agent 会话的实时快照。岛和主窗口共享同一个实例。 +/// +/// 数据来自 `~/.claude/sessions/*.json` —— Claude Code 自己维护的权威状态源。 +/// 这里只做读取、排序和跳转派发,不做任何状态推断。 +@Observable +@MainActor +public final class SessionStore { + + public private(set) var sessions: [AgentSession] = [] + public private(set) var lastRefresh: Date = .distantPast + + /// sessionId → 人能看懂的终端位置("iTerm · storefront-a"、"tmux · @12")。 + /// + /// 详情卡要回答"这个会话在哪儿能找到",但解析它要跑 AppleScript + tmux 子进程, + /// 每次渲染都算是不可接受的。所以在后台按需解析、缓存下来,视图只读缓存。 + public private(set) var terminalLabels: [String: String] = [:] + + /// cwd → 该工作区的 git 快照(分支、变更数、最近提交)。 + /// + /// 按 **cwd** 而不是按 projects.yaml 里的项目采集:会话可能开在任意目录、 + /// 任意 worktree,甚至根本没登记过。同一个项目多开时,分支和仓库根目录 + /// 是区分它们的主要依据 —— 只显示项目名会把几个会话糊成一个。 + public private(set) var workspaceGit: [String: WorkspaceGit] = [:] + + /// 最近一次跳转的结果。跳转是异步的,成功与否必须回显, + /// 否则用户点完只能靠"终端有没有跳过去"自己猜。 + public private(set) var lastJump: (sessionId: String, outcome: JumpOutcome)? + + private let reader: ClaudeSessionReader + private let jumpEngine: JumpEngine + private var timer: Timer? + private var resolvingTerminals = false + private var lastTerminalResolve: Date = .distantPast + private var resolvingGit = false + private var lastGitResolve: Date = .distantPast + + public init( + reader: ClaudeSessionReader = ClaudeSessionReader(), + jumpEngine: JumpEngine = JumpEngine() + ) { + self.reader = reader + self.jumpEngine = jumpEngine + } + + // MARK: - 派生数据 + + public var waitingCount: Int { sessions.count { $0.status == .waiting } } + public var busyCount: Int { sessions.count { $0.status == .busy } } + public var shellCount: Int { sessions.count { $0.status == .shell } } + public var idleCount: Int { sessions.count { $0.status == .idle } } + + /// 有没有活跃会话。决定像素小人的时钟频率和轮询节奏。 + public var hasActiveSessions: Bool { sessions.contains { $0.status.isActive } } + + /// 最紧急的那个会话。折叠态的徽章和闯入态都用它。 + public var mostUrgent: AgentSession? { sessions.first { $0.status == .waiting } } + + // MARK: - 刷新 + + /// 轮询间隔自适应。 + /// + /// `sessions/*.json` 的写入频率跟着 Claude 的活动走,全空闲时几分钟才动一次, + /// 这时候还每秒扫一遍纯属浪费 —— 常驻 app 的耗电就是这么攒出来的。 + /// + /// 后续会换成 `DispatchSource` 监听目录做到近实时;轮询是先跑起来的过渡实现。 + private var refreshInterval: TimeInterval { + if waitingCount > 0 { return 1.0 } + if hasActiveSessions { return 2.0 } + return 5.0 + } + + public func start() { + refresh() + scheduleNext() + } + + public func stop() { + timer?.invalidate() + timer = nil + } + + private func scheduleNext() { + timer?.invalidate() + timer = Timer.scheduledTimer( + withTimeInterval: refreshInterval, repeats: false + ) { [weak self] _ in + Task { @MainActor in + self?.refresh() + self?.scheduleNext() + } + } + } + + public func refresh() { + let fresh = reader.readAll() + let sorted = Self.sort(fresh) + // 只在真的变了时才赋值:@Observable 的写入会触发 SwiftUI 重绘, + // 每秒无脑重绘 9 行列表是没必要的开销。 + if sorted != sessions { + sessions = sorted + } + lastRefresh = Date() + } + + /// 排序即 UI 优先级。 + /// + /// 不按项目分组作默认:9 个会话跨 5 个项目,按项目排会让 + /// 「哪个在等我」这个最高频的问题需要扫描整个列表。 + static func sort(_ input: [AgentSession]) -> [AgentSession] { + input.sorted { lhs, rhs in + // ① 状态优先级:waiting → busy → shell → idle → unknown + if lhs.status != rhs.status { return lhs.status < rhs.status } + + // ② waiting 组内:等得最久的排最上(催得最急的先处理) + if lhs.status == .waiting { + let l = lhs.statusUpdatedAt ?? .distantPast + let r = rhs.statusUpdatedAt ?? .distantPast + if l != r { return l < r } + } + + // ③ 其余:最近活跃的在上 + let l = lhs.updatedAt ?? .distantPast + let r = rhs.updatedAt ?? .distantPast + if l != r { return l > r } + + // ④ interactive 优先于 bg + if lhs.kind != rhs.kind { return lhs.kind == .interactive } + + // ⑤ 兜底用 sessionId 保证顺序稳定,避免列表无谓抖动 + return lhs.sessionId < rhs.sessionId + } + } + + // MARK: - 终端定位 + + /// 解析每个会话在哪个终端里,结果缓存到 `terminalLabels`。 + /// + /// 展开岛的时候调一次即可。有 12 秒的节流:解析要跑 AppleScript 遍历 iTerm + /// 全部 session 再加一次 `tmux list-panes`,几十到几百毫秒,不该跟着渲染走。 + public func resolveTerminalLabels(force: Bool = false) { + guard !resolvingTerminals else { return } + if !force, Date().timeIntervalSince(lastTerminalResolve) < 12 { return } + resolvingTerminals = true + + let snapshot = sessions + Task.detached(priority: .utility) { [weak self] in + let labels = Self.computeTerminalLabels(for: snapshot) + await MainActor.run { + guard let self else { return } + self.terminalLabels = labels + self.lastTerminalResolve = Date() + self.resolvingTerminals = false + } + } + } + + /// 纯计算部分,跑在后台线程。 + /// + /// 两条线索:iTerm 的 `jobPid` 祖先链(能定位到具体 tab), + /// 以及 tmux 的 `pane_pid` 祖先链(能给出窗口名)。两者都拿不到的 + /// `bg` 会话本来就不在任何终端里 —— 如实说,别编一个位置出来。 + nonisolated static func computeTerminalLabels( + for sessions: [AgentSession] + ) -> [String: String] { + guard !sessions.isEmpty else { return [:] } + + let tree = ProcessTree.snapshot() + var labels: [String: String] = [:] + + if ITermLocator.isRunning() { + let terms = ITermLocator().snapshot() + let bound = JumpEngine.bind( + itermSessions: terms, claudeSessions: sessions, tree: tree + ) + let nameByUUID = Dictionary( + terms.map { ($0.sessionUUID, $0.name) }, uniquingKeysWith: { a, _ in a } + ) + for (sessionId, uuid) in bound { + let name = nameByUUID[uuid] ?? "" + labels[sessionId] = name.isEmpty ? "iTerm" : "iTerm · \(name)" + } + } + + let pids = Set(sessions.map(\.pid)) + let panes = TmuxProbe().bindPanes(to: pids, tree: tree) + if !panes.isEmpty { + let sessionIdByPid = Dictionary( + sessions.map { ($0.pid, $0.sessionId) }, uniquingKeysWith: { a, _ in a } + ) + for (pid, pane) in panes { + guard let sessionId = sessionIdByPid[pid] else { continue } + let tmuxLabel = "tmux · \(pane.sessionName):\(pane.windowName)" + // iTerm 的定位更精确(能点到具体 tab),tmux 信息作为补充拼在后面。 + labels[sessionId] = labels[sessionId].map { "\($0) · \(tmuxLabel)" } + ?? tmuxLabel + } + } + + for session in sessions where labels[session.sessionId] == nil { + labels[session.sessionId] = session.kind == .bg ? "后台任务,不在终端里" : "未定位到终端" + } + return labels + } + + // MARK: - 工作区 git + + /// 解析这些 cwd 的 git 快照。展开岛时调,内部按 cwd 去重 + 10 秒节流。 + public func resolveWorkspaceGit(for paths: [String], force: Bool = false) { + let wanted = Set(paths) + let missing = force ? wanted : wanted.filter { workspaceGit[$0] == nil } + let stale = force || Date().timeIntervalSince(lastGitResolve) > 10 + guard !missing.isEmpty || stale else { return } + guard !resolvingGit else { return } + + let targets = Array(stale ? wanted : missing) + guard !targets.isEmpty else { return } + resolvingGit = true + + Task.detached(priority: .utility) { [weak self] in + var collected: [String: WorkspaceGit] = [:] + for path in targets { + guard let root = GitStatus.repositoryRoot(at: path), + let info = GitStatus.collect(at: path) + else { continue } + collected[path] = WorkspaceGit( + root: root, info: info, recent: GitStatus.log(at: path, limit: 3) + ) + } + await MainActor.run { [collected] in + guard let self else { return } + self.workspaceGit.merge(collected) { _, new in new } + self.lastGitResolve = Date() + self.resolvingGit = false + } + } + } + + // MARK: - 动作 + + /// 跳到会话所在的终端 tab。 + /// + /// AppleScript 有跨进程成本,放后台线程跑,别卡住 UI。结果写回 + /// `lastJump` 供 UI 回显 —— 只写日志的话用户看不到任何反馈。 + public func jump(to sessionId: String, then: (@MainActor (JumpOutcome) -> Void)? = nil) { + let engine = jumpEngine + Task.detached(priority: .userInitiated) { [weak self] in + let outcome = engine.jump(to: sessionId) + if case .failed(let reason) = outcome { + NSLog("[ClaudeHub] 跳转失败: \(reason)") + } + await MainActor.run { + self?.lastJump = (sessionId, outcome) + then?(outcome) + } + } + } +} diff --git a/HubKit/Sources/HubUI/SpriteLibrary.swift b/HubKit/Sources/HubUI/SpriteLibrary.swift new file mode 100644 index 0000000..232a220 --- /dev/null +++ b/HubKit/Sources/HubUI/SpriteLibrary.swift @@ -0,0 +1,405 @@ +import HubCore +import SwiftUI + +/// 一帧像素画,已按水平游程合并成矩形列表。 +/// +/// 合并很关键:16×16 的图有 256 个格子,但按行合并连续同色像素之后只剩 40–60 个矩形。 +/// 9 个小人 × 50 矩形 × 12fps ≈ 5400 矩形/秒,对 Canvas 是微不足道的量。 +struct SpriteFrame { + struct Run { + let x: Int + let y: Int + let width: Int + let ink: SpriteInk + } + + let runs: [Run] + + /// 从字符网格构造。字符含义见 `SpriteInk`。 + init(_ rows: [String]) { + var out: [Run] = [] + for (y, row) in rows.enumerated() { + let chars = Array(row) + var x = 0 + while x < chars.count { + guard let ink = SpriteInk(character: chars[x]) else { + x += 1 + continue + } + var width = 1 + while x + width < chars.count, chars[x + width] == chars[x] { width += 1 } + out.append(Run(x: x, y: y, width: width, ink: ink)) + x += width + } + } + runs = out + } +} + +/// 画笔。 +/// +/// ## 为什么从"状态染色的单色剪影"改成"有肤色和衣服的角色" +/// +/// 旧版每个小人整体用状态色染,四个 busy 会话画出来一模一样 —— 只是四个蓝色块。 +/// 更糟的是轮廓用的是近黑(`0.04,0.05,0.06`),而岛本身也是近黑, +/// 轮廓完全溶进背景,剩下的就真的只是一坨颜色。 +/// +/// 现在:**形象归角色,状态归屏幕**。 +/// - 发色 / 肤色 / 衣服由 `sessionId` 决定且**永远不变** —— +/// 你会记住"橙头发那个是 acme-admin",这是单色剪影给不了的辨识度; +/// - 状态由笔记本屏幕的光色表达,配合下方的状态色条和状态点,一样一眼可读。 +enum SpriteInk { + case outline // '#' + case hair // 'h' + case hairShade // 'H' + case skin // 'f' + case skinShade // 'F' + case eye // 'e' + case shirt // 's' + case shirtShade // 'S' + case laptop // 'L' + case laptopEdge // 'l' + case glow // 'g' + + init?(character: Character) { + switch character { + case "#": self = .outline + case "h": self = .hair + case "H": self = .hairShade + case "f": self = .skin + case "F": self = .skinShade + case "e": self = .eye + case "s": self = .shirt + case "S": self = .shirtShade + case "L": self = .laptop + case "l": self = .laptopEdge + case "g": self = .glow + default: return nil // '.' 和空格都是透明 + } + } + + func color(character: SpriteCharacter, status: SessionStatus) -> Color { + // idle 的人要"退到后面去",但**不能变成灰色幽灵** —— 那是旧版最难看的地方。 + // 保留原本的色相,只压饱和与亮度,读起来像"灯关了"而不是"这个人不见了"。 + let dim = character.dim + switch self { + case .outline: return Color(red: 0.05, green: 0.06, blue: 0.08).opacity(0.95) + case .hair: return character.hair.opacity(dim) + case .hairShade: return character.hair.opacity(dim * 0.62) + case .skin: return character.skin.opacity(dim) + case .skinShade: return character.skin.opacity(dim * 0.72) + case .eye: return Color(red: 0.09, green: 0.10, blue: 0.13).opacity(dim) + case .shirt: return character.shirt.opacity(dim) + case .shirtShade: return character.shirt.opacity(dim * 0.66) + // 笔记本压暗一档:它是配角。太亮的话整张图会被一块灰白色块主导, + // 而人脸才是要被认出来的那部分。 + case .laptop: return Color(white: 0.66).opacity(dim) + case .laptopEdge: return Color(white: 0.40).opacity(dim) + // 屏幕的光是状态的载体。idle 时它就是暗的 —— 屏幕熄了,正是"没在干活"。 + case .glow: + return status == .idle || status == .unknown + ? Color(white: 0.34).opacity(dim) + : IslandTheme.color(for: status) + } + } +} + +/// 跨进程稳定的哈希。 +/// +/// **不能用 Swift 的 `String.hashValue`** —— 它每次进程启动都会重新加盐, +/// 同一个 sessionId 在两次运行里得到的值不同。用它做形象派生的话, +/// 每次重开 app 所有人的发色衣服都会换一遍,"记住橙头发那个是 acme-admin" +/// 这件事根本立不住。 +/// +/// FNV-1a:够散、够快、结果只取决于输入。 +enum SpriteSeed { + static func stable(_ value: String) -> Int { + var hash: UInt64 = 0xcbf2_9ce4_8422_2325 + for byte in value.utf8 { + hash ^= UInt64(byte) + hash = hash &* 0x1000_0000_01b3 + } + // 收到 Int 的正数区间,调用方普遍在做 `abs(seed) % n`。 + return Int(hash % UInt64(Int.max)) + } +} + +/// 一个会话的固定形象。 +/// +/// 全部由 `sessionId` 派生,所以**同一个会话永远长同一个样**, +/// 换机器、重启 app、`--resume` 都不变(sessionId 是 UUID,不是 PID)。 +struct SpriteCharacter { + let hair: Color + let skin: Color + let shirt: Color + let style: Int // 发型 + let dim: Double + + /// 发色。有意做得饱和度高、彼此拉开 —— 这是区分会话的主要通道。 + private static let hairPalette: [Color] = [ + Color(red: 0.96, green: 0.62, blue: 0.20), // 橙 + Color(red: 0.36, green: 0.24, blue: 0.18), // 深棕 + Color(red: 0.95, green: 0.83, blue: 0.42), // 金 + Color(red: 0.72, green: 0.25, blue: 0.28), // 酒红 + Color(red: 0.20, green: 0.22, blue: 0.28), // 黑 + Color(red: 0.55, green: 0.42, blue: 0.72), // 紫 + ] + + private static let skinPalette: [Color] = [ + Color(red: 0.98, green: 0.84, blue: 0.72), + Color(red: 0.91, green: 0.73, blue: 0.58), + Color(red: 0.76, green: 0.56, blue: 0.42), + Color(red: 0.55, green: 0.38, blue: 0.28), + ] + + private static let shirtPalette: [Color] = [ + Color(red: 0.29, green: 0.55, blue: 0.86), // 蓝 + Color(red: 0.30, green: 0.68, blue: 0.52), // 绿 + Color(red: 0.86, green: 0.42, blue: 0.44), // 珊瑚 + Color(red: 0.42, green: 0.44, blue: 0.52), // 灰蓝 + Color(red: 0.88, green: 0.66, blue: 0.30), // 芥黄 + Color(red: 0.52, green: 0.40, blue: 0.66), // 紫 + ] + + static let styleCount = 4 + + init(seed: Int, status: SessionStatus) { + // 三个属性各用 seed 的不同段,避免"发色一样的人衣服也一样"。 + // 用 magnitude 而不是 abs:`abs(Int.min)` 会 trap。 + let base = Int(seed.magnitude % UInt(Int.max)) + hair = Self.hairPalette[base % Self.hairPalette.count] + skin = Self.skinPalette[(base / 7) % Self.skinPalette.count] + shirt = Self.shirtPalette[(base / 13) % Self.shirtPalette.count] + style = (base / 31) % Self.styleCount + dim = (status == .idle || status == .unknown) ? 0.55 : 1.0 + } +} + +/// 像素小人素材库。 +/// +/// art 用字符串字面量写在代码里,不做成 PNG 资产:可以 diff、可以 code review、 +/// 改一个像素不用重新导出,还能按角色调色。 +/// +/// 构图参考了"人在笔记本后面"这个经典画法:头 + 肩 + 挡在身前的笔记本, +/// 双手从两侧露出来。好处是**上半身占满画面**,16×16 这么小的网格里 +/// 五官才有地方放;画全身的话头只有 4px,怎么画都是一坨。 +enum SpriteLibrary { + + /// 取某状态在某个时钟 tick 下应该显示的帧。 + /// + /// - Parameter seed: 用 sessionId 的 hash。它同时决定**形象**(发色、衣服、发型) + /// 和**动作相位** —— 9 个人整齐划一地同时敲键盘会很诡异。 + static func frame(for status: SessionStatus, tick: Int, seed: Int) -> SpriteFrame { + let style = SpriteCharacter(seed: seed, status: status).style + let phase = tick + Int(seed.magnitude % 4) + + switch status { + case .busy: + return SpriteFrame(rows(style: style, pose: .typing(step: phase / 2 % 4))) + case .shell: + return SpriteFrame(rows(style: style, pose: .command(step: phase / 2 % 4))) + case .waiting: + // waiting **不错峰**:需要用户行动的状态就该整齐地一起挥手, + // 那是刻意制造的注意力抓取。 + return SpriteFrame(rows(style: style, pose: .waving(step: tick / 2 % 4))) + case .idle, .unknown: + return SpriteFrame(rows(style: style, pose: idlePose(tick: tick, seed: seed))) + } + } + + /// idle 是**状态机**而不是纯循环,这是省电的关键。 + /// + /// 9 个会话里通常 6–7 个是 idle。如果它们都按 6fps 循环,前面为了省电做的 + /// 一切都白费了。所以 idle 平时是静止的闭眼,每 8–14 秒才打一次哈欠, + /// 而且按 seed 错峰 —— 6 个人同时打哈欠会很出戏。 + private static func idlePose(tick: Int, seed: Int) -> Pose { + let clock = 12 // 主时钟 12 tick = 1 秒 + // **先取模再运算。** 这里曾经写成 `abs(seed) * 37`,而真实的 seed 来自 + // FNV 哈希(量级接近 Int.max),一乘就整数溢出,Swift 直接 trap —— + // 表现是 app 一显示小人就闪退。任何拿哈希值做算术的地方都要先收进小区间。 + let magnitude = seed.magnitude + let period = (8 + Int(magnitude % 7)) * clock + let phase = (tick + Int(magnitude % UInt(period))) % period + let yawnLength = 2 * clock + if phase < yawnLength { + return .yawning(step: (phase * 3) / yawnLength) + } + return .resting + } + + // MARK: - 姿势 + + enum Pose { + case typing(step: Int) + case command(step: Int) + case waving(step: Int) + case resting + case yawning(step: Int) + } + + // MARK: - 组装 + + /// 把发型、脸、身体、笔记本拼成 16×16。 + /// + /// 分段拼装而不是给每个状态各写 4 张完整的图:那样是 16 张手写网格, + /// 改一处(比如笔记本宽一格)要同步改 16 遍,必然漂。 + static func rows(style: Int, pose: Pose) -> [String] { + var out: [String] = [] + out.append(contentsOf: hairRows(style: style)) + out.append(contentsOf: faceRows(pose: pose)) + out.append(contentsOf: bodyRows(pose: pose)) + out.append(contentsOf: laptopRows(pose: pose)) + // 拼出来必须正好 16 行 —— 少一行会让笔记本贴到画面外。 + return out + } + + /// 发型(第 0–3 行,含顶上一行留白)。 + /// + /// 四种,靠**轮廓形状**区分而不只是颜色 —— 缩到 32pt 时形状比颜色更早可辨, + /// 而且色觉障碍用户也分得出。 + /// 最后一行统一是 10 格宽,好和下面的脸对齐。 + private static func hairRows(style: Int) -> [String] { + switch style % 4 { + case 0: // 齐刘海 + return [ + "................", + ".....######.....", + "....#hhhhhh#....", + "...#hhhhhhhh#...", + ] + case 1: // 侧分 + return [ + "................", + "....#######.....", + "...#hhhhhhhh#...", + "...#hhhHHhhh#...", + ] + case 2: // 蓬松 + return [ + "................", + "...##h##h##h....", + "...#hhhhhhhh#...", + "...#hHhhhhHh#...", + ] + default: // 丸子头 + return [ + ".......##.......", + "......#hh#......", + "....#hhhhhh#....", + "...#hhhhhhhh#...", + ] + } + } + + /// 脸(第 4–8 行)。 + /// + /// 给了 5 行 —— 初版只给 4 行、笔记本占 7 行,结果脸小得只剩两个点, + /// 整张图被一块灰色笔记本主导。**这是张人物像,不是张笔记本像。** + /// 眼睛是这套画法里最值钱的两个像素:有眼睛是个人,没眼睛是个色块。 + private static func faceRows(pose: Pose) -> [String] { + let closed: Bool + var mouth = "...#ffffffff#..." + switch pose { + case .resting: + closed = true + case .yawning(let step): + closed = true + // 哈欠张嘴:中间挖成阴影。 + mouth = step >= 1 ? "...#ffFeeFff#..." : "...#ffFFFFff#..." + default: + closed = false + } + + let eyes = closed + ? "...#feeeeeef#..." // 闭眼画成一条横线 + : "...#feffffef#..." + + return [ + "...#hffffffh#...", // 两侧留一点鬓角 + eyes, + mouth, + "....#ffffff#....", + ".....#ffff#.....", // 下巴收窄 + ] + } + + /// 肩膀(第 9–10 行)。waving 时右侧多一只挥动的手。 + private static func bodyRows(pose: Pose) -> [String] { + guard case .waving(let step) = pose else { + return [ + "..##ssssssss##..", + ".#ssssssssssss#.", + ] + } + // 手掌左右摆一格,幅度小但在 32pt 下看得出来。 + return [ + step % 2 == 0 ? "..##ssssssss##f." : "..##ssssssss##.f", + ".#ssssssssssss#.", + ] + } + + /// 笔记本(第 9–15 行)。 + /// + /// 从背面看,所以是一块盖板 + 中间发光的标志 —— 屏幕的光就是状态。 + /// 双手从两侧露出来,敲键盘时上下动一格。 + private static func laptopRows(pose: Pose) -> [String] { + // 手的位置用**这一段内部的行号**(0…4),不是整图行号 —— + // 用整图行号的话,上面任何一段增减一行都会让手悄悄跑到别的地方去。 + var hands = (left: 2, right: 2) + // 发光区占 4 格宽 × 2 行。初版只有 2×1,那点面积根本传达不了状态。 + var glow = "gggg" + + switch pose { + case .typing(let step): + // 两只手交替上下,敲键盘的节奏就出来了。 + hands = [(1, 2), (2, 1), (2, 2), (1, 1)][step % 4] + case .command(let step): + // 命令行:发光区变成一个左右走的光标,和"敲键盘"最容易区分开。 + glow = ["g...", ".g..", "..g.", "...g"][step % 4] + hands = (2, 2) + case .waving, .resting, .yawning: + hands = (2, 2) + } + + /// `.` + 左手 + `#` + 盖板 10 格 + `#` + 右手 + `.` = 16 格。 + func lid(_ local: Int, _ core: String) -> String { + let left = hands.left == local ? "f" : "." + let right = hands.right == local ? "f" : "." + return ".\(left)#\(core)#\(right)." + } + + return [ + "..############..", + lid(1, "LLL\(glow)LLL"), + lid(2, "LLL\(glow)LLL"), + "..#LLLLLLLLLL#..", + "..#llllllllll#..", + ] + } + + // MARK: - 小尺寸 + + /// 列表行用的 8×8 简化版。 + /// + /// 不复用 16×16 缩小:那些 1px 的眼睛、发型细节在 8px 网格上会退化成噪点。 + /// 这里只保留三件事 —— 头发的颜色块、两只眼睛、发光的笔记本。 + /// 这就是 SF Symbols 的光学尺寸思路:小尺寸要的是另一张图,不是同一张缩小。 + static func micro(for status: SessionStatus, tick: Int, seed: Int) -> SpriteFrame { + let sleeping = status == .idle || status == .unknown + // 睁眼的会偶尔眨一下;idle 一直闭着。 + // 同样先把 seed 收进小区间:`abs(Int.min)` 会 trap,加法也可能溢出。 + let blink = !sleeping && (tick / 12 + Int(seed.magnitude % 7)) % 7 == 0 + let eyes = (sleeping || blink) ? "#feef#." : "#fefe#." + + return SpriteFrame([ + "..####..", + ".#hhhh#.", + ".#Hhhh#.", + ".\(eyes)", + "#ssssss#", + "#LLggLL#", + "#LLggLL#", + "#llllll#", + ]) + } +} diff --git a/HubKit/Sources/hubctl/main.swift b/HubKit/Sources/hubctl/main.swift new file mode 100644 index 0000000..9b35c4e --- /dev/null +++ b/HubKit/Sources/hubctl/main.swift @@ -0,0 +1,168 @@ +import Darwin +import Foundation +import HubIPC + +// hubctl —— Claude Code hook 的执行体。 +// +// 为什么不用 bash: +// - 用 bash 拼 JSON 必须自己处理转义,而 tool_input 里带着任意 shell 命令, +// 这是实打实的注入隐患; +// - 本机 `nc` 没有 `-N`,没法在写完后半关闭连接,读应答会挂住; +// - python3 的可用性依赖用户环境,hook 是全局生效的东西,不能赌。 +// +// 用法(由 ~/.claude/settings.json 配置): +// hubctl hook stop +// hubctl hook notification +// hubctl hook pretool +// hubctl doctor 自检:socket 通不通 +// +// **fail-open 是最高原则**:任何异常都放行。一个卡死的 hook 比一个漏审的命令 +// 危害大得多 —— 前者会让用户的每一次工具调用都卡住 60 秒。 + +/// preToolUse 等待岛上决策的上限。见 `HookTimeouts` 里的超时阶梯说明 —— +/// 这个值必须**大于**服务端的决策超时,否则超时会变成放行而不是拒绝。 +private let approvalTimeout = HookTimeouts.clientRead + +/// 从 stdin 读 hook 的 JSON。 +private func readStdin() -> [String: Any]? { + let data = FileHandle.standardInput.readDataToEndOfFile() + guard !data.isEmpty else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] +} + +private func string(_ dict: [String: Any], _ key: String) -> String? { + (dict[key] as? String).flatMap { $0.isEmpty ? nil : $0 } +} + +/// 构造事件。补上 Claude 不提供但我们需要的环境信息。 +private func makeEvent(kind: HookEvent.Kind, payload: [String: Any]) -> HookEvent { + let env = ProcessInfo.processInfo.environment + + var toolSummary: String? + if let toolName = string(payload, "tool_name"), + let input = payload["tool_input"] as? [String: Any] { + toolSummary = RiskClassifier.summarize(toolName: toolName, input: input) + } + + return HookEvent( + kind: kind, + requestId: UUID().uuidString, + sessionId: string(payload, "session_id") ?? "", + cwd: string(payload, "cwd") ?? FileManager.default.currentDirectoryPath, + transcriptPath: string(payload, "transcript_path"), + permissionMode: string(payload, "permission_mode"), + lastAssistantMessage: string(payload, "last_assistant_message"), + stopReason: string(payload, "stop_reason"), + notificationType: string(payload, "notification_type"), + message: string(payload, "message"), + toolName: string(payload, "tool_name"), + toolSummary: toolSummary, + toolUseId: string(payload, "tool_use_id"), + // $TMUX_PANE 是 tmux 注入到每个 pane 环境里的 pane id。 + // 旧实现用 `tmux display-message -p '#W'` 且没带 -t,拿的是当前 active + // 窗口而不是自己所在的窗口 —— 用户切个 tab 就归错项目了。读环境变量没这个问题。 + tmuxPane: env["TMUX_PANE"], + clientPid: getpid() + ) +} + +/// PreToolUse 的应答格式(已查证官方文档)。 +/// 什么都不输出等于默认放行,所以只在 deny 时才需要写。 +private func emitDecision(_ decision: HookDecision) { + guard decision.verdict != .allow else { return } + + let output: [String: Any] = [ + "hookSpecificOutput": [ + "hookEventName": "PreToolUse", + "permissionDecision": decision.verdict.rawValue, + "permissionDecisionReason": decision.reason ?? "被 Claude Hub 拦截", + ], + ] + if let data = try? JSONSerialization.data(withJSONObject: output), + let text = String(data: data, encoding: .utf8) { + print(text) + } +} + +private func runHook(_ name: String) -> Int32 { + let kind: HookEvent.Kind + switch name { + case "stop": kind = .stop + case "notification", "notify": kind = .notification + case "pretool", "pretooluse": kind = .preToolUse + case "sessionend": kind = .sessionEnd + default: + FileHandle.standardError.write(Data("未知的 hook 类型:\(name)\n".utf8)) + return 0 // 仍然放行 + } + + guard let payload = readStdin() else { return 0 } + let event = makeEvent(kind: kind, payload: payload) + + let result = HubSocketClient.send( + event, + waitForDecision: kind == .preToolUse, + timeout: approvalTimeout + ) + + guard kind == .preToolUse else { return 0 } + + // 排障开关:HUB_DEBUG=1 时把收到的结果打到 stderr。 + // stderr 不会被 Claude 当成 hook 的决策输出,所以开着也不影响行为。 + if ProcessInfo.processInfo.environment["HUB_DEBUG"] == "1" { + FileHandle.standardError.write(Data("hubctl 收到:\(result)\n".utf8)) + } + + switch result { + case .decided(let decision): + emitDecision(decision) + + case .unreachable: + // Hub 没运行 —— 这次调用压根没被审查过。放行。 + // 不能因为工具没开就让用户的每条命令都失败。 + break + + case .noResponse: + // 连上了、发出去了,但服务端没在 75 秒内回应。 + // + // 关键推断:服务端对非高风险操作是**立即**应答的,能走到这里说明 + // 它认为这次操作值得拦截,只是决策环节出了问题。这种情况必须拒绝 —— + // 拒绝可恢复(Claude 会告诉用户并可重试),放行不可逆。 + emitDecision( + HookDecision(verdict: .deny, reason: "Claude Hub 未能完成审批,已保守拒绝") + ) + + case .acknowledged: + break + } + return 0 +} + +private func runDoctor() -> Int32 { + let path = HubSocket.path + print("socket 路径:\(path)") + print("路径长度:\(path.utf8.count) / \(HubSocket.maxPathLength) 字节") + print("socket 文件存在:\(FileManager.default.fileExists(atPath: path) ? "是" : "否")") + + if HubSocketClient.isHubRunning() { + print("连接测试:✓ Claude Hub 正在运行") + return 0 + } + print("连接测试:✗ 连不上(Claude Hub 没运行,hook 会全部放行)") + return 1 +} + +let args = Array(CommandLine.arguments.dropFirst()) +switch args.first { +case "hook": + guard args.count >= 2 else { + FileHandle.standardError.write(Data("用法:hubctl hook \n".utf8)) + exit(0) + } + exit(runHook(args[1])) +case "doctor": + exit(runDoctor()) +default: + print("用法:hubctl | doctor>") + exit(0) +} diff --git a/HubKit/Tests/HubCoreTests/AgentSessionTests.swift b/HubKit/Tests/HubCoreTests/AgentSessionTests.swift new file mode 100644 index 0000000..2418549 --- /dev/null +++ b/HubKit/Tests/HubCoreTests/AgentSessionTests.swift @@ -0,0 +1,97 @@ +import XCTest +@testable import HubCore + +final class AgentSessionTests: XCTestCase { + + /// 真实样本,逐字段抄自本机 ~/.claude/sessions/34624.json。 + private let realBusySample = """ + {"pid":34624,"sessionId":"8ba8f1c1-0039-4295-8193-78fa6056578f",\ + "cwd":"/Users/dev/Documents/code/shopify/storefront-a","startedAt":1785140285143,\ + "procStart":"Mon Jul 27 08:18:03 2026","version":"2.1.220","peerProtocol":1,\ + "kind":"interactive","entrypoint":"cli","name":"storefront-a-c3","nameSource":"derived",\ + "status":"busy","updatedAt":1785225171453,"statusUpdatedAt":1785225171453} + """ + + func testDecodesRealBusySample() throws { + let session = try XCTUnwrap(AgentSession.decode(from: Data(realBusySample.utf8))) + + XCTAssertEqual(session.sessionId, "8ba8f1c1-0039-4295-8193-78fa6056578f") + XCTAssertEqual(session.pid, 34624) + XCTAssertEqual(session.cwd, "/Users/dev/Documents/code/shopify/storefront-a") + XCTAssertEqual(session.name, "storefront-a-c3") + XCTAssertEqual(session.status, .busy) + XCTAssertEqual(session.kind, .interactive) + XCTAssertEqual(session.version, "2.1.220") + XCTAssertNil(session.waitingFor) + XCTAssertEqual(session.fallbackProjectName, "storefront-a") + } + + func testDecodesWaitingWithReason() throws { + let json = """ + {"pid":34624,"sessionId":"abc","cwd":"/tmp/x","status":"waiting",\ + "kind":"interactive","waitingFor":"input needed"} + """ + let session = try XCTUnwrap(AgentSession.decode(from: Data(json.utf8))) + XCTAssertEqual(session.status, .waiting) + XCTAssertEqual(session.waitingFor, "input needed") + } + + func testDecodesBackgroundSessionWithJobId() throws { + let json = """ + {"pid":23402,"sessionId":"171d69d9","cwd":"/tmp/x","kind":"bg",\ + "status":"shell","jobId":"171d69d9"} + """ + let session = try XCTUnwrap(AgentSession.decode(from: Data(json.utf8))) + XCTAssertEqual(session.kind, .bg) + XCTAssertEqual(session.jobId, "171d69d9") + XCTAssertEqual(session.status, .shell) + } + + /// Claude Code 将来可能加新的 status 取值。不能因此丢掉整个会话, + /// 也不能把未知状态误判成 idle(那会让它在 UI 上装作没事)。 + func testUnknownStatusIsPreservedNotDropped() throws { + let json = #"{"pid":1,"sessionId":"x","cwd":"/tmp","status":"compacting"}"# + let session = try XCTUnwrap(AgentSession.decode(from: Data(json.utf8))) + XCTAssertEqual(session.status, .unknown) + XCTAssertEqual(session.rawStatus, "compacting") + } + + /// schema 变动导致缺字段时,只有三个硬要求缺失才该整条丢弃。 + func testRejectsRecordsMissingIdentityFields() { + XCTAssertNil(AgentSession.decode(from: Data(#"{"sessionId":"x","cwd":"/tmp"}"#.utf8))) + XCTAssertNil(AgentSession.decode(from: Data(#"{"pid":1,"cwd":"/tmp"}"#.utf8))) + XCTAssertNil(AgentSession.decode(from: Data(#"{"pid":1,"sessionId":"x"}"#.utf8))) + XCTAssertNil(AgentSession.decode(from: Data(#"{"pid":1,"sessionId":"","cwd":"/tmp"}"#.utf8))) + XCTAssertNil(AgentSession.decode(from: Data("not json".utf8))) + } + + func testMissingOptionalFieldsStillDecodes() throws { + let json = #"{"pid":1,"sessionId":"x","cwd":"/tmp/proj"}"# + let session = try XCTUnwrap(AgentSession.decode(from: Data(json.utf8))) + XCTAssertEqual(session.status, .unknown) + XCTAssertEqual(session.kind, .unknown) + XCTAssertNil(session.name) + XCTAssertNil(session.startedAt) + } + + /// 排序即 UI 优先级。waiting 必须永远第一,unknown 必须永远最后 —— + /// 后者保证 Claude 新增状态取值时不会意外抢占最显眼的位置。 + func testStatusOrderingPutsWaitingFirstAndUnknownLast() { + let shuffled: [SessionStatus] = [.idle, .unknown, .busy, .waiting, .shell] + XCTAssertEqual(shuffled.sorted(), [.waiting, .busy, .shell, .idle, .unknown]) + } + + func testIsActiveExcludesIdleAndUnknown() { + XCTAssertTrue(SessionStatus.busy.isActive) + XCTAssertTrue(SessionStatus.waiting.isActive) + XCTAssertTrue(SessionStatus.shell.isActive) + XCTAssertFalse(SessionStatus.idle.isActive) + XCTAssertFalse(SessionStatus.unknown.isActive) + } + + func testMillisecondTimestampsConvertToDates() throws { + let session = try XCTUnwrap(AgentSession.decode(from: Data(realBusySample.utf8))) + let started = try XCTUnwrap(session.startedAt) + XCTAssertEqual(started.timeIntervalSince1970, 1_785_140_285.143, accuracy: 0.001) + } +} diff --git a/HubKit/Tests/HubIPCTests/RiskClassifierTests.swift b/HubKit/Tests/HubIPCTests/RiskClassifierTests.swift new file mode 100644 index 0000000..f060edf --- /dev/null +++ b/HubKit/Tests/HubIPCTests/RiskClassifierTests.swift @@ -0,0 +1,196 @@ +import XCTest +@testable import HubIPC + +/// 风险分级是唯一一个「判错会真的删掉代码」的地方,所以两个方向都要测: +/// 漏判(危险命令被放行)和误判(日常命令被拦,用户会直接关掉整个功能)。 +final class RiskClassifierTests: XCTestCase { + + private let classifier = RiskClassifier() + + private func level(_ command: String, tool: String = "Bash") -> RiskLevel { + classifier.classify(toolName: tool, summary: command) + } + + // MARK: - 不可逆 + + func testIrreversibleDeletions() { + XCTAssertEqual(level("rm -rf /"), .irreversible) + XCTAssertEqual(level("rm -rf ~"), .irreversible) + XCTAssertEqual(level("sudo rm -rf /"), .irreversible) + XCTAssertEqual(level("rm -rf /*"), .irreversible) + } + + func testForcePushToProtectedBranches() { + XCTAssertEqual(level("git push --force origin main"), .irreversible) + XCTAssertEqual(level("git push origin master --force"), .irreversible) + XCTAssertEqual(level("git push -f origin production"), .irreversible) + } + + func testDatabaseDestruction() { + XCTAssertEqual(level("psql -c 'DROP DATABASE app'"), .irreversible) + XCTAssertEqual(level("mysql -e \"drop schema prod\""), .irreversible) + } + + func testInfrastructureDestruction() { + XCTAssertEqual(level("kubectl delete deployment api"), .irreversible) + XCTAssertEqual(level("terraform destroy"), .irreversible) + XCTAssertEqual(level("mkfs.ext4 /dev/disk2"), .irreversible) + XCTAssertEqual(level("dd if=/dev/zero of=/dev/disk2"), .irreversible) + } + + // MARK: - 危险但可恢复 + + func testDangerousButRecoverable() { + XCTAssertEqual(level("rm -rf node_modules"), .dangerous) + XCTAssertEqual(level("git reset --hard HEAD~3"), .dangerous) + XCTAssertEqual(level("git push --force origin feature/foo"), .dangerous) + XCTAssertEqual(level("chmod -R 777 ./public"), .dangerous) + XCTAssertEqual(level("curl https://example.com/install.sh | sh"), .dangerous) + XCTAssertEqual(level("npm publish"), .dangerous) + XCTAssertEqual(level("docker system prune"), .dangerous) + XCTAssertEqual(level("sudo systemctl restart nginx"), .dangerous) + XCTAssertEqual(level("cat ~/.ssh/id_rsa"), .dangerous) + } + + func testDeleteWithoutWhereIsDangerous() { + XCTAssertEqual(level("psql -c 'DELETE FROM users'"), .dangerous) + } + + func testDeleteWithWhereIsNormal() { + XCTAssertEqual(level("psql -c 'DELETE FROM users WHERE id = 3'"), .normal) + } + + // MARK: - 不能误伤日常操作 + + /// 这组比上面两组更重要。误判会让用户直接把整个功能关掉, + /// 那时候连不可逆操作也不再受保护了。 + func testEverydayCommandsAreNotFlagged() { + let everyday = [ + "npm install", + "npm run build", + "git status", + "git commit -m 'fix: 修正边界条件'", + "git push origin feature/my-branch", + "git pull --rebase", + "swift build", + "swift test", + "ls -la", + "cargo build --release", + "docker compose up -d", + "pytest tests/", + "mkdir -p build", + "cp -r src dist", + "rm build/output.txt", // 单文件删除,没有 -rf + "SELECT * FROM users", + "kubectl get pods", + "terraform plan", + ] + for command in everyday { + XCTAssertEqual(level(command), .normal, "误判为高风险:\(command)") + } + } + + /// 只读工具永远不拦。 + /// + /// 关键场景:Read 一个讲 `rm -rf` 用法的 README,或 Grep 搜索 "DROP TABLE"。 + /// 按内容匹配的话这些都会误触发。 + func testReadOnlyToolsAreNeverFlagged() { + for tool in ["Read", "Grep", "Glob", "WebFetch", "WebSearch", "TodoWrite", "Task"] { + XCTAssertEqual( + classifier.classify(toolName: tool, summary: "rm -rf / && DROP DATABASE x"), + .normal, + "只读工具 \(tool) 不该被拦" + ) + } + } + + func testEmptyAndNilInputsAreNormal() { + XCTAssertEqual(classifier.classify(toolName: nil, summary: "rm -rf /"), .normal) + XCTAssertEqual(classifier.classify(toolName: "Bash", summary: nil), .normal) + XCTAssertEqual(classifier.classify(toolName: "Bash", summary: ""), .normal) + } + + // MARK: - 拦截门槛 + + /// 默认只拦不可逆。用户全局开着 auto + skip-permissions, + /// 把 dangerous 也拦上每天要弹 3–8 次,结果必然是他关掉整个功能。 + func testDefaultThresholdOnlyInterceptsIrreversible() { + let defaultClassifier = RiskClassifier() + XCTAssertTrue(defaultClassifier.shouldIntercept(.irreversible)) + XCTAssertFalse(defaultClassifier.shouldIntercept(.dangerous)) + XCTAssertFalse(defaultClassifier.shouldIntercept(.normal)) + } + + func testLoweredThresholdInterceptsDangerousToo() { + let strict = RiskClassifier(interceptThreshold: .dangerous) + XCTAssertTrue(strict.shouldIntercept(.irreversible)) + XCTAssertTrue(strict.shouldIntercept(.dangerous)) + // normal 永远不拦,无论门槛怎么调。 + XCTAssertFalse(strict.shouldIntercept(.normal)) + } + + // MARK: - 参数摘要 + + func testSummarizePrefersCommand() { + XCTAssertEqual( + RiskClassifier.summarize( + toolName: "Bash", + input: ["command": "rm -rf x", "description": "清理"] + ), + "rm -rf x" + ) + } + + func testSummarizeFallsBackToPath() { + XCTAssertEqual( + RiskClassifier.summarize(toolName: "Write", input: ["file_path": "/tmp/a.txt"]), + "/tmp/a.txt" + ) + } + + func testSummarizeReturnsNilWhenNothingUseful() { + XCTAssertNil(RiskClassifier.summarize(toolName: "Bash", input: [:])) + XCTAssertNil(RiskClassifier.summarize(toolName: "Bash", input: ["command": ""])) + } +} + +final class HookEventCodingTests: XCTestCase { + + func testRoundTripPreservesAllFields() throws { + let original = HookEvent( + kind: .preToolUse, + requestId: "req-1", + sessionId: "sess-1", + cwd: "/Users/x/code/proj", + transcriptPath: "/tmp/t.jsonl", + permissionMode: "bypassPermissions", + toolName: "Bash", + toolSummary: "rm -rf /", + toolUseId: "toolu_1", + tmuxPane: "%20", + clientPid: 4242 + ) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(HookEvent.self, from: data) + + XCTAssertEqual(decoded.kind, .preToolUse) + XCTAssertEqual(decoded.sessionId, "sess-1") + XCTAssertEqual(decoded.toolSummary, "rm -rf /") + XCTAssertEqual(decoded.tmuxPane, "%20") + XCTAssertEqual(decoded.clientPid, 4242) + XCTAssertEqual(decoded.fallbackProjectName, "proj") + } + + func testDecisionRoundTrip() throws { + let decision = HookDecision(verdict: .deny, reason: "太危险") + let data = try JSONEncoder().encode(decision) + let decoded = try JSONDecoder().decode(HookDecision.self, from: data) + XCTAssertEqual(decoded.verdict, .deny) + XCTAssertEqual(decoded.reason, "太危险") + } + + /// socket 路径必须短于 sun_path 的 104 字节上限,否则 bind 会静默失败。 + func testSocketPathFitsInSunPath() { + XCTAssertLessThanOrEqual(HubSocket.path.utf8.count, HubSocket.maxPathLength) + } +} diff --git a/HubKit/Tests/HubIPCTests/SocketRoundTripTests.swift b/HubKit/Tests/HubIPCTests/SocketRoundTripTests.swift new file mode 100644 index 0000000..5f13426 --- /dev/null +++ b/HubKit/Tests/HubIPCTests/SocketRoundTripTests.swift @@ -0,0 +1,205 @@ +import XCTest +@testable import HubIPC + +/// socket 层的端到端测试。这条链路是 hook 和 app 之间的唯一通道, +/// 断了就等于所有通知和审批全失效,值得真的建 socket 跑一遍。 +final class SocketRoundTripTests: XCTestCase { + + private var socketPath: String! + + override func setUp() { + super.setUp() + // 测试用短路径:Unix socket 的 sun_path 只有 104 字节, + // 用 NSTemporaryDirectory() 拼 UUID 很容易超。 + socketPath = "/tmp/hubtest-\(UUID().uuidString.prefix(8)).sock" + } + + override func tearDown() { + unlink(socketPath) + super.tearDown() + } + + private func makeEvent(kind: HookEvent.Kind, summary: String? = nil) -> HookEvent { + HookEvent( + kind: kind, requestId: UUID().uuidString, sessionId: "s1", + cwd: "/tmp/proj", toolName: "Bash", toolSummary: summary + ) + } + + /// 回归测试:超时阶梯必须严格递增。 + /// + /// 真实事故:`ApprovalCoordinator` 和 hubctl 的读超时都设成 60 秒,两者 + /// 同时到期,hubctl 先判定"没收到应答"并放行了一条 `git push --force origin main`, + /// 而服务端其实已经决定拒绝。超时的语义从"安全拒绝"翻转成了"静默放行"。 + func testTimeoutLadderIsStrictlyIncreasing() { + XCTAssertLessThan( + HookTimeouts.userDecision, HookTimeouts.serverBridge, + "服务端要留余量把用户超时的拒绝收上来" + ) + XCTAssertLessThan( + HookTimeouts.serverBridge, HookTimeouts.clientRead, + "hubctl 必须等得比服务端久,否则超时会变成放行" + ) + XCTAssertLessThan( + HookTimeouts.clientRead, TimeInterval(HookTimeouts.hookProcess), + "Claude 杀 hook 的时限必须最长,否则拒绝还没输出进程就没了" + ) + } + + /// 连上了但服务端不应答 → 必须拒绝,不能放行。 + /// + /// 判据:服务端对非高风险操作是立即应答的。能走到"无应答"说明它认为 + /// 这次操作值得拦截,只是决策环节出了问题 —— 这种情况拒绝才是安全的。 + func testNoResponseIsDistinguishedFromUnreachable() throws { + // 服务端收下但永不应答(模拟 UI 卡死)。 + let server = HubSocketServer(path: socketPath) { _ in + Thread.sleep(forTimeInterval: 3) + return nil + } + try server.start() + defer { server.stop() } + + let result = HubSocketClient.send( + makeEvent(kind: .preToolUse, summary: "rm -rf /"), + path: socketPath, waitForDecision: true, timeout: 0.5 + ) + + guard case .noResponse = result else { + return XCTFail("连上但无应答应报 .noResponse,实际是 \(result)") + } + } + + func testUnreachableIsReportedDistinctly() { + let result = HubSocketClient.send( + makeEvent(kind: .preToolUse, summary: "rm -rf /"), + path: "/tmp/absent-\(UUID().uuidString.prefix(6)).sock", + waitForDecision: true, timeout: 5 + ) + guard case .unreachable = result else { + return XCTFail("连不上应报 .unreachable,实际是 \(result)") + } + } + + func testFireAndForgetEventReachesServer() throws { + let received = XCTestExpectation(description: "服务端收到事件") + nonisolated(unsafe) var seen: HookEvent? + + let server = HubSocketServer(path: socketPath) { event in + seen = event + received.fulfill() + return nil + } + try server.start() + defer { server.stop() } + + let result = HubSocketClient.send( + makeEvent(kind: .stop), path: socketPath, + waitForDecision: false, timeout: 2 + ) + + wait(for: [received], timeout: 3) + guard case .acknowledged = result else { + return XCTFail("非审批事件应报 .acknowledged,实际是 \(result)") + } + XCTAssertEqual(seen?.kind, .stop) + XCTAssertEqual(seen?.sessionId, "s1") + } + + func testPreToolUseReceivesDecision() throws { + let server = HubSocketServer(path: socketPath) { _ in + HookDecision(verdict: .deny, reason: "测试拒绝") + } + try server.start() + defer { server.stop() } + + let result = HubSocketClient.send( + makeEvent(kind: .preToolUse, summary: "rm -rf /"), + path: socketPath, waitForDecision: true, timeout: 5 + ) + + guard case .decided(let decision) = result else { + return XCTFail("应拿到明确决策,实际是 \(result)") + } + XCTAssertEqual(decision.verdict, .deny) + XCTAssertEqual(decision.reason, "测试拒绝") + } + + /// **最重要的一条**:Hub 没跑时客户端必须立刻失败,让 hook 放行。 + /// 如果这里挂住,用户的每一次工具调用都会卡 75 秒 —— 那比漏审严重得多。 + func testClientFailsFastWhenServerAbsent() { + let start = Date() + let result = HubSocketClient.send( + makeEvent(kind: .preToolUse, summary: "rm -rf /"), + path: "/tmp/definitely-not-there-\(UUID().uuidString.prefix(6)).sock", + waitForDecision: true, timeout: 30 + ) + guard case .unreachable = result else { + return XCTFail("连不上应报 .unreachable,实际是 \(result)") + } + XCTAssertLessThan( + Date().timeIntervalSince(start), 1.0, + "连不上时必须立刻失败,不能等到超时" + ) + } + + func testIsHubRunningReflectsServerState() throws { + XCTAssertFalse(HubSocketClient.isHubRunning(path: socketPath)) + + let server = HubSocketServer(path: socketPath) { _ in nil } + try server.start() + XCTAssertTrue(HubSocketClient.isHubRunning(path: socketPath)) + + server.stop() + XCTAssertFalse(HubSocketClient.isHubRunning(path: socketPath)) + } + + /// 服务端处理器返回 nil 时,协议层会回一条明确的 allow,而不是让客户端挂住。 + func testNilHandlerResultBecomesExplicitAllow() throws { + let server = HubSocketServer(path: socketPath) { _ in nil } + try server.start() + defer { server.stop() } + + let result = HubSocketClient.send( + makeEvent(kind: .preToolUse, summary: "rm -rf /"), + path: socketPath, waitForDecision: true, timeout: 5 + ) + guard case .decided(let decision) = result else { + return XCTFail("应拿到明确决策,实际是 \(result)") + } + XCTAssertEqual(decision.verdict, .allow) + } + + /// 上次进程被 kill -9 会留下 socket 文件,不删的话 bind 报 EADDRINUSE。 + func testStaleSocketFileIsReplaced() throws { + FileManager.default.createFile(atPath: socketPath, contents: Data()) + XCTAssertTrue(FileManager.default.fileExists(atPath: socketPath)) + + let server = HubSocketServer(path: socketPath) { _ in nil } + XCTAssertNoThrow(try server.start()) + server.stop() + } + + func testMultipleConcurrentClients() throws { + let count = 8 + let received = XCTestExpectation(description: "全部事件送达") + received.expectedFulfillmentCount = count + + let server = HubSocketServer(path: socketPath) { _ in + received.fulfill() + return nil + } + try server.start() + defer { server.stop() } + + let path = socketPath! + DispatchQueue.concurrentPerform(iterations: count) { index in + _ = HubSocketClient.send( + HookEvent( + kind: .stop, requestId: "r\(index)", sessionId: "s\(index)", cwd: "/tmp" + ), + path: path, waitForDecision: false, timeout: 2 + ) + } + wait(for: [received], timeout: 5) + } +} diff --git a/HubKit/Tests/HubJumpTests/BackgroundSessionBindingTests.swift b/HubKit/Tests/HubJumpTests/BackgroundSessionBindingTests.swift new file mode 100644 index 0000000..1860f18 --- /dev/null +++ b/HubKit/Tests/HubJumpTests/BackgroundSessionBindingTests.swift @@ -0,0 +1,140 @@ +import XCTest +@testable import HubCore +@testable import HubJump +@testable import HubProbe + +/// `kind: bg` 会话的绑定。 +/// +/// 实测背景:bg 会话跑在 `claude bg-pty-host` daemon 下,祖先链是 +/// `23402 (claude bg-spare) → 23371 (claude bg-pty-host) → 70305 (claude daemon) → 1`, +/// **完全不经过任何终端**。用户看它的地方是某个跑 `claude agents` 的 tab, +/// 那个 tab 的 jobPid 指向查看器进程(如 23360),与 23402 毫无亲缘关系。 +final class BackgroundSessionBindingTests: XCTestCase { + + private func bgSession(_ pid: pid_t, id: String, name: String) -> AgentSession { + AgentSession( + sessionId: id, pid: pid, cwd: "/tmp", name: name, + status: .idle, rawStatus: "idle", kind: .bg, jobId: id + ) + } + + private func interactiveSession(_ pid: pid_t, id: String, name: String) -> AgentSession { + AgentSession( + sessionId: id, pid: pid, cwd: "/tmp", name: name, + status: .busy, rawStatus: "busy", kind: .interactive + ) + } + + private func term(_ jobPid: pid_t, _ uuid: String, name: String) -> ITermSession { + ITermSession(sessionUUID: uuid, windowId: "3812", jobPid: jobPid, name: name) + } + + /// 实测场景,PID 和标题都抄自本机。 + func testBgSessionBindsToViewerTabByJobName() { + let tree = ProcessTree(parents: [ + 23402: 23371, 23371: 70305, 70305: 1, // bg 会话,挂在 daemon 下 + 23360: 61126, 61126: 61125, 61125: 1, // 查看器 tab,与上面无亲缘 + ]) + let mapping = JumpEngine.bind( + itermSessions: [ + term(23360, "VIEWER", name: "⠐ o3-ui-memory-idempotent-fixes (Claude)"), + ], + claudeSessions: [ + bgSession(23402, id: "bg-1", name: "o3-ui-memory-idempotent-fixes"), + ], + tree: tree + ) + XCTAssertEqual(mapping["bg-1"], "VIEWER") + } + + /// 祖先链先跑,名字匹配只兜底。已经被祖先链认领的 tab 不能被名字匹配抢走 —— + /// 否则一个 interactive 会话的 ai-title 里偶然含有某个 job 名就会把 tab 抢过去。 + func testAncestryBindingWinsOverNameMatching() { + let tree = ProcessTree(parents: [ + 30024: 29976, 29976: 1, + 23402: 23371, 23371: 70305, 70305: 1, + ]) + // 这个 tab 既能被祖先链绑到 interactive 会话,标题里又恰好含 bg 会话的名字。 + let ambiguous = term( + 30024, "SHARED", + name: "正在处理 o3-ui-memory-idempotent-fixes 相关问题" + ) + let mapping = JumpEngine.bind( + itermSessions: [ambiguous], + claudeSessions: [ + interactiveSession(29976, id: "inter", name: "hub"), + bgSession(23402, id: "bg-1", name: "o3-ui-memory-idempotent-fixes"), + ], + tree: tree + ) + XCTAssertEqual(mapping["inter"], "SHARED") + XCTAssertNil(mapping["bg-1"], "已被祖先链认领的 tab 不该再被名字匹配抢走") + } + + /// interactive 会话**不**参与名字匹配。它的祖先链一定连得上; + /// 连不上说明它压根不在 iTerm 里,此时按标题猜只会猜错。 + func testInteractiveSessionsDoNotFallBackToNameMatching() { + let tree = ProcessTree(parents: [99999: 1, 30024: 1]) + let mapping = JumpEngine.bind( + itermSessions: [term(30024, "T", name: "some-unique-session-name")], + claudeSessions: [ + interactiveSession(99999, id: "i", name: "some-unique-session-name"), + ], + tree: tree + ) + XCTAssertTrue(mapping.isEmpty) + } + + /// 太短的名字不参与匹配,避免 `api` 这类词在别的标题里偶然命中。 + func testShortJobNamesAreNotMatchedByTitle() { + let tree = ProcessTree(parents: [500: 1, 600: 1]) + let mapping = JumpEngine.bind( + itermSessions: [term(600, "T", name: "deploying api to prod")], + claudeSessions: [bgSession(500, id: "bg", name: "api")], + tree: tree + ) + XCTAssertTrue(mapping.isEmpty) + } + + func testBgSessionWithoutNameIsSkipped() { + let tree = ProcessTree(parents: [500: 1, 600: 1]) + let mapping = JumpEngine.bind( + itermSessions: [term(600, "T", name: "anything")], + claudeSessions: [ + AgentSession( + sessionId: "bg", pid: 500, cwd: "/tmp", name: nil, + status: .idle, rawStatus: "idle", kind: .bg + ), + ], + tree: tree + ) + XCTAssertTrue(mapping.isEmpty) + } + + /// 两个 bg 会话不能抢同一个 tab。 + func testTwoBackgroundSessionsDoNotShareOneTab() { + let tree = ProcessTree(parents: [500: 70305, 501: 70305, 70305: 1, 600: 1]) + let mapping = JumpEngine.bind( + itermSessions: [term(600, "ONLY", name: "shared-prefix-alpha")], + claudeSessions: [ + bgSession(500, id: "bg-a", name: "shared-prefix-alpha"), + bgSession(501, id: "bg-b", name: "shared-prefix-alpha"), + ], + tree: tree + ) + XCTAssertEqual(mapping.count, 1) + XCTAssertEqual(mapping.values.first, "ONLY") + } + + /// 没有查看器 tab 开着时,bg 会话就是绑不上 —— 这是正确行为, + /// 跳转会降级为「只前置终端」,而不是跳到一个错误的 tab。 + func testBgSessionWithNoViewerTabRemainsUnbound() { + let tree = ProcessTree(parents: [500: 70305, 70305: 1, 600: 1]) + let mapping = JumpEngine.bind( + itermSessions: [term(600, "OTHER", name: "完全无关的标题")], + claudeSessions: [bgSession(500, id: "bg", name: "some-long-job-name")], + tree: tree + ) + XCTAssertTrue(mapping.isEmpty) + } +} diff --git a/HubKit/Tests/HubJumpTests/JumpBindingTests.swift b/HubKit/Tests/HubJumpTests/JumpBindingTests.swift new file mode 100644 index 0000000..837c986 --- /dev/null +++ b/HubKit/Tests/HubJumpTests/JumpBindingTests.swift @@ -0,0 +1,169 @@ +import XCTest +@testable import HubCore +@testable import HubJump +@testable import HubProbe + +/// 绑定逻辑是整个跳转方案的核心,也是最容易出错的地方。 +/// AppleScript 和 sysctl 没法在测试里跑,所以把纯逻辑抽了出来单独测。 +/// +/// 下面的进程树全部抄自本机实测(见方案文档 §1.2),不是编造的场景。 +final class JumpBindingTests: XCTestCase { + + private func session(_ pid: pid_t, _ id: String, cwd: String = "/tmp") -> AgentSession { + AgentSession( + sessionId: id, pid: pid, cwd: cwd, + status: .busy, rawStatus: "busy", kind: .interactive + ) + } + + private func term(_ jobPid: pid_t, _ uuid: String) -> ITermSession { + ITermSession(sessionUUID: uuid, windowId: "3812", jobPid: jobPid, name: "irrelevant") + } + + /// 实测链路一:tmux -CC 会话,claude 是 pane 根进程,node 是它的子进程。 + /// 30024(node) → 29976(claude ✓) → 25111(tmux) + func testBindsThroughNodeChildInTmuxSession() { + let tree = ProcessTree(parents: [30024: 29976, 29976: 25111, 25111: 1]) + let mapping = JumpEngine.bind( + itermSessions: [term(30024, "UUID-A")], + claudeSessions: [session(29976, "claude-hub-session")], + tree: tree + ) + XCTAssertEqual(mapping["claude-hub-session"], "UUID-A") + } + + /// 实测链路二:中间隔着 npm exec 的 MCP 服务进程,祖先链更深。 + /// 91852(node) → 91758(npm exec) → 90252(claude ✓) → 25111(tmux) + func testBindsThroughDeeperChainViaNpmExec() { + let tree = ProcessTree(parents: [ + 91852: 91758, 91758: 90252, 90252: 25111, 25111: 1, + ]) + let mapping = JumpEngine.bind( + itermSessions: [term(91852, "UUID-B")], + claudeSessions: [session(90252, "acme-admin-session")], + tree: tree + ) + XCTAssertEqual(mapping["acme-admin-session"], "UUID-B") + } + + /// 实测链路三:**非 tmux** 的 iTerm 原生会话,祖先链通到 iTermServer。 + /// 同一套算法必须覆盖这种情况 —— 这正是"任意终端,非 tmux"需求的实现依据。 + func testBindsNonTmuxNativeItermSession() { + let tree = ProcessTree(parents: [ + 23360: 61126, 61126: 61125, 61125: 21560, 21560: 21558, 21558: 1, + ]) + let mapping = JumpEngine.bind( + itermSessions: [term(23360, "UUID-C")], + claudeSessions: [session(61126, "bg-session")], + tree: tree + ) + XCTAssertEqual(mapping["bg-session"], "UUID-C") + } + + /// jobPid 恰好就是 claude 进程本身(pane 里没有前台子进程时)。 + func testBindsWhenJobPidIsTheClaudeProcessItself() { + let tree = ProcessTree(parents: [29976: 25111, 25111: 1]) + let mapping = JumpEngine.bind( + itermSessions: [term(29976, "UUID-D")], + claudeSessions: [session(29976, "direct")], + tree: tree + ) + XCTAssertEqual(mapping["direct"], "UUID-D") + } + + /// 多个会话同时存在时不能串味 —— 这是旧实现按标题子串匹配最容易犯的错 + /// (多个 tab 标题都叫 "Claude Code" 就会跳错)。 + func testMultipleSessionsDoNotCrossBind() { + let tree = ProcessTree(parents: [ + 30024: 29976, 29976: 25111, + 91852: 91758, 91758: 90252, 90252: 25111, + 97188: 97138, 97138: 25111, + 25111: 1, + ]) + let mapping = JumpEngine.bind( + itermSessions: [term(30024, "A"), term(91852, "B"), term(97188, "C")], + claudeSessions: [ + session(29976, "hub"), session(90252, "admin"), session(97138, "image"), + ], + tree: tree + ) + XCTAssertEqual(mapping, ["hub": "A", "admin": "B", "image": "C"]) + } + + /// 非 Claude 的 tab(比如一个普通 zsh)不该被绑到任何会话上。 + func testPlainShellTabBindsToNothing() { + let tree = ProcessTree(parents: [5608: 25111, 25111: 1]) + let mapping = JumpEngine.bind( + itermSessions: [term(5608, "UUID-ZSH")], + claudeSessions: [session(29976, "elsewhere")], + tree: tree + ) + XCTAssertTrue(mapping.isEmpty) + } + + /// 进程树里查不到 jobPid(进程刚退出,快照有撕裂)时不能崩,静默跳过即可。 + func testUnknownJobPidIsSkipped() { + let tree = ProcessTree(parents: [29976: 25111]) + let mapping = JumpEngine.bind( + itermSessions: [term(999_999, "GHOST"), term(29976, "REAL")], + claudeSessions: [session(29976, "s")], + tree: tree + ) + XCTAssertEqual(mapping, ["s": "REAL"]) + } + + func testNoClaudeSessionsYieldsEmptyMapping() { + let tree = ProcessTree(parents: [30024: 29976, 29976: 1]) + XCTAssertTrue(JumpEngine.bind( + itermSessions: [term(30024, "A")], claudeSessions: [], tree: tree + ).isEmpty) + } + + /// 最近的祖先优先:嵌套 claude(claude 内又起了一个 claude)时 + /// 应该绑到里层那个,因为那才是这个 tab 前台真正在跑的会话。 + func testNearestAncestorWins() { + let tree = ProcessTree(parents: [500: 400, 400: 300, 300: 1]) + let mapping = JumpEngine.bind( + itermSessions: [term(500, "T")], + claudeSessions: [session(400, "inner"), session(300, "outer")], + tree: tree + ) + XCTAssertEqual(mapping["inner"], "T") + XCTAssertNil(mapping["outer"]) + } +} + +/// 进程树遍历本身的边界情况。 +final class ProcessTreeTests: XCTestCase { + + func testAncestryIncludesSelfAndStopsAtRoot() { + let tree = ProcessTree(parents: [30024: 29976, 29976: 25111, 25111: 1, 1: 0]) + XCTAssertEqual(tree.ancestry(of: 30024), [30024, 29976, 25111, 1]) + } + + /// 快照撕裂可能造出环。必须截断而不是死循环 —— 这会挂死整个跳转。 + func testCyclicParentChainTerminates() { + let tree = ProcessTree(parents: [10: 20, 20: 30, 30: 10]) + let chain = tree.ancestry(of: 10) + XCTAssertEqual(chain, [10, 20, 30]) + } + + func testSelfParentTerminates() { + let tree = ProcessTree(parents: [10: 10]) + XCTAssertEqual(tree.ancestry(of: 10), [10]) + } + + func testDepthLimitIsRespected() { + var parents: [pid_t: pid_t] = [:] + for i in 1..<200 { parents[pid_t(i)] = pid_t(i + 1) } + XCTAssertEqual(tree(parents).ancestry(of: 1, limit: 5).count, 5) + } + + func testMissingPidYieldsItselfOnly() { + XCTAssertEqual(ProcessTree(parents: [:]).ancestry(of: 42), [42]) + } + + private func tree(_ parents: [pid_t: pid_t]) -> ProcessTree { + ProcessTree(parents: parents) + } +} diff --git a/HubKit/Tests/HubProbeTests/ClaudeSessionReaderTests.swift b/HubKit/Tests/HubProbeTests/ClaudeSessionReaderTests.swift new file mode 100644 index 0000000..290d97e --- /dev/null +++ b/HubKit/Tests/HubProbeTests/ClaudeSessionReaderTests.swift @@ -0,0 +1,152 @@ +import XCTest +@testable import HubCore +@testable import HubProbe + +final class ClaudeSessionReaderTests: XCTestCase { + + private var tempDir: URL! + + override func setUpWithError() throws { + tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("hubkit-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: tempDir) + } + + private func write(pid: Int, json: String) throws { + try Data(json.utf8).write(to: tempDir.appendingPathComponent("\(pid).json")) + } + + private func record( + pid: Int, sessionId: String, status: String = "busy", + cwd: String = "/tmp/p", updatedAt: Int? = nil + ) -> String { + var fields = [ + "\"pid\":\(pid)", "\"sessionId\":\"\(sessionId)\"", + "\"cwd\":\"\(cwd)\"", "\"status\":\"\(status)\"", "\"kind\":\"interactive\"", + ] + if let updatedAt { fields.append("\"updatedAt\":\(updatedAt)") } + return "{\(fields.joined(separator: ","))}" + } + + /// 这是最关键的一条:实测 ~/.claude/sessions/ 里残留着好几天前已退出进程的 + /// json(死进程的文件不会被清理)。不过滤,岛上就会显示一堆幽灵会话。 + func testDeadProcessesAreFilteredOut() throws { + try write(pid: 100, json: record(pid: 100, sessionId: "alive")) + try write(pid: 200, json: record(pid: 200, sessionId: "dead")) + + let reader = ClaudeSessionReader(directory: tempDir) + let sessions = reader.readAll(isAlive: { $0 == 100 }) + + XCTAssertEqual(sessions.map(\.sessionId), ["alive"]) + } + + func testNonJsonFilesAreIgnored() throws { + try write(pid: 100, json: record(pid: 100, sessionId: "s")) + try Data("garbage".utf8).write(to: tempDir.appendingPathComponent("README.txt")) + try Data("garbage".utf8).write(to: tempDir.appendingPathComponent("notes.md")) + + let sessions = ClaudeSessionReader(directory: tempDir).readAll(isAlive: { _ in true }) + XCTAssertEqual(sessions.count, 1) + } + + /// 一个坏文件不能让整个探测失败 —— 那会让岛整片空白。 + func testCorruptFileDoesNotBreakOtherRecords() throws { + try write(pid: 100, json: record(pid: 100, sessionId: "good")) + try write(pid: 101, json: "{ this is not json") + try write(pid: 102, json: record(pid: 102, sessionId: "also-good")) + + let sessions = ClaudeSessionReader(directory: tempDir).readAll(isAlive: { _ in true }) + XCTAssertEqual(Set(sessions.map(\.sessionId)), ["good", "also-good"]) + } + + /// resume 之后旧 PID 的 json 可能还在,而旧 PID 又恰好被系统复用给别的进程 + /// 从而通过判活。同一 sessionId 只能留一条,取 updatedAt 最新的。 + func testDuplicateSessionIdKeepsMostRecentlyUpdated() throws { + try write(pid: 100, json: record(pid: 100, sessionId: "same", updatedAt: 1000)) + try write(pid: 200, json: record(pid: 200, sessionId: "same", updatedAt: 5000)) + + let sessions = ClaudeSessionReader(directory: tempDir).readAll(isAlive: { _ in true }) + XCTAssertEqual(sessions.count, 1) + XCTAssertEqual(sessions.first?.pid, 200) + } + + func testMissingDirectoryReturnsEmptyRatherThanCrashing() { + let reader = ClaudeSessionReader( + directory: tempDir.appendingPathComponent("does-not-exist") + ) + XCTAssertTrue(reader.readAll(isAlive: { _ in true }).isEmpty) + } + + func testAlivePIDsSetMatchesReadAll() throws { + try write(pid: 100, json: record(pid: 100, sessionId: "a")) + try write(pid: 200, json: record(pid: 200, sessionId: "b")) + try write(pid: 300, json: record(pid: 300, sessionId: "c")) + + let reader = ClaudeSessionReader(directory: tempDir) + XCTAssertEqual(reader.alivePIDs(isAlive: { $0 != 200 }), [100, 300]) + } + + func testDefaultDirectoryPointsAtClaudeSessions() { + XCTAssertTrue( + ClaudeSessionReader().directory.path.hasSuffix("/.claude/sessions") + ) + } +} + +final class TmuxBindingTests: XCTestCase { + + private func pane(_ windowId: String, pid: pid_t, name: String = "w") -> TmuxPane { + TmuxPane( + sessionName: "hub", windowId: windowId, windowName: name, + paneId: "%1", panePid: pid, currentPath: "/tmp", currentCommand: "2.1.220" + ) + } + + /// claude 就是 pane 的根进程 —— 直接相等的简单情况。 + func testBindsWhenClaudeIsPaneRoot() { + let tree = ProcessTree(parents: [29976: 25111, 25111: 1]) + let binding = TmuxProbe().bindPanes( + to: [29976], tree: tree, panes: [pane("@20", pid: 29976)] + ) + XCTAssertEqual(binding[29976]?.windowId, "@20") + } + + /// pane 根进程是 shell,claude 是它的子进程。实测 8 个窗口里有 2 个是这样, + /// 所以绝不能用 `panePid == sessionPid` 直接相等去绑。 + func testBindsWhenClaudeIsDescendantOfPaneRoot() { + let tree = ProcessTree(parents: [29976: 8000, 8000: 25111, 25111: 1]) + let binding = TmuxProbe().bindPanes( + to: [29976], tree: tree, panes: [pane("@21", pid: 8000)] + ) + XCTAssertEqual(binding[29976]?.windowId, "@21") + } + + /// 不在任何 tmux pane 里的会话(跑在原生终端 tab 里)不该被误绑。 + func testSessionOutsideTmuxIsNotBound() { + let tree = ProcessTree(parents: [61126: 61125, 61125: 21558, 21558: 1]) + let binding = TmuxProbe().bindPanes( + to: [61126], tree: tree, panes: [pane("@20", pid: 29976)] + ) + XCTAssertTrue(binding.isEmpty) + } + + func testEachSessionBindsToItsOwnPane() { + let tree = ProcessTree(parents: [ + 29976: 25111, 90252: 25111, 97138: 25111, 25111: 1, + ]) + let binding = TmuxProbe().bindPanes( + to: [29976, 90252, 97138], + tree: tree, + panes: [ + pane("@20", pid: 29976), pane("@23", pid: 90252), pane("@22", pid: 97138), + ] + ) + XCTAssertEqual(binding[29976]?.windowId, "@20") + XCTAssertEqual(binding[90252]?.windowId, "@23") + XCTAssertEqual(binding[97138]?.windowId, "@22") + } +} diff --git a/HubKit/Tests/HubProjectsTests/ProjectSourceTests.swift b/HubKit/Tests/HubProjectsTests/ProjectSourceTests.swift new file mode 100644 index 0000000..d33bace --- /dev/null +++ b/HubKit/Tests/HubProjectsTests/ProjectSourceTests.swift @@ -0,0 +1,203 @@ +import XCTest +@testable import HubProjects + +/// 守 projects.yaml 这条数据链路。 +/// +/// 背景是一次真实事故:主窗口三个页面全空。根因不是界面没做,而是 +/// **app 读的 projects.yaml 和脚本维护的那份不是同一个文件**, +/// 且它读到的那份是坏的(`projects: []` 后面跟着 117 条内容), +/// 解析器静默返回空数组。 +final class ProjectSourceTests: XCTestCase { + + // MARK: - 坏文件 + + /// `projects: []` 后面跟着真实条目 —— 旧版写下空数组标记, + /// 后来的追加逻辑又往文件尾巴上加内容,产出的就是这种文件。 + /// + /// 原解析器判的是 `trimmed == "projects:"`,`projects: []` 完全不匹配, + /// 于是后面所有条目被静默忽略。 + func testEmptyArrayMarkerDoesNotSwallowEntries() { + let yaml = """ + # Claude Hub 项目清单 + scan_dirs: + - ~/Documents/code + + projects: [] + + - name: claude-hub + path: /Users/dev/Documents/code/claude-hub + + - name: go-wechat + path: /Users/dev/Documents/code/go-wechat + """ + let projects = ProjectYAML.parse(yaml) + XCTAssertEqual(projects.count, 2) + XCTAssertEqual(projects.map(\.name), ["claude-hub", "go-wechat"]) + } + + /// 其它几种"空值"写法同样不能吃掉后面的条目。 + func testOtherEmptyMarkersAlsoParse() { + for marker in ["[]", "~", "null", "{}"] { + let yaml = """ + projects: \(marker) + - name: demo + path: /tmp/demo + """ + XCTAssertEqual( + ProjectYAML.parse(yaml).count, 1, + "`projects: \(marker)` 后面的条目被吃掉了" + ) + } + } + + /// 正常写法不能被上面的放宽改坏。 + func testCanonicalFormStillParses() { + let yaml = """ + projects: + - name: acme-erp + aliases: [acme-admin, erp, 后台] + path: ~/Documents/code/acme-admin + description: "示例管理后台" + tags: [vue, gin] + """ + let projects = ProjectYAML.parse(yaml) + XCTAssertEqual(projects.count, 1) + XCTAssertEqual(projects[0].name, "acme-erp") + XCTAssertEqual(projects[0].aliases, ["acme-admin", "erp", "后台"]) + XCTAssertEqual(projects[0].description, "示例管理后台") + XCTAssertEqual(projects[0].tags, ["vue", "gin"]) + } + + /// 别的顶层段落不能被当成项目段。 + func testOtherTopLevelSectionsAreSkipped() { + let yaml = """ + scan_dirs: + - name: 这不是项目 + path: /tmp/nope + projects: + - name: real + path: /tmp/real + """ + let projects = ProjectYAML.parse(yaml) + XCTAssertEqual(projects.map(\.name), ["real"]) + } + + // MARK: - 解析失败要能被看见 + + /// 文件里明明有条目却一条都没解析出来时,必须给出诊断。 + /// 静默返回空数组正是这次排查花掉最多时间的原因。 + func testBrokenFileProducesDiagnostic() { + // 项目段整个缺失,但下面有 name 条目。 + let yaml = """ + scan_dirs: + - ~/code + somethingelse: + - name: orphan + path: /tmp/orphan + """ + let outcome = ProjectYAML.analyze(yaml) + XCTAssertTrue(outcome.projects.isEmpty) + XCTAssertTrue(outcome.looksBroken) + XCTAssertNotNil(outcome.diagnostic) + } + + /// 真的就是没有项目(不是格式坏了)时不该报错。 + func testGenuinelyEmptyFileIsNotFlaggedAsBroken() { + let outcome = ProjectYAML.analyze("projects:\n") + XCTAssertTrue(outcome.projects.isEmpty) + XCTAssertFalse(outcome.looksBroken) + XCTAssertNil(outcome.diagnostic) + } + + // MARK: - 追加写入 + + /// 追加前必须把 `projects: []` 规范化,否则写出去的文件下次还是解析不出来 —— + /// 这正是那份坏文件的产生方式。 + func testAppendNormalizesEmptyMarkerFirst() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("projects-\(UUID().uuidString).yaml") + defer { try? FileManager.default.removeItem(at: url) } + + try """ + # 注释要保住 + scan_dirs: + - ~/code + + projects: [] + """.write(to: url, atomically: true, encoding: .utf8) + + ProjectStore.append(paths: ["/tmp/alpha", "/tmp/beta"], to: url) + + let text = try String(contentsOf: url, encoding: .utf8) + XCTAssertTrue(text.contains("# 注释要保住"), "追加不该丢掉用户手写的注释") + XCTAssertFalse(text.contains("projects: []")) + + let projects = ProjectYAML.parse(text) + XCTAssertEqual(projects.map(\.name), ["alpha", "beta"]) + } + + /// 已经是正常格式时,追加不该动 `projects:` 那一行,也不该丢掉已有条目。 + func testAppendPreservesExistingEntries() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("projects-\(UUID().uuidString).yaml") + defer { try? FileManager.default.removeItem(at: url) } + + try """ + projects: + - name: existing + aliases: [old] + path: /tmp/existing + """.write(to: url, atomically: true, encoding: .utf8) + + ProjectStore.append(paths: ["/tmp/fresh"], to: url) + + let projects = ProjectYAML.parse(try String(contentsOf: url, encoding: .utf8)) + XCTAssertEqual(projects.map(\.name), ["existing", "fresh"]) + XCTAssertEqual(projects[0].aliases, ["old"], "已有条目的别名要原样保留") + } + + func testNormalizeLeavesGoodFilesUntouched() { + let good = "projects:\n - name: a\n path: /tmp/a\n" + XCTAssertEqual(ProjectStore.normalizeProjectsKey(in: good), good) + } + + // MARK: - 数据源选择 + + /// 坏掉的候选**不能**把好的那份挡住。 + /// + /// 原实现只判"文件存不存在",于是 Application Support 里那份坏文件 + /// 永远排在前面,仓库里 21 个项目的好文件根本没机会被读到。 + @MainActor + func testBrokenCandidateDoesNotShadowGoodOne() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let good = dir.appendingPathComponent("good.yaml") + try "projects:\n - name: good\n path: /tmp/good\n" + .write(to: good, atomically: true, encoding: .utf8) + + let store = ProjectStore(yamlURL: good) + store.load() + XCTAssertEqual(store.projects.map(\.name), ["good"]) + XCTAssertEqual(store.sourceURL, good) + XCTAssertNil(store.loadDiagnostic) + } + + /// 读到坏文件时,`sourceURL` 和诊断都要有值 —— UI 靠它们告诉用户去哪看。 + @MainActor + func testBrokenSourceIsReported() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("broken-\(UUID().uuidString).yaml") + defer { try? FileManager.default.removeItem(at: url) } + try "notprojects:\n - name: x\n path: /tmp/x\n" + .write(to: url, atomically: true, encoding: .utf8) + + let store = ProjectStore(yamlURL: url) + store.load() + XCTAssertTrue(store.projects.isEmpty) + XCTAssertEqual(store.sourceURL, url) + XCTAssertNotNil(store.loadDiagnostic) + } +} diff --git a/HubKit/Tests/HubProjectsTests/ProjectYAMLTests.swift b/HubKit/Tests/HubProjectsTests/ProjectYAMLTests.swift new file mode 100644 index 0000000..6a3da60 --- /dev/null +++ b/HubKit/Tests/HubProjectsTests/ProjectYAMLTests.swift @@ -0,0 +1,183 @@ +import XCTest +@testable import HubProjects + +final class ProjectYAMLTests: XCTestCase { + + /// 真实样本,结构抄自本机 projects.yaml。 + private let sample = """ + scan_dirs: + - ~/Documents/code + - ~/Documents/code/shopify + + projects: + - name: acme-erp + aliases: [acme-admin, erp, 后台] + path: ~/Documents/code/acme-admin + description: "示例管理后台 (GIN-VUE-ADMIN)" + tags: [vue, gin, docker] + - name: claude-hub + path: ~/Documents/code/claude-hub + description: "调度中心" + """ + + func testParsesRealSample() { + let projects = ProjectYAML.parse(sample) + XCTAssertEqual(projects.count, 2) + + let erp = projects[0] + XCTAssertEqual(erp.name, "acme-erp") + XCTAssertEqual(erp.path, "~/Documents/code/acme-admin") + XCTAssertEqual(erp.aliases, ["acme-admin", "erp", "后台"]) + XCTAssertEqual(erp.description, "示例管理后台 (GIN-VUE-ADMIN)") + XCTAssertEqual(erp.tags, ["vue", "gin", "docker"]) + } + + /// scan_dirs 段落里的 `- ~/...` 长得很像项目条目,必须跳过。 + func testScanDirsSectionIsIgnored() { + let projects = ProjectYAML.parse(sample) + XCTAssertFalse(projects.contains { $0.name.contains("Documents") }) + } + + func testTildeExpansion() { + let project = Project(name: "x", path: "~/code/x") + XCTAssertTrue(project.expandedPath.hasPrefix("/")) + XCTAssertFalse(project.expandedPath.contains("~")) + } + + func testAbsolutePathUnchanged() { + XCTAssertEqual(Project(name: "x", path: "/opt/x").expandedPath, "/opt/x") + } + + func testCommentsAndBlankLinesIgnored() { + let text = """ + # 这是注释 + projects: + + # 另一条注释 + - name: a + path: /tmp/a + """ + let projects = ProjectYAML.parse(text) + XCTAssertEqual(projects.count, 1) + XCTAssertEqual(projects[0].name, "a") + } + + /// 缺 path 的条目直接丢弃,不能造出一个指向空路径的项目。 + func testEntryWithoutPathIsDropped() { + let text = """ + projects: + - name: broken + - name: ok + path: /tmp/ok + """ + let projects = ProjectYAML.parse(text) + XCTAssertEqual(projects.map(\.name), ["ok"]) + } + + func testInlineNameOnDashLine() { + let text = """ + projects: + - name: inline + path: /tmp/inline + """ + XCTAssertEqual(ProjectYAML.parse(text).first?.name, "inline") + } + + func testEmptyInputYieldsNoProjects() { + XCTAssertTrue(ProjectYAML.parse("").isEmpty) + XCTAssertTrue(ProjectYAML.parse("projects:").isEmpty) + } +} + +final class GitStatusParsingTests: XCTestCase { + + func testCleanRepoWithUpstream() { + let info = GitStatus.parse(""" + # branch.oid abc123 + # branch.head main + # branch.upstream origin/main + # branch.ab +0 -0 + """) + XCTAssertEqual(info.branch, "main") + XCTAssertTrue(info.isClean) + XCTAssertTrue(info.hasUpstream) + XCTAssertEqual(info.ahead, 0) + XCTAssertEqual(info.behind, 0) + } + + func testDirtyRepoCountsEveryChangeLine() { + let info = GitStatus.parse(""" + # branch.head feature/x + # branch.upstream origin/feature/x + # branch.ab +3 -1 + 1 .M N... 100644 100644 100644 aaa bbb file1.swift + 1 M. N... 100644 100644 100644 ccc ddd file2.swift + ? untracked.txt + """) + XCTAssertEqual(info.branch, "feature/x") + XCTAssertEqual(info.changeCount, 3) + XCTAssertEqual(info.ahead, 3) + XCTAssertEqual(info.behind, 1) + XCTAssertFalse(info.isClean) + } + + /// 没有上游的分支不该显示 ahead/behind —— 那两个数字没有意义。 + func testBranchWithoutUpstream() { + let info = GitStatus.parse(""" + # branch.head local-only + """) + XCTAssertEqual(info.branch, "local-only") + XCTAssertFalse(info.hasUpstream) + } + + func testDetachedHeadHasNoBranch() { + let info = GitStatus.parse("# branch.head (detached)") + XCTAssertNil(info.branch) + } +} + +final class UsageEncodingTests: XCTestCase { + + /// 编码规则必须和 Claude Code 完全一致,否则统计会全部落空。 + func testDirectoryEncodingMatchesClaudeCode() { + XCTAssertEqual( + UsageStats.encodeDirectoryName(for: "/Users/dev/Documents/code/claude-hub"), + "-Users-dev-Documents-code-claude-hub" + ) + } + + func testNonAsciiBecomesDash() { + XCTAssertEqual(UsageStats.encodeDirectoryName(for: "/tmp/项目"), "-tmp---") + } + + func testDotsAndUnderscoresBecomeDash() { + XCTAssertEqual(UsageStats.encodeDirectoryName(for: "/a/b.c_d"), "-a-b-c-d") + } +} + +final class LaunchModeTests: XCTestCase { + + func testCommandsMatchRustImplementation() { + XCTAssertEqual(LaunchMode.claude.command(sessionId: nil), "claude") + XCTAssertEqual( + LaunchMode.claudeSkipPermissions.command(sessionId: nil), + "claude --dangerously-skip-permissions" + ) + XCTAssertEqual(LaunchMode.resumeLast.command(sessionId: nil), "claude --continue") + XCTAssertEqual(LaunchMode.resumePick.command(sessionId: nil), "claude --resume") + XCTAssertNil(LaunchMode.terminal.command(sessionId: nil)) + } + + func testResumeSessionQuotesId() { + XCTAssertEqual( + LaunchMode.resumeSession.command(sessionId: "abc-123"), + "claude --resume 'abc-123'" + ) + } + + /// sessionId 来自文件内容,理论上可控 —— 引号必须转义,不能拼出注入。 + func testShellQuoteEscapesSingleQuotes() { + XCTAssertEqual(shellQuote("it's"), #"'it'\''s'"#) + XCTAssertEqual(shellQuote("a; rm -rf /"), "'a; rm -rf /'") + } +} diff --git a/HubKit/Tests/HubProjectsTests/UsageStreamingTests.swift b/HubKit/Tests/HubProjectsTests/UsageStreamingTests.swift new file mode 100644 index 0000000..6cf08e3 --- /dev/null +++ b/HubKit/Tests/HubProjectsTests/UsageStreamingTests.swift @@ -0,0 +1,133 @@ +import XCTest +@testable import HubProjects + +/// 守用量统计的流式解析和缓存。 +/// +/// 背景:原实现对每个 jsonl 做 `String(contentsOf:)` 再 `split`, +/// 而 `~/.claude/projects` 实测有 **945 MB / 341 个文件**, +/// 整份读进内存跑不出结果,用量页永远停在 0 —— 用户读到的是"这个功能没做"。 +final class UsageStreamingTests: XCTestCase { + + private func write(_ lines: [String]) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("usage-\(UUID().uuidString).jsonl") + try lines.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8) + return url + } + + private func scan(_ url: URL) -> FileUsage? { + let values = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]) + return UsageStats.scanFile( + url, + size: Int64(values?.fileSize ?? 0), + modified: values?.contentModificationDate ?? .distantPast + ) + } + + // MARK: - 解析正确性 + + func testCountsTokensAcrossLines() throws { + let url = try write([ + #"{"type":"user","message":{"content":"hi"}}"#, + #"{"message":{"model":"claude-opus-5","usage":{"input_tokens":10,"output_tokens":5}}}"#, + #"{"message":{"model":"claude-opus-5","usage":{"input_tokens":3,"cache_read_input_tokens":7,"cache_creation_input_tokens":2,"output_tokens":4}}}"#, + ]) + defer { try? FileManager.default.removeItem(at: url) } + + let stats = try XCTUnwrap(scan(url)) + XCTAssertEqual(stats.messages, 2) + // 10 + (3 + 7 + 2):cache 读写都算进入 input。 + XCTAssertEqual(stats.input, 22) + XCTAssertEqual(stats.output, 9) + XCTAssertEqual(stats.models, ["claude-opus-5"]) + } + + /// **跨块的那一行不能被切坏。** + /// + /// 分块读的关键细节:块边界几乎必然落在某行中间,残留的尾巴必须留到 + /// 下一块开头拼接。写错的话表现是"大文件统计偏低",而且非常难发现 —— + /// 数字只是小了一点,不会报错。 + func testHandlesLinesSpanningChunkBoundaries() throws { + // 每行塞一段长 padding,逼出多个 1 MB 块。 + let padding = String(repeating: "x", count: 200_000) + var lines: [String] = [] + for index in 0..<20 { + lines.append( + #"{"pad":"\#(padding)","message":{"usage":{"input_tokens":\#(index),"output_tokens":1}}}"# + ) + } + let url = try write(lines) + defer { try? FileManager.default.removeItem(at: url) } + + let stats = try XCTUnwrap(scan(url)) + XCTAssertEqual(stats.messages, 20, "跨块的行被漏掉或切坏了") + XCTAssertEqual(stats.input, (0..<20).reduce(0, +)) + XCTAssertEqual(stats.output, 20) + } + + /// 最后一行没有换行符时也要算进去。 + func testCountsFinalLineWithoutTrailingNewline() throws { + let url = try write([ + #"{"message":{"usage":{"input_tokens":1,"output_tokens":1}}}"# + ]) + defer { try? FileManager.default.removeItem(at: url) } + XCTAssertEqual(try XCTUnwrap(scan(url)).messages, 1) + } + + /// 坏行、空行、没有 usage 的行都要安静跳过,不能中断整个文件。 + func testSkipsMalformedAndIrrelevantLines() throws { + let url = try write([ + "", + "这不是 json", + #"{"message":{"content":"没有 usage"}}"#, + #"{"broken":"usage 出现在这里但结构不对"}"#, + #"{"message":{"usage":{"input_tokens":5,"output_tokens":2}}}"#, + "", + ]) + defer { try? FileManager.default.removeItem(at: url) } + + let stats = try XCTUnwrap(scan(url)) + XCTAssertEqual(stats.messages, 1) + XCTAssertEqual(stats.input, 5) + } + + func testEmptyFileYieldsZeros() throws { + let url = try write([]) + defer { try? FileManager.default.removeItem(at: url) } + let stats = try XCTUnwrap(scan(url)) + XCTAssertEqual(stats.messages, 0) + XCTAssertEqual(stats.input, 0) + } + + // MARK: - 缓存 + + /// 缓存的失效判据是 size + mtime。两者都没变就必须命中, + /// 否则每次打开都要重扫近 1 GB。 + func testCacheRoundTrips() throws { + let entry = FileUsage( + size: 123, modified: Date(timeIntervalSince1970: 1_700_000_000), + messages: 4, input: 100, output: 50, models: ["claude-opus-5"] + ) + let encoded = try JSONEncoder().encode(["/tmp/a.jsonl": entry]) + let decoded = try JSONDecoder().decode([String: FileUsage].self, from: encoded) + + let restored = try XCTUnwrap(decoded["/tmp/a.jsonl"]) + XCTAssertEqual(restored.size, entry.size) + XCTAssertEqual(restored.modified, entry.modified) + XCTAssertEqual(restored.input, entry.input) + XCTAssertEqual(restored.models, entry.models) + } + + // MARK: - 范围 + + func testRangeCutoffs() { + XCTAssertNil(UsageRange.all.cutoff) + XCTAssertNotNil(UsageRange.week7.cutoff) + XCTAssertNotNil(UsageRange.month30.cutoff) + // 7 天的截止时间必须比 30 天的晚(窗口更窄)。 + XCTAssertGreaterThan( + try XCTUnwrap(UsageRange.week7.cutoff), + try XCTUnwrap(UsageRange.month30.cutoff) + ) + } +} diff --git a/HubKit/Tests/HubUITests/ApprovalCoordinatorTests.swift b/HubKit/Tests/HubUITests/ApprovalCoordinatorTests.swift new file mode 100644 index 0000000..e508c53 --- /dev/null +++ b/HubKit/Tests/HubUITests/ApprovalCoordinatorTests.swift @@ -0,0 +1,195 @@ +import HubIPC +import XCTest +@testable import HubUI + +@MainActor +final class ApprovalCoordinatorTests: XCTestCase { + + // 每个 case 都用 `logURL: nil` 建协调器 —— 默认路径指向用户真实的 + // ~/Library/Application Support/claude-hub/approval-log.json, + // 测试跑进去既会互相污染,也会往用户数据里写垃圾。 + + private func request( + id: String = "r1", risk: RiskLevel = .irreversible, clientPid: pid_t = 0 + ) -> ApprovalRequest { + ApprovalRequest( + id: id, sessionId: "s1", projectName: "demo", cwd: "/tmp", + toolName: "Bash", command: "git push --force origin main", + risk: risk, clientPid: clientPid + ) + } + + // MARK: - 决策必须原样送回 + + /// 这条看着平凡,但它守的是整个系统里**唯一一个做错了会真删代码**的地方: + /// 用户点了拒绝,hook 那一侧必须收到拒绝。中间任何一环把它降级成放行, + /// 都会让"高风险操作审批"变成一个假的安全感。 + func testDenyReachesTheWaitingHook() async { + let coordinator = ApprovalCoordinator(logURL: nil) + let target = request() + + let decision = Task { await coordinator.requestDecision(for: target) } + // 让 requestDecision 先把请求排进队列。 + while coordinator.current == nil { + await Task.yield() + } + coordinator.deny(target) + + let result = await decision.value + XCTAssertEqual(result.verdict, .deny) + XCTAssertNil(coordinator.current, "决策完成后卡片必须消失") + } + + func testAllowReachesTheWaitingHook() async { + let coordinator = ApprovalCoordinator(logURL: nil) + let target = request(risk: .dangerous) + + let decision = Task { await coordinator.requestDecision(for: target) } + while coordinator.current == nil { + await Task.yield() + } + coordinator.allow(target) + + let result = await decision.value + XCTAssertEqual(result.verdict, .allow) + } + + // MARK: - 僵尸卡 + + /// 发起方进程死了以后,卡片必须自己消失。 + /// + /// 排障时真实撞到过:hook 进程被杀(Ctrl-C、会话关闭、超时被 kill), + /// 服务端仍然阻塞在等决策上,岛上挂着一张**已经没人接收结果**的卡。 + /// 它不只是碍眼 —— 队列是先进先出的,这张僵尸卡会一直挡住后面真正 + /// 需要用户处理的请求。 + func testOrphanedRequestIsDroppedWhenClientDies() async { + let coordinator = ApprovalCoordinator(logURL: nil) + // PID 1 (launchd) 一定活着但不属于我们 —— kill 返回 EPERM,应判定为"还活着"。 + let alive = request(id: "alive", clientPid: 1) + // 一个几乎不可能存在的 PID。 + let dead = request(id: "dead", clientPid: 0x7FFF_FFFE) + + let aliveDecision = Task { await coordinator.requestDecision(for: alive) } + let deadDecision = Task { await coordinator.requestDecision(for: dead) } + while coordinator.queue.count < 2 { + await Task.yield() + } + + coordinator.startOrphanSweep() + let outcome = await deadDecision.value + coordinator.stopOrphanSweep() + + XCTAssertEqual(outcome.verdict, .deny, "发起方已死,必须按拒绝收尾") + XCTAssertEqual(coordinator.queue.count, 1, "只该清掉死掉的那一条") + XCTAssertEqual(coordinator.current?.id, "alive") + + coordinator.deny(alive) + _ = await aliveDecision.value + } + + /// `clientPid == 0` 表示"不知道发起方是谁",绝不能当成已死。 + func testUnknownClientPidIsNeverTreatedAsOrphan() async { + let coordinator = ApprovalCoordinator(logURL: nil) + let target = request(clientPid: 0) + + let decision = Task { await coordinator.requestDecision(for: target) } + while coordinator.current == nil { + await Task.yield() + } + + coordinator.startOrphanSweep() + try? await Task.sleep(for: .seconds(3)) + XCTAssertNotNil(coordinator.current, "未知发起方的请求不该被清掉") + coordinator.stopOrphanSweep() + + coordinator.deny(target) + _ = await decision.value + } + + // MARK: - 队列 + + /// 跳过是"稍后再说",不是"拒绝"——不能顺手把它 resolve 掉。 + func testSkipMovesToBackWithoutResolving() async { + let coordinator = ApprovalCoordinator(logURL: nil) + let first = request(id: "a") + let second = request(id: "b") + + let firstDecision = Task { await coordinator.requestDecision(for: first) } + let secondDecision = Task { await coordinator.requestDecision(for: second) } + while coordinator.queue.count < 2 { + await Task.yield() + } + + coordinator.skip(first) + XCTAssertEqual(coordinator.current?.id, "b") + XCTAssertEqual(coordinator.queue.count, 2, "跳过不该让请求消失") + + coordinator.deny(second) + coordinator.deny(first) + let firstResult = await firstDecision.value + let secondResult = await secondDecision.value + XCTAssertEqual(firstResult.verdict, .deny) + XCTAssertEqual(secondResult.verdict, .deny) + } + + /// 队列只剩一条时跳过没有意义,且不能把它弄丢。 + func testSkipIsNoOpForTheOnlyRequest() async { + let coordinator = ApprovalCoordinator(logURL: nil) + let only = request() + + let decision = Task { await coordinator.requestDecision(for: only) } + while coordinator.current == nil { + await Task.yield() + } + + coordinator.skip(only) + XCTAssertEqual(coordinator.current?.id, only.id) + + coordinator.deny(only) + _ = await decision.value + } + + /// 每一次拦截都要留痕,包括被清掉的僵尸请求 —— 审批日志是用户 + /// 决定"要不要把 dangerous 一档也开启拦截"的唯一依据。 + func testEveryResolutionIsLogged() async { + let coordinator = ApprovalCoordinator(logURL: nil) + let target = request() + + let decision = Task { await coordinator.requestDecision(for: target) } + while coordinator.current == nil { + await Task.yield() + } + coordinator.deny(target) + _ = await decision.value + + XCTAssertEqual(coordinator.log.count, 1) + XCTAssertEqual(coordinator.log.first?.verdict, "deny") + XCTAssertTrue(coordinator.log.first?.intercepted == true) + } + + /// 日志必须活过重启。 + /// + /// 这份数据的用途是"看一周再决定要不要把 dangerous 一档也拦上"—— + /// 只存内存的话每次重启都归零,那个决策永远做不了。 + func testLogSurvivesRestart() async { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("approval-log-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: url) } + + let first = ApprovalCoordinator(logURL: url) + let target = request() + let decision = Task { await first.requestDecision(for: target) } + while first.current == nil { await Task.yield() } + first.deny(target) + _ = await decision.value + + // 换一个实例,模拟 app 重启。 + let reopened = ApprovalCoordinator(logURL: url) + XCTAssertEqual(reopened.log.count, 1) + XCTAssertEqual(reopened.log.first?.command, target.command) + XCTAssertEqual(reopened.log.first?.verdict, "deny") + + reopened.clearLog() + XCTAssertTrue(ApprovalCoordinator(logURL: url).log.isEmpty) + } +} diff --git a/HubKit/Tests/HubUITests/IslandLayoutTests.swift b/HubKit/Tests/HubUITests/IslandLayoutTests.swift new file mode 100644 index 0000000..e8a216f --- /dev/null +++ b/HubKit/Tests/HubUITests/IslandLayoutTests.swift @@ -0,0 +1,227 @@ +import XCTest +@testable import HubUI + +/// 形态尺寸回归。 +/// +/// 存在的理由是一个真实事故:悬停态内容算下来要 113.5pt,而容器给的是 96pt, +/// 外面还套着 clipShape —— 于是被**静默裁掉** 17.5pt。没有报错、没有警告, +/// 只有用户看到"信息显示不全"。 +/// +/// 布局裁切在 SwiftUI 里是完全静默的失败,只能靠这种算术断言挡住。 +final class IslandLayoutTests: XCTestCase { + + /// 本机实测的内建屏几何(16" M4 Max,默认缩放)。 + private let notched = NotchGeometry( + screenFrame: CGRect(x: 0, y: 0, width: 1728, height: 1117), + notchWidth: 185, + notchHeight: 32 + ) + + /// 外接屏:无刘海。 + private let external = NotchGeometry( + screenFrame: CGRect(x: 0, y: 0, width: 1920, height: 1080), + notchWidth: 0, + notchHeight: 0 + ) + + // MARK: - 内容必须放得下 + + func testHoverBodyFitsItsContent() { + for count in [0, 1, 3, 9, 20] { + for geo in [notched, external] { + let metrics = IslandMetrics.metrics( + for: .hover, geometry: geo, sessionCount: count + ) + XCTAssertGreaterThanOrEqual( + metrics.bodyHeight, + IslandMetrics.hoverContentHeight, + "悬停态容器装不下内容(\(count) 个会话)—— 会被 clipShape 静默裁掉" + ) + XCTAssertGreaterThanOrEqual( + metrics.bodyWidth, + IslandMetrics.hoverScreenSize(sessionCount: count).width, + "悬停态宽度装不下小人排(\(count) 个会话)" + ) + } + } + } + + func testExpandedBodyFitsHeaderAndDetailCard() { + for geo in [notched, external] { + for count in [1, 4, 9, 30] { + let metrics = IslandMetrics.metrics( + for: .expanded, geometry: geo, sessionCount: count + ) + // header + 分栏 + 详情卡 + 至少一行列表,否则展开的意义就没了。 + let minimum = IslandMetrics.expandedHeaderHeight + + IslandMetrics.expandedTabHeight + + IslandMetrics.expandedDetailHeight + + IslandMetrics.expandedRowHeight + XCTAssertGreaterThanOrEqual(metrics.bodyHeight, minimum) + XCTAssertLessThanOrEqual( + metrics.bodyHeight, IslandMetrics.expandedMaxHeight + ) + } + } + } + + /// 项目栏至少要能放下几行。 + /// + /// 展开态的高度是按**会话数**算的,但「项目」栏里通常有二十来个项目 —— + /// 会话很少时(比如只有 1 个)高度会收得很紧,别把项目列表挤成一条缝。 + func testProjectTabHasUsableHeightEvenWithOneSession() { + let metrics = IslandMetrics.metrics( + for: .expanded, geometry: notched, sessionCount: 1 + ) + let chrome = IslandMetrics.expandedHeaderHeight + IslandMetrics.expandedTabHeight + let listRoom = metrics.bodyHeight - chrome + XCTAssertGreaterThanOrEqual( + listRoom, IslandMetrics.expandedProjectRowHeight * 4, + "只有 1 个会话时,项目栏连 4 行都放不下" + ) + } + + /// 展开态高度跟着会话数走:4 个会话时不该挂着一大片空玻璃。 + func testExpandedHeightAdaptsToSessionCount() { + let few = IslandMetrics.expandedHeight(sessionCount: 3) + let many = IslandMetrics.expandedHeight(sessionCount: 9) + XCTAssertLessThan(few, many) + XCTAssertEqual( + IslandMetrics.expandedHeight(sessionCount: 200), + IslandMetrics.expandedMaxHeight + ) + // 一个会话都没有时也要能立住,不能算出负数或 0。 + XCTAssertGreaterThan(IslandMetrics.expandedHeight(sessionCount: 0), 0) + } + + func testApprovalBodyFitsItsContent() { + for geo in [notched, external] { + let metrics = IslandMetrics.metrics(for: .approval, geometry: geo) + XCTAssertGreaterThanOrEqual( + metrics.bodyHeight, IslandMetrics.approvalContentHeight + ) + } + } + + /// 所有形态都不能超出固定的窗口尺寸 —— 窗口不会跟着变大,超出部分是画不出来的。 + func testEveryStateFitsInsideThePanel() { + let states: [IslandState] = [.rest, .hover, .expanded, .intrusion, .approval] + for state in states { + for geo in [notched, external] { + let metrics = IslandMetrics.metrics( + for: state, geometry: geo, sessionCount: 9 + ) + XCTAssertLessThanOrEqual( + metrics.bodyWidth, IslandMetrics.panelSize.width, + "\(state) 比窗口还宽" + ) + XCTAssertLessThanOrEqual( + metrics.bodyHeight + geo.notchHeight, IslandMetrics.panelSize.height, + "\(state) 比窗口还高" + ) + } + } + } + + // MARK: - 折叠态不能压住菜单栏 + + /// 折叠态上半段的宽度必须**严格等于**刘海宽。 + /// + /// 多出一点点就会压在菜单栏图标上,把输入法、电量的点击吃掉 —— + /// 这是"岛把系统搞坏了"最容易发生的方式。 + func testRestShapeDoesNotCoverTheMenuBar() { + let metrics = IslandMetrics.metrics(for: .rest, geometry: notched) + let shape = IslandShape( + notchWidth: notched.notchWidth, + notchHeight: notched.notchHeight, + bodyWidth: metrics.bodyWidth, + bodyHeight: metrics.bodyHeight, + bottomRadius: metrics.bottomRadius + ) + let rect = CGRect(origin: .zero, size: IslandMetrics.panelSize) + let path = shape.path(in: rect).cgPath + let centerX = rect.midX + + // 刘海高度之内、刘海边缘之外的点,全都必须落在形状外面。 + for y in stride(from: CGFloat(1), through: notched.notchHeight - 1, by: 4) { + let justOutside = centerX + notched.notchWidth / 2 + 2 + XCTAssertFalse( + path.contains(CGPoint(x: justOutside, y: y)), + "折叠态在 y=\(y) 处越过了刘海右沿,会吃掉菜单栏点击" + ) + XCTAssertTrue( + path.contains(CGPoint(x: centerX, y: y)), + "折叠态在 y=\(y) 处没有覆盖刘海中心" + ) + } + } + + /// 折叠态确实比刘海宽("液体挤出"),但只在刘海下沿之后。 + func testRestFlaresOnlyBelowTheNotch() { + let metrics = IslandMetrics.metrics(for: .rest, geometry: notched) + XCTAssertGreaterThan(metrics.bodyWidth, notched.notchWidth) + + let shape = IslandShape( + notchWidth: notched.notchWidth, + notchHeight: notched.notchHeight, + bodyWidth: metrics.bodyWidth, + bodyHeight: metrics.bodyHeight, + bottomRadius: metrics.bottomRadius + ) + let rect = CGRect(origin: .zero, size: IslandMetrics.panelSize) + let path = shape.path(in: rect).cgPath + let centerX = rect.midX + let belowNotch = notched.notchHeight + metrics.bodyHeight / 2 + + XCTAssertTrue( + path.contains(CGPoint( + x: centerX + notched.notchWidth / 2 + 2, y: belowNotch + )), + "刘海下方应该已经扩张开了" + ) + } + + // MARK: - 无刘海走胶囊 + + func testExternalScreenUsesPillNotHalfLip() { + XCTAssertFalse(external.hasNotch) + let metrics = IslandMetrics.metrics(for: .rest, geometry: external) + let shape = IslandShape( + notchWidth: external.notchWidth, + notchHeight: external.notchHeight, + bodyWidth: metrics.bodyWidth, + bodyHeight: metrics.bodyHeight, + bottomRadius: metrics.bottomRadius + ) + let rect = CGRect(origin: .zero, size: IslandMetrics.panelSize) + let path = shape.path(in: rect).cgPath + + // 胶囊距顶悬浮,所以 y=0 那一行不该有东西。 + XCTAssertFalse(path.contains(CGPoint(x: rect.midX, y: 0))) + XCTAssertTrue( + path.contains(CGPoint(x: rect.midX, y: 8 + metrics.bodyHeight / 2)) + ) + } + + // MARK: - 悬停宽度自适应 + + func testHoverWidthGrowsWithSessionCountAndIsBounded() { + let one = IslandMetrics.hoverWidth(sessionCount: 1) + let five = IslandMetrics.hoverWidth(sessionCount: 5) + let nine = IslandMetrics.hoverWidth(sessionCount: 9) + let absurd = IslandMetrics.hoverWidth(sessionCount: 400) + + XCTAssertLessThanOrEqual(one, five) + XCTAssertLessThan(five, nine) + // 超过上限的会话数不该把岛撑爆 —— 多出来的在展开态的列表里看。 + XCTAssertEqual(nine, absurd) + XCTAssertLessThanOrEqual(absurd, 520) + XCTAssertGreaterThanOrEqual(one, 264) + } + + /// 会话数为 0 时也要有个合理的宽度,不能算出 0 或负数。 + func testHoverWidthHandlesEmptyStore() { + XCTAssertGreaterThan(IslandMetrics.hoverWidth(sessionCount: 0), 0) + XCTAssertGreaterThan(IslandMetrics.hoverRowWidth(sessionCount: 0), 0) + } +} diff --git a/HubKit/Tests/HubUITests/SpriteLibraryTests.swift b/HubKit/Tests/HubUITests/SpriteLibraryTests.swift new file mode 100644 index 0000000..e6b7ec3 --- /dev/null +++ b/HubKit/Tests/HubUITests/SpriteLibraryTests.swift @@ -0,0 +1,164 @@ +import HubCore +import XCTest +@testable import HubUI + +/// 守像素小人的网格完整性和形象稳定性。 +/// +/// 这两类问题在界面上都**不会报错**,只会显示成"有点歪"或"怎么又换发色了", +/// 只能靠断言挡住。 +final class SpriteLibraryTests: XCTestCase { + + private let statuses: [SessionStatus] = [.busy, .waiting, .shell, .idle, .unknown] + + /// 真实量级的 seed,外加整数边界。 + /// + /// 生产里的 seed 是 `SpriteSeed.stable(sessionId)`,量级接近 `Int.max`。 + /// 边界值单列出来是因为 `abs(Int.min)` 会 trap,而 `magnitude` 不会。 + private var realisticSeeds: [Int] { + [ + SpriteSeed.stable("8ba8f1c1-6b3d-4e2a-9c11-2f7d0a1b3c4d"), + SpriteSeed.stable("claude-hub-swiftui-rewrite"), + SpriteSeed.stable(""), + Int.max, + Int.min, + 0, + -1, + ] + } + + // MARK: - 网格 + + /// 每一帧都必须正好 16 行 × 16 列。 + /// + /// 图是分段拼装的(发型 + 脸 + 身体 + 笔记本),少一行会让笔记本掉出画面, + /// 某行少一格会让整个人歪掉 —— 而 Canvas 只会安静地少画几个方块。 + func testEveryFrameIsSixteenBySixteen() { + for status in statuses { + for style in 0..() + for index in 0..<40 { + let seed = SpriteSeed.stable("session-\(index)") + let character = SpriteCharacter(seed: seed, status: .busy) + looks.insert("\(character.hair)|\(character.shirt)|\(character.style)") + } + XCTAssertGreaterThan(looks.count, 20, "40 个会话只有 \(looks.count) 种形象,区分度不够") + } +} diff --git a/README.md b/README.md index 614cc4d..57fa9cd 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,11 @@ Claude Code 本地开发调度中心。用一个 Claude 窗口管理所有项目 ## 功能 +- **灵动岛** — MacBook 刘海变成 agent 控制中心,Liquid Glass + 状态驱动的像素小人 +- **实时会话监控** — 每个 Claude 会话的 `busy / waiting / shell / idle` 状态一眼可见 +- **精准跳转** — 点通知或点会话,直接跳到那个会话所在的终端 tab(不是"跳到 iTerm 然后自己找") +- **高风险审批** — 不可逆操作(`rm -rf /`、force push 到 main 等)在岛上拦截确认 - **tmux 多项目管理** — 每个项目一个 tmux 窗口,iTerm2 标签页体验 -- **全局通知** — 任何 Claude 做完事 → 桌面弹窗 + 语音提醒(零配置) -- **权限通知** — 任何 Claude 等授权 → 立即提醒你去操作 - **用量统计** — 按项目查看 token 消耗、会话数、模型分布 - **项目发现** — 扫描目录自动生成项目清单 - **浏览器自动化** — Claude in Chrome + DevTools 双引擎协同 @@ -143,47 +145,92 @@ Hub 是你和所有项目 Claude 之间的调度层: - 你说"在 xxx 跑构建" → Hub 发送命令到对应窗口 - 任何窗口的 Claude 做完事 → 你收到通知 -## 通知机制(核心功能) +## 灵动岛(Claude Hub.app) -所有 Claude 实例做完事或等授权时,**自动通知你**。零配置,全局生效。 +原生 SwiftUI 应用,把 MacBook 刘海变成 AI agent 的控制中心。要求 **macOS 26+**,自己编译还需要 **Xcode 26**。 -### 工作原理 - -Claude Code 内置 Hook 机制,在特定事件发生时自动执行 shell 脚本。`setup.sh` 会把两个 hook 注入到全局配置 `~/.claude/settings.json`,所有 Claude 实例共享,无需在每个项目单独配置。 - -### 完整链路 - -``` -Claude 完成一轮回复 - → 触发 Stop hook(hub-hook-stop.sh) - → 1. 桌面弹窗通知(点击可跳转到 iTerm2) - → 2. 语音播报(随机:"搞定了,来看看" / "干完了,等你验收" 等 5 种) - → 3. 写事件 JSON 到 /tmp/hub-signals/events/ - → 4. 发 tmux 信号(唤醒 Hub 中等待的进程) - ↓ - Tauri 托盘 app 监听到事件文件 - → 面板实时更新事件列表 - → 系统通知 - -Claude 需要用户授权 - → 触发 Notification hook(hub-hook-permission.sh) - → 同上链路,但提示语变成"需要你授权" / "在等你点确认"等 +```bash +bash scripts/build-swift-app.sh # 编译 + 签名 + 安装到 /Applications +bash scripts/setup-swift-hooks.sh # 安装 hook 到 ~/.claude/settings.json +open -a "Claude Hub" ``` -### 通知渠道 +### 打发布包 -| 渠道 | 说明 | 依赖 | +```bash +bash scripts/release.sh # 跑测试 → universal 编译 → 签名 → dmg +``` + +产物在 `release/Claude-Hub-<版本>.dmg`,arm64 + x86_64 双架构。 +和日常构建的分工:`build-swift-app.sh` 只编当前架构、装进 `/Applications`,几秒钟; +`release.sh` 打完整发布包,**不碰你正在用的那个实例**。 + +包是**自签名、未公证**的。自己的机器没问题,别人下载后首次打开会被 Gatekeeper 拦, +需要右键 → 打开,或者 `xattr -dr com.apple.quarantine "/Applications/Claude Hub.app"`。 +要做到双击即用得有 Apple Developer Program 的 Developer ID 证书 + 公证。 + +**同一个 bundle identifier 只允许对应 /Applications 里那一个 .app。** 留第二份副本 +(哪怕只是"调试用")会让 LaunchServices 注册出多条记录,通知授权和 TCC 授权互相抢, +存活判断也会误匹配 —— 构建脚本因此装完就删掉暂存副本。 + +### 五种形态 + +| 形态 | 触发 | 内容 | |------|------|------| -| 桌面弹窗 | macOS 通知中心,点击跳转 iTerm2 | terminal-notifier(推荐),缺失时降级到 osascript | -| 语音播报 | 随机语音,做完/授权各 5 种话术 | say + afplay(macOS 内置) | -| 托盘面板 | 实时事件列表,点击项目可直接跳转 | Tauri app(文件系统监听 /tmp/hub-signals/events/) | -| tmux 信号 | 程序化等待任务完成 | tmux wait-for(Hub 内部使用) | +| **折叠** | 常驻 | 贴刘海挤出一条 14pt 的"下唇":会话数 + 状态点阵 + 待办徽章 | +| **悬停** | 鼠标靠近 | 每个会话一个 32pt 像素小人,状态驱动动作 + 一行统计 | +| **展开** | 点击 | 完整会话列表,点一行精准跳回对应终端 tab | +| **闯入** | 会话完成 / 需要输入 | 主动膨胀提示,2–2.6 秒后回落 | +| **审批** | 拦截到不可逆操作 | 命令内容 + 拒绝/按住确认,**不会自动消失** | -### 为什么零配置 +无刘海的外接显示器上,折叠态自动退化为屏幕顶部的悬浮胶囊。 -- Hook 配置在 `~/.claude/settings.json`,对所有 Claude 实例全局生效 -- `setup.sh` 一次注入,永久生效,不需要在每个项目重新配置 -- 语音文件在 `setup.sh` 时一次性生成到 `sounds/` 目录 +### 像素小人 + +每个运行中的会话配一个,动作由状态驱动:`busy` 敲键盘、`waiting` 抬头挥手、`shell` 敲命令行、`idle` 呼吸 + 偶发打哈欠(按 sessionId 错峰,避免所有人同时打哈欠)。 + +全部小人共用一个 12fps 主时钟,折叠态 / 窗口被遮挡 / 锁屏 / 显示器休眠时整体冻结。 + +## 实时会话监控 + +状态来自 Claude Code 自己维护的 `~/.claude/sessions/.json`,是**权威数据**而非推断: + +| 状态 | 含义 | +|------|------| +| `busy` | 正在干活 | +| `waiting` | 等你输入或授权(附带 `waitingFor` 说明原因) | +| `shell` | 正在跑 shell 命令 | +| `idle` | 空闲 | + +同时区分 `interactive`(前台会话)和 `bg`(后台任务)。 + +## 精准跳转 + +点通知、点岛上的会话行,都会跳到**那个会话所在的具体终端 tab**。 + +实现方式是从 iTerm 的 `jobPid` 向上遍历进程树,第一个存在 `sessions/.json` 的祖先就是该 tab 承载的会话 —— 全程只用整数 PID 和 UUID,不做任何字符串匹配。tmux `-CC` 会话和非 tmux 的原生会话用同一套算法。 + +`kind: bg` 的会话跑在 daemon 下、不在任何终端里,改为定位到显示它的 `claude agents` 查看器 tab。 + +## 高风险操作审批 + +`PreToolUse` hook 会拦截**不可逆**操作,在岛上弹出审批: + +| 级别 | 例子 | 默认行为 | +|------|------|----------| +| 普通 | `npm install`、`git commit` | 直接放行 | +| 危险 | `rm -rf node_modules`、`git reset --hard`、`sudo` | 只记进审批日志,不拦截 | +| 不可逆 | `rm -rf /`、force push 到 main、`DROP DATABASE` | **拦截**,需按住 900ms 确认 | + +默认只拦最后一档,把日常摩擦压到接近零。想更严格可以在设置里把「危险」一档也打开 —— 建议先看一周审批日志再决定。 + +防误点做了六重保护:按钮间距 24pt 不融合、出现后 700ms 时间锁、指针进入 120ms 冷却、不可逆操作必须长按、**默认焦点在拒绝**(`⏎` 和 `⎋` 都是拒绝,允许必须 `⌘⏎`)、60 秒超时自动拒绝。 + +## 主窗口 + +托盘菜单 → 打开主窗口。五个分区:会话 / 项目 / 用量 / 审批日志 / 设置。 + +和岛的分工:**岛是一瞥**(零点击获取状态),**主窗口是深挖**(用量趋势、项目管理、审批历史)。两者共享同一套数据和组件。 ## 项目管理 @@ -198,24 +245,20 @@ bash scripts/add-project.sh my-app ~/code/my-app app 前端 bash scripts/welcome.sh ``` -## 通知面板(Tauri App) +主窗口的「项目」页也能直接扫描、搜索、按 8 种模式启动(Claude Code / 跳过权限 / 纯终端 / 继续上次会话 / 选择历史会话 / Finder / VS Code)。 -菜单栏托盘应用,实时展示项目状态和通知。 - -- 左键点击托盘图标 → 显示面板 -- 关闭窗口 × → 隐藏到托盘(不退出) -- 右键托盘 → 退出 -- 支持项目搜索(按名称/描述/路径模糊匹配) -- **用量统计** — 按项目查看 token 消耗、会话数、模型分布 - -`setup.sh` 首次运行会自动编译 release 版本(约 2-3 分钟),之后每次启动直接运行二进制,秒开。编译产物是本机架构(ARM/x86),不会提交到 git。 - -手动编译/重新编译: +## 开发 ```bash -bash scripts/build-app.sh +cd HubKit +swift build # 编译 +swift test # 87 个测试 +swift run hubprobe list # 命令行查看会话与终端绑定 +swift run hubprobe jump 3 # 跳到第 3 个会话 ``` +工程结构见 `HubKit/`,实测踩过的坑记在 `HubKit/NOTES.md`。 + ## 浏览器自动化 Claude in Chrome(视觉引擎)+ chrome-devtools-mcp(数据引擎)双引擎协同,连接同一个 Chrome 浏览器。 @@ -265,16 +308,29 @@ Not authorized to send Apple events to iTerm2 2. 找到 Terminal(或 iTerm2),勾选允许控制 3. 手动验证:`osascript -e 'tell application "iTerm2" to activate'` -### 托盘 app 编译失败 +### 灵动岛不出现 / 点不动 ```bash -# 检查 Xcode CLT -xcode-select -p +# 1. 确认跑的是新版(可执行文件名叫 ClaudeHub) +pgrep -x ClaudeHub || open -a "Claude Hub" -# 清理后重新编译 -cd app/src-tauri && cargo clean && cargo build --release +# 2. 重新编译安装 +bash scripts/build-swift-app.sh -# 首次编译需下载约 2GB 依赖,确保网络通畅 +# 3. 确认 hook 链路通 +~/.local/bin/hubctl doctor +``` + +判断是否在运行**必须**用 `pgrep -x ClaudeHub`。用 `pgrep -f "Claude Hub"` +会匹配到任何路径里含这个串的进程,历史上仓库里同时存在过三个同名 bundle, +导致明明没在跑也被判成"已运行"。 + +### app 编译失败 + +```bash +# 需要 Xcode 26(macOS 26 SDK)——灵动岛依赖 Liquid Glass API +xcrun --show-sdk-version # 应 >= 26 +sudo xcodebuild -runFirstLaunch ``` ### 浏览器双引擎连不上 @@ -296,25 +352,28 @@ claude mcp get chrome-devtools claude-hub/ ├── CLAUDE.md # Hub 行为规范(Claude 读取此文件决定行为) ├── projects.yaml.example # 项目配置模板 -├── app/ # 通知面板(Tauri v2) -│ ├── src/ # 前端(HTML + JS) -│ └── src-tauri/ # 后端(Rust) -│ └── src/ -│ ├── main.rs # 入口 + Tauri 命令 -│ ├── watcher.rs # 文件监听(/tmp/hub-signals/events/) -│ ├── projects.rs # 项目管理 + tmux 集成 -│ └── usage.rs # 用量统计(解析 Claude 日志) +├── HubKit/ # 全部业务逻辑(SwiftPM package,可 swift test) +│ ├── NOTES.md # 实测踩到的坑(值得先读一遍) +│ └── Sources/ +│ ├── HubCore/ # 数据模型(AgentSession 等) +│ ├── HubProbe/ # 会话探测:sessions/*.json + tmux + 进程树 +│ ├── HubJump/ # 跳转引擎:iTerm jobPid 祖先链 +│ ├── HubIPC/ # Unix socket + hook 协议 + 风险分级 +│ ├── HubProjects/ # projects.yaml + git 状态 + 终端派发 +│ ├── HubUI/ # 灵动岛 + 主窗口(SwiftUI) +│ ├── hubctl/ # hook 可执行文件 +│ └── ClaudeHubApp/ # 薄壳:@main + 窗口装配 +├── Resources/AppIcon.icns ├── scripts/ -│ ├── setup.sh # 一键初始化(hooks + 语音 + 编译) +│ ├── setup.sh # 一键初始化(hook + 编译安装) +│ ├── setup-swift-hooks.sh # 装 hubctl 并改写 ~/.claude/settings.json +│ ├── build-swift-app.sh # 编译 + 签名 + 安装到 /Applications │ ├── setup-browser.sh # 浏览器环境配置 -│ ├── build-app.sh # 编译托盘 app(检查环境 + release 构建) -│ ├── cc.sh # 一键启动(托盘 app + 调度中心) +│ ├── cc.sh # 一键启动(app + 调度中心) │ ├── scan-projects.sh # 扫描目录发现项目 │ ├── add-project.sh # 添加项目 │ ├── welcome.sh # 项目列表展示 │ ├── project-menu.sh # tmux 窗口菜单 -│ ├── hub-hook-stop.sh # Stop hook(做完通知) -│ ├── hub-hook-permission.sh # Notification hook(授权通知) │ ├── hub-run.sh # 命令包装(带信号) │ └── hub-notify.sh # 手动通知 ├── skills/ diff --git a/app/src-tauri/icons/icon.icns b/Resources/AppIcon.icns similarity index 100% rename from app/src-tauri/icons/icon.icns rename to Resources/AppIcon.icns diff --git a/app/package.json b/app/package.json deleted file mode 100644 index 1bc887e..0000000 --- a/app/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "claude-hub-app", - "version": "0.1.0", - "description": "Claude Hub 通知面板", - "scripts": { - "tauri": "cargo tauri", - "dev": "cargo tauri dev", - "build": "cargo tauri build" - } -} diff --git a/app/src-tauri/.cargo/config.toml b/app/src-tauri/.cargo/config.toml deleted file mode 100644 index c69c0da..0000000 --- a/app/src-tauri/.cargo/config.toml +++ /dev/null @@ -1,8 +0,0 @@ -# 隐私保护:把编译时的本地绝对路径重映射成中性路径, -# 避免 Rust panic 信息把开发者用户名 / 源码目录暴露进 release 二进制。 -[build] -rustflags = [ - "--remap-path-prefix=/Users/dev/Documents/code/claude-hub=/build/claude-hub", - "--remap-path-prefix=/Users/dev/.cargo=/build/cargo", - "--remap-path-prefix=/Users/dev/.rustup=/build/rustup", -] diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock deleted file mode 100644 index 0cd8292..0000000 --- a/app/src-tauri/Cargo.lock +++ /dev/null @@ -1,5645 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "alloc-no-stdlib" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" - -[[package]] -name = "alloc-stdlib" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" -dependencies = [ - "alloc-no-stdlib", -] - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-executor" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-lock" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener", - "futures-lite", - "rustix", -] - -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "async-signal" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "atk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" -dependencies = [ - "atk-sys", - "glib", - "libc", -] - -[[package]] -name = "atk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" -dependencies = [ - "serde_core", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - -[[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - -[[package]] -name = "brotli" -version = "8.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", -] - -[[package]] -name = "brotli-decompressor" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", -] - -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -dependencies = [ - "serde", -] - -[[package]] -name = "cairo-rs" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" -dependencies = [ - "bitflags 2.11.0", - "cairo-sys-rs", - "glib", - "libc", - "once_cell", - "thiserror 1.0.69", -] - -[[package]] -name = "cairo-sys-rs" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - -[[package]] -name = "camino" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" -dependencies = [ - "serde_core", -] - -[[package]] -name = "cargo-platform" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" -dependencies = [ - "serde", -] - -[[package]] -name = "cargo_metadata" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" -dependencies = [ - "camino", - "cargo-platform", - "semver", - "serde", - "serde_json", - "thiserror 2.0.18", -] - -[[package]] -name = "cargo_toml" -version = "0.22.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" -dependencies = [ - "serde", - "toml 0.9.12+spec-1.1.0", -] - -[[package]] -name = "cc" -version = "1.2.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - -[[package]] -name = "cfb" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" -dependencies = [ - "byteorder", - "fnv", - "uuid", -] - -[[package]] -name = "cfg-expr" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" -dependencies = [ - "smallvec", - "target-lexicon", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link 0.2.1", -] - -[[package]] -name = "claude-hub" -version = "0.1.0" -dependencies = [ - "chrono", - "notify", - "serde", - "serde_json", - "tauri", - "tauri-build", - "tauri-plugin-notification", -] - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "time", - "version_check", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "core-graphics" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" -dependencies = [ - "bitflags 2.11.0", - "core-foundation", - "core-graphics-types", - "foreign-types", - "libc", -] - -[[package]] -name = "core-graphics-types" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" -dependencies = [ - "bitflags 2.11.0", - "core-foundation", - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "cssparser" -version = "0.29.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "matches", - "phf 0.10.1", - "proc-macro2", - "quote", - "smallvec", - "syn 1.0.109", -] - -[[package]] -name = "cssparser" -version = "0.36.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "phf 0.13.1", - "smallvec", -] - -[[package]] -name = "cssparser-macros" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" -dependencies = [ - "quote", - "syn 2.0.117", -] - -[[package]] -name = "ctor" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" -dependencies = [ - "quote", - "syn 2.0.117", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", - "serde_core", -] - -[[package]] -name = "derive_more" -version = "0.99.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.117", -] - -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.117", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags 2.11.0", - "block2", - "libc", - "objc2", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "dlopen2" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" -dependencies = [ - "dlopen2_derive", - "libc", - "once_cell", - "winapi", -] - -[[package]] -name = "dlopen2_derive" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "dom_query" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d9c2e7f1d22d0f2ce07626d259b8a55f4a47cb0938d4006dd8ae037f17d585e" -dependencies = [ - "bit-set", - "cssparser 0.36.0", - "foldhash 0.2.0", - "html5ever 0.36.1", - "precomputed-hash", - "selectors 0.35.0", - "tendril", -] - -[[package]] -name = "dpi" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" -dependencies = [ - "serde", -] - -[[package]] -name = "dtoa" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" - -[[package]] -name = "dtoa-short" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" -dependencies = [ - "dtoa", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "embed-resource" -version = "3.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47ec73ddcf6b7f23173d5c3c5a32b5507dc0a734de7730aa14abc5d5e296bb5f" -dependencies = [ - "cc", - "memchr", - "rustc_version", - "toml 0.9.12+spec-1.1.0", - "vswhom", - "winreg", -] - -[[package]] -name = "embed_plist" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" - -[[package]] -name = "endi" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" - -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "erased-serde" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" -dependencies = [ - "serde", - "serde_core", - "typeid", -] - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "field-offset" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" -dependencies = [ - "memoffset", - "rustc_version", -] - -[[package]] -name = "filetime" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" -dependencies = [ - "cfg-if", - "libc", - "libredox", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "foreign-types" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" -dependencies = [ - "foreign-types-macros", - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-macros" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "foreign-types-shared" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fsevent-sys" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" -dependencies = [ - "libc", -] - -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - -[[package]] -name = "gdk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" -dependencies = [ - "cairo-rs", - "gdk-pixbuf", - "gdk-sys", - "gio", - "glib", - "libc", - "pango", -] - -[[package]] -name = "gdk-pixbuf" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" -dependencies = [ - "gdk-pixbuf-sys", - "gio", - "glib", - "libc", - "once_cell", -] - -[[package]] -name = "gdk-pixbuf-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gdk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" -dependencies = [ - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "pkg-config", - "system-deps", -] - -[[package]] -name = "gdkwayland-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" -dependencies = [ - "gdk-sys", - "glib-sys", - "gobject-sys", - "libc", - "pkg-config", - "system-deps", -] - -[[package]] -name = "gdkx11" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" -dependencies = [ - "gdk", - "gdkx11-sys", - "gio", - "glib", - "libc", - "x11", -] - -[[package]] -name = "gdkx11-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" -dependencies = [ - "gdk-sys", - "glib-sys", - "libc", - "system-deps", - "x11", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", -] - -[[package]] -name = "gio" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "gio-sys", - "glib", - "libc", - "once_cell", - "pin-project-lite", - "smallvec", - "thiserror 1.0.69", -] - -[[package]] -name = "gio-sys" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", - "winapi", -] - -[[package]] -name = "glib" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" -dependencies = [ - "bitflags 2.11.0", - "futures-channel", - "futures-core", - "futures-executor", - "futures-task", - "futures-util", - "gio-sys", - "glib-macros", - "glib-sys", - "gobject-sys", - "libc", - "memchr", - "once_cell", - "smallvec", - "thiserror 1.0.69", -] - -[[package]] -name = "glib-macros" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" -dependencies = [ - "heck 0.4.1", - "proc-macro-crate 2.0.2", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "glib-sys" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" -dependencies = [ - "libc", - "system-deps", -] - -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - -[[package]] -name = "gobject-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gtk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" -dependencies = [ - "atk", - "cairo-rs", - "field-offset", - "futures-channel", - "gdk", - "gdk-pixbuf", - "gio", - "glib", - "gtk-sys", - "gtk3-macros", - "libc", - "pango", - "pkg-config", -] - -[[package]] -name = "gtk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" -dependencies = [ - "atk-sys", - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "system-deps", -] - -[[package]] -name = "gtk3-macros" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" -dependencies = [ - "proc-macro-crate 1.3.1", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "html5ever" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" -dependencies = [ - "log", - "mac", - "markup5ever 0.14.1", - "match_token", -] - -[[package]] -name = "html5ever" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6452c4751a24e1b99c3260d505eaeee76a050573e61f30ac2c924ddc7236f01e" -dependencies = [ - "log", - "markup5ever 0.36.1", -] - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "hyper" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core 0.62.2", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "ico" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" -dependencies = [ - "byteorder", - "png 0.17.16", -] - -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "image" -version = "0.25.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" -dependencies = [ - "bytemuck", - "byteorder-lite", - "moxcms", - "num-traits", - "png 0.18.1", -] - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - -[[package]] -name = "indexmap" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" -dependencies = [ - "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", -] - -[[package]] -name = "infer" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" -dependencies = [ - "cfb", -] - -[[package]] -name = "inotify" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" -dependencies = [ - "bitflags 1.3.2", - "inotify-sys", - "libc", -] - -[[package]] -name = "inotify-sys" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" -dependencies = [ - "libc", -] - -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "iri-string" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "javascriptcore-rs" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" -dependencies = [ - "bitflags 1.3.2", - "glib", - "javascriptcore-rs-sys", -] - -[[package]] -name = "javascriptcore-rs-sys" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni-sys" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" - -[[package]] -name = "js-sys" -version = "0.3.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "json-patch" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" -dependencies = [ - "jsonptr", - "serde", - "serde_json", - "thiserror 1.0.69", -] - -[[package]] -name = "jsonptr" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "keyboard-types" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" -dependencies = [ - "bitflags 2.11.0", - "serde", - "unicode-segmentation", -] - -[[package]] -name = "kqueue" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" -dependencies = [ - "kqueue-sys", - "libc", -] - -[[package]] -name = "kqueue-sys" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" -dependencies = [ - "bitflags 1.3.2", - "libc", -] - -[[package]] -name = "kuchikiki" -version = "0.8.8-speedreader" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" -dependencies = [ - "cssparser 0.29.6", - "html5ever 0.29.1", - "indexmap 2.13.0", - "selectors 0.24.0", -] - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libappindicator" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" -dependencies = [ - "glib", - "gtk", - "gtk-sys", - "libappindicator-sys", - "log", -] - -[[package]] -name = "libappindicator-sys" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" -dependencies = [ - "gtk-sys", - "libloading", - "once_cell", -] - -[[package]] -name = "libc" -version = "0.2.183" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" - -[[package]] -name = "libloading" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" -dependencies = [ - "cfg-if", - "winapi", -] - -[[package]] -name = "libredox" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" -dependencies = [ - "bitflags 2.11.0", - "libc", - "plain", - "redox_syscall 0.7.3", -] - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - -[[package]] -name = "mac-notification-sys" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" -dependencies = [ - "cc", - "objc2", - "objc2-foundation", - "time", -] - -[[package]] -name = "markup5ever" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" -dependencies = [ - "log", - "phf 0.11.3", - "phf_codegen 0.11.3", - "string_cache 0.8.9", - "string_cache_codegen 0.5.4", - "tendril", -] - -[[package]] -name = "markup5ever" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c3294c4d74d0742910f8c7b466f44dda9eb2d5742c1e430138df290a1e8451c" -dependencies = [ - "log", - "tendril", - "web_atoms", -] - -[[package]] -name = "match_token" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" -dependencies = [ - "libc", - "log", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.61.2", -] - -[[package]] -name = "moxcms" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" -dependencies = [ - "num-traits", - "pxfm", -] - -[[package]] -name = "muda" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" -dependencies = [ - "crossbeam-channel", - "dpi", - "gtk", - "keyboard-types", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "once_cell", - "png 0.17.16", - "serde", - "thiserror 2.0.18", - "windows-sys 0.60.2", -] - -[[package]] -name = "ndk" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" -dependencies = [ - "bitflags 2.11.0", - "jni-sys", - "log", - "ndk-sys", - "num_enum", - "raw-window-handle", - "thiserror 1.0.69", -] - -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - -[[package]] -name = "ndk-sys" -version = "0.6.0+11769913" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" -dependencies = [ - "jni-sys", -] - -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - -[[package]] -name = "nodrop" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" - -[[package]] -name = "notify" -version = "7.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" -dependencies = [ - "bitflags 2.11.0", - "filetime", - "fsevent-sys", - "inotify", - "kqueue", - "libc", - "log", - "mio", - "notify-types", - "walkdir", - "windows-sys 0.52.0", -] - -[[package]] -name = "notify-rust" -version = "4.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2" -dependencies = [ - "futures-lite", - "log", - "mac-notification-sys", - "serde", - "tauri-winrt-notification", - "zbus", -] - -[[package]] -name = "notify-types" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" -dependencies = [ - "instant", -] - -[[package]] -name = "num-conv" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_enum" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" -dependencies = [ - "num_enum_derive", - "rustversion", -] - -[[package]] -name = "num_enum_derive" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", - "objc2-exception-helper", -] - -[[package]] -name = "objc2-app-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "bitflags 2.11.0", - "block2", - "objc2", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags 2.11.0", - "dispatch2", - "objc2", -] - -[[package]] -name = "objc2-core-graphics" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" -dependencies = [ - "bitflags 2.11.0", - "dispatch2", - "objc2", - "objc2-core-foundation", - "objc2-io-surface", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-exception-helper" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" -dependencies = [ - "cc", -] - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags 2.11.0", - "block2", - "libc", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-io-surface" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" -dependencies = [ - "bitflags 2.11.0", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" -dependencies = [ - "bitflags 2.11.0", - "objc2", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "objc2-ui-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" -dependencies = [ - "bitflags 2.11.0", - "objc2", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "objc2-web-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" -dependencies = [ - "bitflags 2.11.0", - "block2", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "ordered-stream" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "pango" -version = "0.18.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" -dependencies = [ - "gio", - "glib", - "libc", - "once_cell", - "pango-sys", -] - -[[package]] -name = "pango-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link 0.2.1", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "phf" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" -dependencies = [ - "phf_shared 0.8.0", -] - -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_macros 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros 0.11.3", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" -dependencies = [ - "phf_macros 0.13.1", - "phf_shared 0.13.1", - "serde", -] - -[[package]] -name = "phf_codegen" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" -dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf_codegen" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" -dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_generator" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" -dependencies = [ - "phf_shared 0.8.0", - "rand 0.7.3", -] - -[[package]] -name = "phf_generator" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" -dependencies = [ - "phf_shared 0.10.0", - "rand 0.8.5", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.5", -] - -[[package]] -name = "phf_generator" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" -dependencies = [ - "fastrand", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_macros" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" -dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "phf_macros" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" -dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "phf_shared" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher 1.0.2", -] - -[[package]] -name = "phf_shared" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" -dependencies = [ - "siphasher 1.0.2", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "piper" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - -[[package]] -name = "plist" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" -dependencies = [ - "base64 0.22.1", - "indexmap 2.13.0", - "quick-xml 0.38.4", - "serde", - "time", -] - -[[package]] -name = "png" -version = "0.17.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" -dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "png" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" -dependencies = [ - "bitflags 2.11.0", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - -[[package]] -name = "proc-macro-crate" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" -dependencies = [ - "once_cell", - "toml_edit 0.19.15", -] - -[[package]] -name = "proc-macro-crate" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" -dependencies = [ - "toml_datetime 0.6.3", - "toml_edit 0.20.2", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit 0.25.4+spec-1.1.0", -] - -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - -[[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "pxfm" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" - -[[package]] -name = "quick-xml" -version = "0.37.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" -dependencies = [ - "memchr", -] - -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", - "rand_pcg", -] - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" -dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "rand_pcg" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "raw-window-handle" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "redox_syscall" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", -] - -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - -[[package]] -name = "reqwest" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "serde", - "serde_json", - "sync_wrapper", - "tokio", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", -] - -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags 2.11.0", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schemars" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" -dependencies = [ - "dyn-clone", - "indexmap 1.9.3", - "schemars_derive", - "serde", - "serde_json", - "url", - "uuid", -] - -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars_derive" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn 2.0.117", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "selectors" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" -dependencies = [ - "bitflags 1.3.2", - "cssparser 0.29.6", - "derive_more 0.99.20", - "fxhash", - "log", - "phf 0.8.0", - "phf_codegen 0.8.0", - "precomputed-hash", - "servo_arc 0.2.0", - "smallvec", -] - -[[package]] -name = "selectors" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fdfed56cd634f04fe8b9ddf947ae3dc493483e819593d2ba17df9ad05db8b2" -dependencies = [ - "bitflags 2.11.0", - "cssparser 0.36.0", - "derive_more 2.1.1", - "log", - "new_debug_unreachable", - "phf 0.13.1", - "phf_codegen 0.13.1", - "precomputed-hash", - "rustc-hash", - "servo_arc 0.4.3", - "smallvec", -] - -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde-untagged" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" -dependencies = [ - "erased-serde", - "serde", - "serde_core", - "typeid", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "serde_derive_internals" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - -[[package]] -name = "serde_spanned" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_with" -version = "3.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" -dependencies = [ - "base64 0.22.1", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.13.0", - "schemars 0.9.0", - "schemars 1.2.1", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "serialize-to-javascript" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" -dependencies = [ - "serde", - "serde_json", - "serialize-to-javascript-impl", -] - -[[package]] -name = "serialize-to-javascript-impl" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "servo_arc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" -dependencies = [ - "nodrop", - "stable_deref_trait", -] - -[[package]] -name = "servo_arc" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" -dependencies = [ - "stable_deref_trait", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "simd-adler32" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" - -[[package]] -name = "siphasher" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" - -[[package]] -name = "siphasher" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "softbuffer" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" -dependencies = [ - "bytemuck", - "js-sys", - "ndk", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation", - "objc2-quartz-core", - "raw-window-handle", - "redox_syscall 0.5.18", - "tracing", - "wasm-bindgen", - "web-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "soup3" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" -dependencies = [ - "futures-channel", - "gio", - "glib", - "libc", - "soup3-sys", -] - -[[package]] -name = "soup3-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared 0.11.3", - "precomputed-hash", - "serde", -] - -[[package]] -name = "string_cache" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared 0.13.1", - "precomputed-hash", -] - -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", -] - -[[package]] -name = "string_cache_codegen" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" -dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", - "proc-macro2", - "quote", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "swift-rs" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" -dependencies = [ - "base64 0.21.7", - "serde", - "serde_json", -] - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "system-deps" -version = "6.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" -dependencies = [ - "cfg-expr", - "heck 0.5.0", - "pkg-config", - "toml 0.8.2", - "version-compare", -] - -[[package]] -name = "tao" -version = "0.34.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d52c379e63da659a483a958110bbde891695a0ecb53e48cc7786d5eda7bb" -dependencies = [ - "bitflags 2.11.0", - "block2", - "core-foundation", - "core-graphics", - "crossbeam-channel", - "dispatch2", - "dlopen2", - "dpi", - "gdkwayland-sys", - "gdkx11-sys", - "gtk", - "jni", - "libc", - "log", - "ndk", - "ndk-context", - "ndk-sys", - "objc2", - "objc2-app-kit", - "objc2-foundation", - "once_cell", - "parking_lot", - "raw-window-handle", - "tao-macros", - "unicode-segmentation", - "url", - "windows", - "windows-core 0.61.2", - "windows-version", - "x11-dl", -] - -[[package]] -name = "tao-macros" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "target-lexicon" -version = "0.12.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" - -[[package]] -name = "tauri" -version = "2.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" -dependencies = [ - "anyhow", - "bytes", - "cookie", - "dirs", - "dunce", - "embed_plist", - "getrandom 0.3.4", - "glob", - "gtk", - "heck 0.5.0", - "http", - "image", - "jni", - "libc", - "log", - "mime", - "muda", - "objc2", - "objc2-app-kit", - "objc2-foundation", - "objc2-ui-kit", - "objc2-web-kit", - "percent-encoding", - "plist", - "raw-window-handle", - "reqwest", - "serde", - "serde_json", - "serde_repr", - "serialize-to-javascript", - "swift-rs", - "tauri-build", - "tauri-macros", - "tauri-runtime", - "tauri-runtime-wry", - "tauri-utils", - "thiserror 2.0.18", - "tokio", - "tray-icon", - "url", - "webkit2gtk", - "webview2-com", - "window-vibrancy", - "windows", -] - -[[package]] -name = "tauri-build" -version = "2.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" -dependencies = [ - "anyhow", - "cargo_toml", - "dirs", - "glob", - "heck 0.5.0", - "json-patch", - "schemars 0.8.22", - "semver", - "serde", - "serde_json", - "tauri-utils", - "tauri-winres", - "toml 0.9.12+spec-1.1.0", - "walkdir", -] - -[[package]] -name = "tauri-codegen" -version = "2.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" -dependencies = [ - "base64 0.22.1", - "brotli", - "ico", - "json-patch", - "plist", - "png 0.17.16", - "proc-macro2", - "quote", - "semver", - "serde", - "serde_json", - "sha2", - "syn 2.0.117", - "tauri-utils", - "thiserror 2.0.18", - "time", - "url", - "uuid", - "walkdir", -] - -[[package]] -name = "tauri-macros" -version = "2.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", - "tauri-codegen", - "tauri-utils", -] - -[[package]] -name = "tauri-plugin" -version = "2.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa" -dependencies = [ - "anyhow", - "glob", - "plist", - "schemars 0.8.22", - "serde", - "serde_json", - "tauri-utils", - "toml 0.9.12+spec-1.1.0", - "walkdir", -] - -[[package]] -name = "tauri-plugin-notification" -version = "2.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" -dependencies = [ - "log", - "notify-rust", - "rand 0.9.2", - "serde", - "serde_json", - "serde_repr", - "tauri", - "tauri-plugin", - "thiserror 2.0.18", - "time", - "url", -] - -[[package]] -name = "tauri-runtime" -version = "2.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" -dependencies = [ - "cookie", - "dpi", - "gtk", - "http", - "jni", - "objc2", - "objc2-ui-kit", - "objc2-web-kit", - "raw-window-handle", - "serde", - "serde_json", - "tauri-utils", - "thiserror 2.0.18", - "url", - "webkit2gtk", - "webview2-com", - "windows", -] - -[[package]] -name = "tauri-runtime-wry" -version = "2.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" -dependencies = [ - "gtk", - "http", - "jni", - "log", - "objc2", - "objc2-app-kit", - "once_cell", - "percent-encoding", - "raw-window-handle", - "softbuffer", - "tao", - "tauri-runtime", - "tauri-utils", - "url", - "webkit2gtk", - "webview2-com", - "windows", - "wry", -] - -[[package]] -name = "tauri-utils" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" -dependencies = [ - "anyhow", - "brotli", - "cargo_metadata", - "ctor", - "dunce", - "glob", - "html5ever 0.29.1", - "http", - "infer", - "json-patch", - "kuchikiki", - "log", - "memchr", - "phf 0.11.3", - "proc-macro2", - "quote", - "regex", - "schemars 0.8.22", - "semver", - "serde", - "serde-untagged", - "serde_json", - "serde_with", - "swift-rs", - "thiserror 2.0.18", - "toml 0.9.12+spec-1.1.0", - "url", - "urlpattern", - "uuid", - "walkdir", -] - -[[package]] -name = "tauri-winres" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" -dependencies = [ - "dunce", - "embed-resource", - "toml 0.9.12+spec-1.1.0", -] - -[[package]] -name = "tauri-winrt-notification" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" -dependencies = [ - "quick-xml 0.37.5", - "thiserror 2.0.18", - "windows", - "windows-version", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tokio" -version = "1.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "toml" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.3", - "toml_edit 0.20.2", -] - -[[package]] -name = "toml" -version = "0.9.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "indexmap 2.13.0", - "serde_core", - "serde_spanned 1.0.4", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.15", -] - -[[package]] -name = "toml_datetime" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_datetime" -version = "1.0.0+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.19.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" -dependencies = [ - "indexmap 2.13.0", - "toml_datetime 0.6.3", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" -dependencies = [ - "indexmap 2.13.0", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.3", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.25.4+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" -dependencies = [ - "indexmap 2.13.0", - "toml_datetime 1.0.0+spec-1.1.0", - "toml_parser", - "winnow 0.7.15", -] - -[[package]] -name = "toml_parser" -version = "1.0.9+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" -dependencies = [ - "winnow 0.7.15", -] - -[[package]] -name = "toml_writer" -version = "1.0.6+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" -dependencies = [ - "bitflags 2.11.0", - "bytes", - "futures-util", - "http", - "http-body", - "iri-string", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "tray-icon" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" -dependencies = [ - "crossbeam-channel", - "dirs", - "libappindicator", - "muda", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation", - "once_cell", - "png 0.17.16", - "serde", - "thiserror 2.0.18", - "windows-sys 0.60.2", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "uds_windows" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" -dependencies = [ - "memoffset", - "tempfile", - "windows-sys 0.61.2", -] - -[[package]] -name = "unic-char-property" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" -dependencies = [ - "unic-char-range", -] - -[[package]] -name = "unic-char-range" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" - -[[package]] -name = "unic-common" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" - -[[package]] -name = "unic-ucd-ident" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] - -[[package]] -name = "unic-ucd-version" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" -dependencies = [ - "unic-common", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-segmentation" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", - "serde_derive", -] - -[[package]] -name = "urlpattern" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" -dependencies = [ - "regex", - "serde", - "unic-ucd-ident", - "url", -] - -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" -dependencies = [ - "getrandom 0.4.2", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "version-compare" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vswhom" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" -dependencies = [ - "libc", - "vswhom-sys", -] - -[[package]] -name = "vswhom-sys" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" -dependencies = [ - "cc", - "libc", -] - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" -dependencies = [ - "cfg-if", - "futures-util", - "js-sys", - "once_cell", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.117", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.13.0", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasm-streams" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.0", - "hashbrown 0.15.5", - "indexmap 2.13.0", - "semver", -] - -[[package]] -name = "web-sys" -version = "0.3.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web_atoms" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" -dependencies = [ - "phf 0.13.1", - "phf_codegen 0.13.1", - "string_cache 0.9.0", - "string_cache_codegen 0.6.1", -] - -[[package]] -name = "webkit2gtk" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" -dependencies = [ - "bitflags 1.3.2", - "cairo-rs", - "gdk", - "gdk-sys", - "gio", - "gio-sys", - "glib", - "glib-sys", - "gobject-sys", - "gtk", - "gtk-sys", - "javascriptcore-rs", - "libc", - "once_cell", - "soup3", - "webkit2gtk-sys", -] - -[[package]] -name = "webkit2gtk-sys" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" -dependencies = [ - "bitflags 1.3.2", - "cairo-sys-rs", - "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "gtk-sys", - "javascriptcore-rs-sys", - "libc", - "pkg-config", - "soup3-sys", - "system-deps", -] - -[[package]] -name = "webview2-com" -version = "0.38.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" -dependencies = [ - "webview2-com-macros", - "webview2-com-sys", - "windows", - "windows-core 0.61.2", - "windows-implement", - "windows-interface", -] - -[[package]] -name = "webview2-com-macros" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "webview2-com-sys" -version = "0.38.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" -dependencies = [ - "thiserror 2.0.18", - "windows", - "windows-core 0.61.2", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "window-vibrancy" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" -dependencies = [ - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "raw-window-handle", - "windows-sys 0.59.0", - "windows-version", -] - -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections", - "windows-core 0.61.2", - "windows-future", - "windows-link 0.1.3", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core 0.61.2", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link 0.2.1", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows-threading" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-version" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "winnow" -version = "0.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" -dependencies = [ - "memchr", -] - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - -[[package]] -name = "winreg" -version = "0.55.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" -dependencies = [ - "cfg-if", - "windows-sys 0.59.0", -] - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap 2.13.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.0", - "indexmap 2.13.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.13.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "wry" -version = "0.54.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a24eda84b5d488f99344e54b807138896cee8df0b2d16c793f1f6b80e6d8df1f" -dependencies = [ - "base64 0.22.1", - "block2", - "cookie", - "crossbeam-channel", - "dirs", - "dom_query", - "dpi", - "dunce", - "gdkx11", - "gtk", - "http", - "javascriptcore-rs", - "jni", - "libc", - "ndk", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "objc2-ui-kit", - "objc2-web-kit", - "once_cell", - "percent-encoding", - "raw-window-handle", - "sha2", - "soup3", - "tao-macros", - "thiserror 2.0.18", - "url", - "webkit2gtk", - "webkit2gtk-sys", - "webview2-com", - "windows", - "windows-core 0.61.2", - "windows-version", - "x11-dl", -] - -[[package]] -name = "x11" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" -dependencies = [ - "libc", - "pkg-config", -] - -[[package]] -name = "x11-dl" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" -dependencies = [ - "libc", - "once_cell", - "pkg-config", -] - -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "zbus" -version = "5.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" -dependencies = [ - "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener", - "futures-core", - "futures-lite", - "hex", - "libc", - "ordered-stream", - "rustix", - "serde", - "serde_repr", - "tracing", - "uds_windows", - "uuid", - "windows-sys 0.61.2", - "winnow 0.7.15", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus_macros" -version = "5.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", - "zbus_names", - "zvariant", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "4.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" -dependencies = [ - "serde", - "winnow 0.7.15", - "zvariant", -] - -[[package]] -name = "zerocopy" -version = "0.8.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zvariant" -version = "5.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" -dependencies = [ - "endi", - "enumflags2", - "serde", - "winnow 0.7.15", - "zvariant_derive", - "zvariant_utils", -] - -[[package]] -name = "zvariant_derive" -version = "5.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn 2.0.117", - "winnow 0.7.15", -] diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml deleted file mode 100644 index ab76b66..0000000 --- a/app/src-tauri/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "claude-hub" -version = "0.1.0" -description = "Claude Hub - 本地开发调度中心通知面板" -authors = ["hengjun-dev"] -edition = "2021" - -[build-dependencies] -tauri-build = { version = "2", features = [] } - -[dependencies] -tauri = { version = "2", features = ["tray-icon", "image-png"] } -tauri-plugin-notification = "2" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -notify = "7" -chrono = "0.4" - -# release 模式精简二进制 + 去除调试符号,配合 .cargo/config.toml 的路径重映射保护用户名隐私 -[profile.release] -strip = "symbols" -opt-level = 3 -codegen-units = 1 diff --git a/app/src-tauri/build.rs b/app/src-tauri/build.rs deleted file mode 100644 index d860e1e..0000000 --- a/app/src-tauri/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - tauri_build::build() -} diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json deleted file mode 100644 index 0551b73..0000000 --- a/app/src-tauri/capabilities/default.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "identifier": "default", - "description": "Claude Hub default capabilities", - "windows": ["main"], - "permissions": [ - "core:default", - "core:window:default", - "core:window:allow-show", - "core:window:allow-set-focus", - "notification:default", - "notification:allow-notify", - "notification:allow-is-permission-granted", - "notification:allow-request-permission" - ] -} diff --git a/app/src-tauri/gen/schemas/acl-manifests.json b/app/src-tauri/gen/schemas/acl-manifests.json deleted file mode 100644 index 31bddbf..0000000 --- a/app/src-tauri/gen/schemas/acl-manifests.json +++ /dev/null @@ -1 +0,0 @@ -{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"notification":{"default_permission":{"identifier":"default","description":"This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n","permissions":["allow-is-permission-granted","allow-request-permission","allow-notify","allow-register-action-types","allow-register-listener","allow-cancel","allow-get-pending","allow-remove-active","allow-get-active","allow-check-permissions","allow-show","allow-batch","allow-list-channels","allow-delete-channel","allow-create-channel","allow-permission-state"]},"permissions":{"allow-batch":{"identifier":"allow-batch","description":"Enables the batch command without any pre-configured scope.","commands":{"allow":["batch"],"deny":[]}},"allow-cancel":{"identifier":"allow-cancel","description":"Enables the cancel command without any pre-configured scope.","commands":{"allow":["cancel"],"deny":[]}},"allow-check-permissions":{"identifier":"allow-check-permissions","description":"Enables the check_permissions command without any pre-configured scope.","commands":{"allow":["check_permissions"],"deny":[]}},"allow-create-channel":{"identifier":"allow-create-channel","description":"Enables the create_channel command without any pre-configured scope.","commands":{"allow":["create_channel"],"deny":[]}},"allow-delete-channel":{"identifier":"allow-delete-channel","description":"Enables the delete_channel command without any pre-configured scope.","commands":{"allow":["delete_channel"],"deny":[]}},"allow-get-active":{"identifier":"allow-get-active","description":"Enables the get_active command without any pre-configured scope.","commands":{"allow":["get_active"],"deny":[]}},"allow-get-pending":{"identifier":"allow-get-pending","description":"Enables the get_pending command without any pre-configured scope.","commands":{"allow":["get_pending"],"deny":[]}},"allow-is-permission-granted":{"identifier":"allow-is-permission-granted","description":"Enables the is_permission_granted command without any pre-configured scope.","commands":{"allow":["is_permission_granted"],"deny":[]}},"allow-list-channels":{"identifier":"allow-list-channels","description":"Enables the list_channels command without any pre-configured scope.","commands":{"allow":["list_channels"],"deny":[]}},"allow-notify":{"identifier":"allow-notify","description":"Enables the notify command without any pre-configured scope.","commands":{"allow":["notify"],"deny":[]}},"allow-permission-state":{"identifier":"allow-permission-state","description":"Enables the permission_state command without any pre-configured scope.","commands":{"allow":["permission_state"],"deny":[]}},"allow-register-action-types":{"identifier":"allow-register-action-types","description":"Enables the register_action_types command without any pre-configured scope.","commands":{"allow":["register_action_types"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-active":{"identifier":"allow-remove-active","description":"Enables the remove_active command without any pre-configured scope.","commands":{"allow":["remove_active"],"deny":[]}},"allow-request-permission":{"identifier":"allow-request-permission","description":"Enables the request_permission command without any pre-configured scope.","commands":{"allow":["request_permission"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"deny-batch":{"identifier":"deny-batch","description":"Denies the batch command without any pre-configured scope.","commands":{"allow":[],"deny":["batch"]}},"deny-cancel":{"identifier":"deny-cancel","description":"Denies the cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel"]}},"deny-check-permissions":{"identifier":"deny-check-permissions","description":"Denies the check_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_permissions"]}},"deny-create-channel":{"identifier":"deny-create-channel","description":"Denies the create_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["create_channel"]}},"deny-delete-channel":{"identifier":"deny-delete-channel","description":"Denies the delete_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_channel"]}},"deny-get-active":{"identifier":"deny-get-active","description":"Denies the get_active command without any pre-configured scope.","commands":{"allow":[],"deny":["get_active"]}},"deny-get-pending":{"identifier":"deny-get-pending","description":"Denies the get_pending command without any pre-configured scope.","commands":{"allow":[],"deny":["get_pending"]}},"deny-is-permission-granted":{"identifier":"deny-is-permission-granted","description":"Denies the is_permission_granted command without any pre-configured scope.","commands":{"allow":[],"deny":["is_permission_granted"]}},"deny-list-channels":{"identifier":"deny-list-channels","description":"Denies the list_channels command without any pre-configured scope.","commands":{"allow":[],"deny":["list_channels"]}},"deny-notify":{"identifier":"deny-notify","description":"Denies the notify command without any pre-configured scope.","commands":{"allow":[],"deny":["notify"]}},"deny-permission-state":{"identifier":"deny-permission-state","description":"Denies the permission_state command without any pre-configured scope.","commands":{"allow":[],"deny":["permission_state"]}},"deny-register-action-types":{"identifier":"deny-register-action-types","description":"Denies the register_action_types command without any pre-configured scope.","commands":{"allow":[],"deny":["register_action_types"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-active":{"identifier":"deny-remove-active","description":"Denies the remove_active command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_active"]}},"deny-request-permission":{"identifier":"deny-request-permission","description":"Denies the request_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_permission"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/app/src-tauri/gen/schemas/capabilities.json b/app/src-tauri/gen/schemas/capabilities.json deleted file mode 100644 index 48bd4f3..0000000 --- a/app/src-tauri/gen/schemas/capabilities.json +++ /dev/null @@ -1 +0,0 @@ -{"default":{"identifier":"default","description":"Claude Hub default capabilities","local":true,"windows":["main"],"permissions":["core:default","core:window:default","core:window:allow-show","core:window:allow-set-focus","notification:default","notification:allow-notify","notification:allow-is-permission-granted","notification:allow-request-permission"]}} \ No newline at end of file diff --git a/app/src-tauri/gen/schemas/desktop-schema.json b/app/src-tauri/gen/schemas/desktop-schema.json deleted file mode 100644 index cc7a333..0000000 --- a/app/src-tauri/gen/schemas/desktop-schema.json +++ /dev/null @@ -1,2442 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "CapabilityFile", - "description": "Capability formats accepted in a capability file.", - "anyOf": [ - { - "description": "A single capability.", - "allOf": [ - { - "$ref": "#/definitions/Capability" - } - ] - }, - { - "description": "A list of capabilities.", - "type": "array", - "items": { - "$ref": "#/definitions/Capability" - } - }, - { - "description": "A list of capabilities.", - "type": "object", - "required": [ - "capabilities" - ], - "properties": { - "capabilities": { - "description": "The list of capabilities.", - "type": "array", - "items": { - "$ref": "#/definitions/Capability" - } - } - } - } - ], - "definitions": { - "Capability": { - "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", - "type": "object", - "required": [ - "identifier", - "permissions" - ], - "properties": { - "identifier": { - "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", - "type": "string" - }, - "description": { - "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", - "default": "", - "type": "string" - }, - "remote": { - "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", - "anyOf": [ - { - "$ref": "#/definitions/CapabilityRemote" - }, - { - "type": "null" - } - ] - }, - "local": { - "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", - "default": true, - "type": "boolean" - }, - "windows": { - "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", - "type": "array", - "items": { - "type": "string" - } - }, - "webviews": { - "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", - "type": "array", - "items": { - "type": "string" - } - }, - "permissions": { - "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", - "type": "array", - "items": { - "$ref": "#/definitions/PermissionEntry" - }, - "uniqueItems": true - }, - "platforms": { - "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Target" - } - } - } - }, - "CapabilityRemote": { - "description": "Configuration for remote URLs that are associated with the capability.", - "type": "object", - "required": [ - "urls" - ], - "properties": { - "urls": { - "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "PermissionEntry": { - "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", - "anyOf": [ - { - "description": "Reference a permission or permission set by identifier.", - "allOf": [ - { - "$ref": "#/definitions/Identifier" - } - ] - }, - { - "description": "Reference a permission or permission set by identifier and extends its scope.", - "type": "object", - "allOf": [ - { - "properties": { - "identifier": { - "description": "Identifier of the permission or permission set.", - "allOf": [ - { - "$ref": "#/definitions/Identifier" - } - ] - }, - "allow": { - "description": "Data that defines what is allowed by the scope.", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Value" - } - }, - "deny": { - "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Value" - } - } - } - } - ], - "required": [ - "identifier" - ] - } - ] - }, - "Identifier": { - "description": "Permission identifier", - "oneOf": [ - { - "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", - "type": "string", - "const": "core:default", - "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" - }, - { - "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`", - "type": "string", - "const": "core:app:default", - "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`" - }, - { - "description": "Enables the app_hide command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-app-hide", - "markdownDescription": "Enables the app_hide command without any pre-configured scope." - }, - { - "description": "Enables the app_show command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-app-show", - "markdownDescription": "Enables the app_show command without any pre-configured scope." - }, - { - "description": "Enables the bundle_type command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-bundle-type", - "markdownDescription": "Enables the bundle_type command without any pre-configured scope." - }, - { - "description": "Enables the default_window_icon command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-default-window-icon", - "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." - }, - { - "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-fetch-data-store-identifiers", - "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." - }, - { - "description": "Enables the identifier command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-identifier", - "markdownDescription": "Enables the identifier command without any pre-configured scope." - }, - { - "description": "Enables the name command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-name", - "markdownDescription": "Enables the name command without any pre-configured scope." - }, - { - "description": "Enables the register_listener command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-register-listener", - "markdownDescription": "Enables the register_listener command without any pre-configured scope." - }, - { - "description": "Enables the remove_data_store command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-remove-data-store", - "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." - }, - { - "description": "Enables the remove_listener command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-remove-listener", - "markdownDescription": "Enables the remove_listener command without any pre-configured scope." - }, - { - "description": "Enables the set_app_theme command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-set-app-theme", - "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." - }, - { - "description": "Enables the set_dock_visibility command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-set-dock-visibility", - "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." - }, - { - "description": "Enables the tauri_version command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-tauri-version", - "markdownDescription": "Enables the tauri_version command without any pre-configured scope." - }, - { - "description": "Enables the version command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-version", - "markdownDescription": "Enables the version command without any pre-configured scope." - }, - { - "description": "Denies the app_hide command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-app-hide", - "markdownDescription": "Denies the app_hide command without any pre-configured scope." - }, - { - "description": "Denies the app_show command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-app-show", - "markdownDescription": "Denies the app_show command without any pre-configured scope." - }, - { - "description": "Denies the bundle_type command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-bundle-type", - "markdownDescription": "Denies the bundle_type command without any pre-configured scope." - }, - { - "description": "Denies the default_window_icon command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-default-window-icon", - "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." - }, - { - "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-fetch-data-store-identifiers", - "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." - }, - { - "description": "Denies the identifier command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-identifier", - "markdownDescription": "Denies the identifier command without any pre-configured scope." - }, - { - "description": "Denies the name command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-name", - "markdownDescription": "Denies the name command without any pre-configured scope." - }, - { - "description": "Denies the register_listener command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-register-listener", - "markdownDescription": "Denies the register_listener command without any pre-configured scope." - }, - { - "description": "Denies the remove_data_store command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-remove-data-store", - "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." - }, - { - "description": "Denies the remove_listener command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-remove-listener", - "markdownDescription": "Denies the remove_listener command without any pre-configured scope." - }, - { - "description": "Denies the set_app_theme command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-set-app-theme", - "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." - }, - { - "description": "Denies the set_dock_visibility command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-set-dock-visibility", - "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." - }, - { - "description": "Denies the tauri_version command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-tauri-version", - "markdownDescription": "Denies the tauri_version command without any pre-configured scope." - }, - { - "description": "Denies the version command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-version", - "markdownDescription": "Denies the version command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", - "type": "string", - "const": "core:event:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" - }, - { - "description": "Enables the emit command without any pre-configured scope.", - "type": "string", - "const": "core:event:allow-emit", - "markdownDescription": "Enables the emit command without any pre-configured scope." - }, - { - "description": "Enables the emit_to command without any pre-configured scope.", - "type": "string", - "const": "core:event:allow-emit-to", - "markdownDescription": "Enables the emit_to command without any pre-configured scope." - }, - { - "description": "Enables the listen command without any pre-configured scope.", - "type": "string", - "const": "core:event:allow-listen", - "markdownDescription": "Enables the listen command without any pre-configured scope." - }, - { - "description": "Enables the unlisten command without any pre-configured scope.", - "type": "string", - "const": "core:event:allow-unlisten", - "markdownDescription": "Enables the unlisten command without any pre-configured scope." - }, - { - "description": "Denies the emit command without any pre-configured scope.", - "type": "string", - "const": "core:event:deny-emit", - "markdownDescription": "Denies the emit command without any pre-configured scope." - }, - { - "description": "Denies the emit_to command without any pre-configured scope.", - "type": "string", - "const": "core:event:deny-emit-to", - "markdownDescription": "Denies the emit_to command without any pre-configured scope." - }, - { - "description": "Denies the listen command without any pre-configured scope.", - "type": "string", - "const": "core:event:deny-listen", - "markdownDescription": "Denies the listen command without any pre-configured scope." - }, - { - "description": "Denies the unlisten command without any pre-configured scope.", - "type": "string", - "const": "core:event:deny-unlisten", - "markdownDescription": "Denies the unlisten command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", - "type": "string", - "const": "core:image:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" - }, - { - "description": "Enables the from_bytes command without any pre-configured scope.", - "type": "string", - "const": "core:image:allow-from-bytes", - "markdownDescription": "Enables the from_bytes command without any pre-configured scope." - }, - { - "description": "Enables the from_path command without any pre-configured scope.", - "type": "string", - "const": "core:image:allow-from-path", - "markdownDescription": "Enables the from_path command without any pre-configured scope." - }, - { - "description": "Enables the new command without any pre-configured scope.", - "type": "string", - "const": "core:image:allow-new", - "markdownDescription": "Enables the new command without any pre-configured scope." - }, - { - "description": "Enables the rgba command without any pre-configured scope.", - "type": "string", - "const": "core:image:allow-rgba", - "markdownDescription": "Enables the rgba command without any pre-configured scope." - }, - { - "description": "Enables the size command without any pre-configured scope.", - "type": "string", - "const": "core:image:allow-size", - "markdownDescription": "Enables the size command without any pre-configured scope." - }, - { - "description": "Denies the from_bytes command without any pre-configured scope.", - "type": "string", - "const": "core:image:deny-from-bytes", - "markdownDescription": "Denies the from_bytes command without any pre-configured scope." - }, - { - "description": "Denies the from_path command without any pre-configured scope.", - "type": "string", - "const": "core:image:deny-from-path", - "markdownDescription": "Denies the from_path command without any pre-configured scope." - }, - { - "description": "Denies the new command without any pre-configured scope.", - "type": "string", - "const": "core:image:deny-new", - "markdownDescription": "Denies the new command without any pre-configured scope." - }, - { - "description": "Denies the rgba command without any pre-configured scope.", - "type": "string", - "const": "core:image:deny-rgba", - "markdownDescription": "Denies the rgba command without any pre-configured scope." - }, - { - "description": "Denies the size command without any pre-configured scope.", - "type": "string", - "const": "core:image:deny-size", - "markdownDescription": "Denies the size command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", - "type": "string", - "const": "core:menu:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" - }, - { - "description": "Enables the append command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-append", - "markdownDescription": "Enables the append command without any pre-configured scope." - }, - { - "description": "Enables the create_default command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-create-default", - "markdownDescription": "Enables the create_default command without any pre-configured scope." - }, - { - "description": "Enables the get command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-get", - "markdownDescription": "Enables the get command without any pre-configured scope." - }, - { - "description": "Enables the insert command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-insert", - "markdownDescription": "Enables the insert command without any pre-configured scope." - }, - { - "description": "Enables the is_checked command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-is-checked", - "markdownDescription": "Enables the is_checked command without any pre-configured scope." - }, - { - "description": "Enables the is_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-is-enabled", - "markdownDescription": "Enables the is_enabled command without any pre-configured scope." - }, - { - "description": "Enables the items command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-items", - "markdownDescription": "Enables the items command without any pre-configured scope." - }, - { - "description": "Enables the new command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-new", - "markdownDescription": "Enables the new command without any pre-configured scope." - }, - { - "description": "Enables the popup command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-popup", - "markdownDescription": "Enables the popup command without any pre-configured scope." - }, - { - "description": "Enables the prepend command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-prepend", - "markdownDescription": "Enables the prepend command without any pre-configured scope." - }, - { - "description": "Enables the remove command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-remove", - "markdownDescription": "Enables the remove command without any pre-configured scope." - }, - { - "description": "Enables the remove_at command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-remove-at", - "markdownDescription": "Enables the remove_at command without any pre-configured scope." - }, - { - "description": "Enables the set_accelerator command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-accelerator", - "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." - }, - { - "description": "Enables the set_as_app_menu command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-as-app-menu", - "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." - }, - { - "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-as-help-menu-for-nsapp", - "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." - }, - { - "description": "Enables the set_as_window_menu command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-as-window-menu", - "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." - }, - { - "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-as-windows-menu-for-nsapp", - "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." - }, - { - "description": "Enables the set_checked command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-checked", - "markdownDescription": "Enables the set_checked command without any pre-configured scope." - }, - { - "description": "Enables the set_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-enabled", - "markdownDescription": "Enables the set_enabled command without any pre-configured scope." - }, - { - "description": "Enables the set_icon command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-icon", - "markdownDescription": "Enables the set_icon command without any pre-configured scope." - }, - { - "description": "Enables the set_text command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-text", - "markdownDescription": "Enables the set_text command without any pre-configured scope." - }, - { - "description": "Enables the text command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-text", - "markdownDescription": "Enables the text command without any pre-configured scope." - }, - { - "description": "Denies the append command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-append", - "markdownDescription": "Denies the append command without any pre-configured scope." - }, - { - "description": "Denies the create_default command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-create-default", - "markdownDescription": "Denies the create_default command without any pre-configured scope." - }, - { - "description": "Denies the get command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-get", - "markdownDescription": "Denies the get command without any pre-configured scope." - }, - { - "description": "Denies the insert command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-insert", - "markdownDescription": "Denies the insert command without any pre-configured scope." - }, - { - "description": "Denies the is_checked command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-is-checked", - "markdownDescription": "Denies the is_checked command without any pre-configured scope." - }, - { - "description": "Denies the is_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-is-enabled", - "markdownDescription": "Denies the is_enabled command without any pre-configured scope." - }, - { - "description": "Denies the items command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-items", - "markdownDescription": "Denies the items command without any pre-configured scope." - }, - { - "description": "Denies the new command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-new", - "markdownDescription": "Denies the new command without any pre-configured scope." - }, - { - "description": "Denies the popup command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-popup", - "markdownDescription": "Denies the popup command without any pre-configured scope." - }, - { - "description": "Denies the prepend command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-prepend", - "markdownDescription": "Denies the prepend command without any pre-configured scope." - }, - { - "description": "Denies the remove command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-remove", - "markdownDescription": "Denies the remove command without any pre-configured scope." - }, - { - "description": "Denies the remove_at command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-remove-at", - "markdownDescription": "Denies the remove_at command without any pre-configured scope." - }, - { - "description": "Denies the set_accelerator command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-accelerator", - "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." - }, - { - "description": "Denies the set_as_app_menu command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-as-app-menu", - "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." - }, - { - "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-as-help-menu-for-nsapp", - "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." - }, - { - "description": "Denies the set_as_window_menu command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-as-window-menu", - "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." - }, - { - "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-as-windows-menu-for-nsapp", - "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." - }, - { - "description": "Denies the set_checked command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-checked", - "markdownDescription": "Denies the set_checked command without any pre-configured scope." - }, - { - "description": "Denies the set_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-enabled", - "markdownDescription": "Denies the set_enabled command without any pre-configured scope." - }, - { - "description": "Denies the set_icon command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-icon", - "markdownDescription": "Denies the set_icon command without any pre-configured scope." - }, - { - "description": "Denies the set_text command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-text", - "markdownDescription": "Denies the set_text command without any pre-configured scope." - }, - { - "description": "Denies the text command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-text", - "markdownDescription": "Denies the text command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", - "type": "string", - "const": "core:path:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" - }, - { - "description": "Enables the basename command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-basename", - "markdownDescription": "Enables the basename command without any pre-configured scope." - }, - { - "description": "Enables the dirname command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-dirname", - "markdownDescription": "Enables the dirname command without any pre-configured scope." - }, - { - "description": "Enables the extname command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-extname", - "markdownDescription": "Enables the extname command without any pre-configured scope." - }, - { - "description": "Enables the is_absolute command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-is-absolute", - "markdownDescription": "Enables the is_absolute command without any pre-configured scope." - }, - { - "description": "Enables the join command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-join", - "markdownDescription": "Enables the join command without any pre-configured scope." - }, - { - "description": "Enables the normalize command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-normalize", - "markdownDescription": "Enables the normalize command without any pre-configured scope." - }, - { - "description": "Enables the resolve command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-resolve", - "markdownDescription": "Enables the resolve command without any pre-configured scope." - }, - { - "description": "Enables the resolve_directory command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-resolve-directory", - "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." - }, - { - "description": "Denies the basename command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-basename", - "markdownDescription": "Denies the basename command without any pre-configured scope." - }, - { - "description": "Denies the dirname command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-dirname", - "markdownDescription": "Denies the dirname command without any pre-configured scope." - }, - { - "description": "Denies the extname command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-extname", - "markdownDescription": "Denies the extname command without any pre-configured scope." - }, - { - "description": "Denies the is_absolute command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-is-absolute", - "markdownDescription": "Denies the is_absolute command without any pre-configured scope." - }, - { - "description": "Denies the join command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-join", - "markdownDescription": "Denies the join command without any pre-configured scope." - }, - { - "description": "Denies the normalize command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-normalize", - "markdownDescription": "Denies the normalize command without any pre-configured scope." - }, - { - "description": "Denies the resolve command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-resolve", - "markdownDescription": "Denies the resolve command without any pre-configured scope." - }, - { - "description": "Denies the resolve_directory command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-resolve-directory", - "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", - "type": "string", - "const": "core:resources:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" - }, - { - "description": "Enables the close command without any pre-configured scope.", - "type": "string", - "const": "core:resources:allow-close", - "markdownDescription": "Enables the close command without any pre-configured scope." - }, - { - "description": "Denies the close command without any pre-configured scope.", - "type": "string", - "const": "core:resources:deny-close", - "markdownDescription": "Denies the close command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`", - "type": "string", - "const": "core:tray:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`" - }, - { - "description": "Enables the get_by_id command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-get-by-id", - "markdownDescription": "Enables the get_by_id command without any pre-configured scope." - }, - { - "description": "Enables the new command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-new", - "markdownDescription": "Enables the new command without any pre-configured scope." - }, - { - "description": "Enables the remove_by_id command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-remove-by-id", - "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." - }, - { - "description": "Enables the set_icon command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-icon", - "markdownDescription": "Enables the set_icon command without any pre-configured scope." - }, - { - "description": "Enables the set_icon_as_template command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-icon-as-template", - "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." - }, - { - "description": "Enables the set_menu command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-menu", - "markdownDescription": "Enables the set_menu command without any pre-configured scope." - }, - { - "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-show-menu-on-left-click", - "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." - }, - { - "description": "Enables the set_temp_dir_path command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-temp-dir-path", - "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." - }, - { - "description": "Enables the set_title command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-title", - "markdownDescription": "Enables the set_title command without any pre-configured scope." - }, - { - "description": "Enables the set_tooltip command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-tooltip", - "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." - }, - { - "description": "Enables the set_visible command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-visible", - "markdownDescription": "Enables the set_visible command without any pre-configured scope." - }, - { - "description": "Denies the get_by_id command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-get-by-id", - "markdownDescription": "Denies the get_by_id command without any pre-configured scope." - }, - { - "description": "Denies the new command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-new", - "markdownDescription": "Denies the new command without any pre-configured scope." - }, - { - "description": "Denies the remove_by_id command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-remove-by-id", - "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." - }, - { - "description": "Denies the set_icon command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-icon", - "markdownDescription": "Denies the set_icon command without any pre-configured scope." - }, - { - "description": "Denies the set_icon_as_template command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-icon-as-template", - "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." - }, - { - "description": "Denies the set_menu command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-menu", - "markdownDescription": "Denies the set_menu command without any pre-configured scope." - }, - { - "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-show-menu-on-left-click", - "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." - }, - { - "description": "Denies the set_temp_dir_path command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-temp-dir-path", - "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." - }, - { - "description": "Denies the set_title command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-title", - "markdownDescription": "Denies the set_title command without any pre-configured scope." - }, - { - "description": "Denies the set_tooltip command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-tooltip", - "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." - }, - { - "description": "Denies the set_visible command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-visible", - "markdownDescription": "Denies the set_visible command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", - "type": "string", - "const": "core:webview:default", - "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" - }, - { - "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-clear-all-browsing-data", - "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." - }, - { - "description": "Enables the create_webview command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-create-webview", - "markdownDescription": "Enables the create_webview command without any pre-configured scope." - }, - { - "description": "Enables the create_webview_window command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-create-webview-window", - "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." - }, - { - "description": "Enables the get_all_webviews command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-get-all-webviews", - "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." - }, - { - "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-internal-toggle-devtools", - "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." - }, - { - "description": "Enables the print command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-print", - "markdownDescription": "Enables the print command without any pre-configured scope." - }, - { - "description": "Enables the reparent command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-reparent", - "markdownDescription": "Enables the reparent command without any pre-configured scope." - }, - { - "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-set-webview-auto-resize", - "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." - }, - { - "description": "Enables the set_webview_background_color command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-set-webview-background-color", - "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." - }, - { - "description": "Enables the set_webview_focus command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-set-webview-focus", - "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." - }, - { - "description": "Enables the set_webview_position command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-set-webview-position", - "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." - }, - { - "description": "Enables the set_webview_size command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-set-webview-size", - "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." - }, - { - "description": "Enables the set_webview_zoom command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-set-webview-zoom", - "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." - }, - { - "description": "Enables the webview_close command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-webview-close", - "markdownDescription": "Enables the webview_close command without any pre-configured scope." - }, - { - "description": "Enables the webview_hide command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-webview-hide", - "markdownDescription": "Enables the webview_hide command without any pre-configured scope." - }, - { - "description": "Enables the webview_position command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-webview-position", - "markdownDescription": "Enables the webview_position command without any pre-configured scope." - }, - { - "description": "Enables the webview_show command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-webview-show", - "markdownDescription": "Enables the webview_show command without any pre-configured scope." - }, - { - "description": "Enables the webview_size command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-webview-size", - "markdownDescription": "Enables the webview_size command without any pre-configured scope." - }, - { - "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-clear-all-browsing-data", - "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." - }, - { - "description": "Denies the create_webview command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-create-webview", - "markdownDescription": "Denies the create_webview command without any pre-configured scope." - }, - { - "description": "Denies the create_webview_window command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-create-webview-window", - "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." - }, - { - "description": "Denies the get_all_webviews command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-get-all-webviews", - "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." - }, - { - "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-internal-toggle-devtools", - "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." - }, - { - "description": "Denies the print command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-print", - "markdownDescription": "Denies the print command without any pre-configured scope." - }, - { - "description": "Denies the reparent command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-reparent", - "markdownDescription": "Denies the reparent command without any pre-configured scope." - }, - { - "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-set-webview-auto-resize", - "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." - }, - { - "description": "Denies the set_webview_background_color command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-set-webview-background-color", - "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." - }, - { - "description": "Denies the set_webview_focus command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-set-webview-focus", - "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." - }, - { - "description": "Denies the set_webview_position command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-set-webview-position", - "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." - }, - { - "description": "Denies the set_webview_size command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-set-webview-size", - "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." - }, - { - "description": "Denies the set_webview_zoom command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-set-webview-zoom", - "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." - }, - { - "description": "Denies the webview_close command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-webview-close", - "markdownDescription": "Denies the webview_close command without any pre-configured scope." - }, - { - "description": "Denies the webview_hide command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-webview-hide", - "markdownDescription": "Denies the webview_hide command without any pre-configured scope." - }, - { - "description": "Denies the webview_position command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-webview-position", - "markdownDescription": "Denies the webview_position command without any pre-configured scope." - }, - { - "description": "Denies the webview_show command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-webview-show", - "markdownDescription": "Denies the webview_show command without any pre-configured scope." - }, - { - "description": "Denies the webview_size command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-webview-size", - "markdownDescription": "Denies the webview_size command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`", - "type": "string", - "const": "core:window:default", - "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`" - }, - { - "description": "Enables the available_monitors command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-available-monitors", - "markdownDescription": "Enables the available_monitors command without any pre-configured scope." - }, - { - "description": "Enables the center command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-center", - "markdownDescription": "Enables the center command without any pre-configured scope." - }, - { - "description": "Enables the close command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-close", - "markdownDescription": "Enables the close command without any pre-configured scope." - }, - { - "description": "Enables the create command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-create", - "markdownDescription": "Enables the create command without any pre-configured scope." - }, - { - "description": "Enables the current_monitor command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-current-monitor", - "markdownDescription": "Enables the current_monitor command without any pre-configured scope." - }, - { - "description": "Enables the cursor_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-cursor-position", - "markdownDescription": "Enables the cursor_position command without any pre-configured scope." - }, - { - "description": "Enables the destroy command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-destroy", - "markdownDescription": "Enables the destroy command without any pre-configured scope." - }, - { - "description": "Enables the get_all_windows command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-get-all-windows", - "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." - }, - { - "description": "Enables the hide command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-hide", - "markdownDescription": "Enables the hide command without any pre-configured scope." - }, - { - "description": "Enables the inner_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-inner-position", - "markdownDescription": "Enables the inner_position command without any pre-configured scope." - }, - { - "description": "Enables the inner_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-inner-size", - "markdownDescription": "Enables the inner_size command without any pre-configured scope." - }, - { - "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-internal-toggle-maximize", - "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." - }, - { - "description": "Enables the is_always_on_top command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-always-on-top", - "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." - }, - { - "description": "Enables the is_closable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-closable", - "markdownDescription": "Enables the is_closable command without any pre-configured scope." - }, - { - "description": "Enables the is_decorated command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-decorated", - "markdownDescription": "Enables the is_decorated command without any pre-configured scope." - }, - { - "description": "Enables the is_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-enabled", - "markdownDescription": "Enables the is_enabled command without any pre-configured scope." - }, - { - "description": "Enables the is_focused command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-focused", - "markdownDescription": "Enables the is_focused command without any pre-configured scope." - }, - { - "description": "Enables the is_fullscreen command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-fullscreen", - "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." - }, - { - "description": "Enables the is_maximizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-maximizable", - "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." - }, - { - "description": "Enables the is_maximized command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-maximized", - "markdownDescription": "Enables the is_maximized command without any pre-configured scope." - }, - { - "description": "Enables the is_minimizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-minimizable", - "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." - }, - { - "description": "Enables the is_minimized command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-minimized", - "markdownDescription": "Enables the is_minimized command without any pre-configured scope." - }, - { - "description": "Enables the is_resizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-resizable", - "markdownDescription": "Enables the is_resizable command without any pre-configured scope." - }, - { - "description": "Enables the is_visible command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-visible", - "markdownDescription": "Enables the is_visible command without any pre-configured scope." - }, - { - "description": "Enables the maximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-maximize", - "markdownDescription": "Enables the maximize command without any pre-configured scope." - }, - { - "description": "Enables the minimize command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-minimize", - "markdownDescription": "Enables the minimize command without any pre-configured scope." - }, - { - "description": "Enables the monitor_from_point command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-monitor-from-point", - "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." - }, - { - "description": "Enables the outer_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-outer-position", - "markdownDescription": "Enables the outer_position command without any pre-configured scope." - }, - { - "description": "Enables the outer_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-outer-size", - "markdownDescription": "Enables the outer_size command without any pre-configured scope." - }, - { - "description": "Enables the primary_monitor command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-primary-monitor", - "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." - }, - { - "description": "Enables the request_user_attention command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-request-user-attention", - "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." - }, - { - "description": "Enables the scale_factor command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-scale-factor", - "markdownDescription": "Enables the scale_factor command without any pre-configured scope." - }, - { - "description": "Enables the set_always_on_bottom command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-always-on-bottom", - "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." - }, - { - "description": "Enables the set_always_on_top command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-always-on-top", - "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." - }, - { - "description": "Enables the set_background_color command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-background-color", - "markdownDescription": "Enables the set_background_color command without any pre-configured scope." - }, - { - "description": "Enables the set_badge_count command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-badge-count", - "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." - }, - { - "description": "Enables the set_badge_label command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-badge-label", - "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." - }, - { - "description": "Enables the set_closable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-closable", - "markdownDescription": "Enables the set_closable command without any pre-configured scope." - }, - { - "description": "Enables the set_content_protected command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-content-protected", - "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." - }, - { - "description": "Enables the set_cursor_grab command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-cursor-grab", - "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." - }, - { - "description": "Enables the set_cursor_icon command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-cursor-icon", - "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." - }, - { - "description": "Enables the set_cursor_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-cursor-position", - "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." - }, - { - "description": "Enables the set_cursor_visible command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-cursor-visible", - "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." - }, - { - "description": "Enables the set_decorations command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-decorations", - "markdownDescription": "Enables the set_decorations command without any pre-configured scope." - }, - { - "description": "Enables the set_effects command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-effects", - "markdownDescription": "Enables the set_effects command without any pre-configured scope." - }, - { - "description": "Enables the set_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-enabled", - "markdownDescription": "Enables the set_enabled command without any pre-configured scope." - }, - { - "description": "Enables the set_focus command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-focus", - "markdownDescription": "Enables the set_focus command without any pre-configured scope." - }, - { - "description": "Enables the set_focusable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-focusable", - "markdownDescription": "Enables the set_focusable command without any pre-configured scope." - }, - { - "description": "Enables the set_fullscreen command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-fullscreen", - "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." - }, - { - "description": "Enables the set_icon command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-icon", - "markdownDescription": "Enables the set_icon command without any pre-configured scope." - }, - { - "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-ignore-cursor-events", - "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." - }, - { - "description": "Enables the set_max_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-max-size", - "markdownDescription": "Enables the set_max_size command without any pre-configured scope." - }, - { - "description": "Enables the set_maximizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-maximizable", - "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." - }, - { - "description": "Enables the set_min_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-min-size", - "markdownDescription": "Enables the set_min_size command without any pre-configured scope." - }, - { - "description": "Enables the set_minimizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-minimizable", - "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." - }, - { - "description": "Enables the set_overlay_icon command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-overlay-icon", - "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." - }, - { - "description": "Enables the set_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-position", - "markdownDescription": "Enables the set_position command without any pre-configured scope." - }, - { - "description": "Enables the set_progress_bar command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-progress-bar", - "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." - }, - { - "description": "Enables the set_resizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-resizable", - "markdownDescription": "Enables the set_resizable command without any pre-configured scope." - }, - { - "description": "Enables the set_shadow command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-shadow", - "markdownDescription": "Enables the set_shadow command without any pre-configured scope." - }, - { - "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-simple-fullscreen", - "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." - }, - { - "description": "Enables the set_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-size", - "markdownDescription": "Enables the set_size command without any pre-configured scope." - }, - { - "description": "Enables the set_size_constraints command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-size-constraints", - "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." - }, - { - "description": "Enables the set_skip_taskbar command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-skip-taskbar", - "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." - }, - { - "description": "Enables the set_theme command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-theme", - "markdownDescription": "Enables the set_theme command without any pre-configured scope." - }, - { - "description": "Enables the set_title command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-title", - "markdownDescription": "Enables the set_title command without any pre-configured scope." - }, - { - "description": "Enables the set_title_bar_style command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-title-bar-style", - "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." - }, - { - "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-visible-on-all-workspaces", - "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." - }, - { - "description": "Enables the show command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-show", - "markdownDescription": "Enables the show command without any pre-configured scope." - }, - { - "description": "Enables the start_dragging command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-start-dragging", - "markdownDescription": "Enables the start_dragging command without any pre-configured scope." - }, - { - "description": "Enables the start_resize_dragging command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-start-resize-dragging", - "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." - }, - { - "description": "Enables the theme command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-theme", - "markdownDescription": "Enables the theme command without any pre-configured scope." - }, - { - "description": "Enables the title command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-title", - "markdownDescription": "Enables the title command without any pre-configured scope." - }, - { - "description": "Enables the toggle_maximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-toggle-maximize", - "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." - }, - { - "description": "Enables the unmaximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-unmaximize", - "markdownDescription": "Enables the unmaximize command without any pre-configured scope." - }, - { - "description": "Enables the unminimize command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-unminimize", - "markdownDescription": "Enables the unminimize command without any pre-configured scope." - }, - { - "description": "Denies the available_monitors command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-available-monitors", - "markdownDescription": "Denies the available_monitors command without any pre-configured scope." - }, - { - "description": "Denies the center command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-center", - "markdownDescription": "Denies the center command without any pre-configured scope." - }, - { - "description": "Denies the close command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-close", - "markdownDescription": "Denies the close command without any pre-configured scope." - }, - { - "description": "Denies the create command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-create", - "markdownDescription": "Denies the create command without any pre-configured scope." - }, - { - "description": "Denies the current_monitor command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-current-monitor", - "markdownDescription": "Denies the current_monitor command without any pre-configured scope." - }, - { - "description": "Denies the cursor_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-cursor-position", - "markdownDescription": "Denies the cursor_position command without any pre-configured scope." - }, - { - "description": "Denies the destroy command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-destroy", - "markdownDescription": "Denies the destroy command without any pre-configured scope." - }, - { - "description": "Denies the get_all_windows command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-get-all-windows", - "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." - }, - { - "description": "Denies the hide command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-hide", - "markdownDescription": "Denies the hide command without any pre-configured scope." - }, - { - "description": "Denies the inner_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-inner-position", - "markdownDescription": "Denies the inner_position command without any pre-configured scope." - }, - { - "description": "Denies the inner_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-inner-size", - "markdownDescription": "Denies the inner_size command without any pre-configured scope." - }, - { - "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-internal-toggle-maximize", - "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." - }, - { - "description": "Denies the is_always_on_top command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-always-on-top", - "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." - }, - { - "description": "Denies the is_closable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-closable", - "markdownDescription": "Denies the is_closable command without any pre-configured scope." - }, - { - "description": "Denies the is_decorated command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-decorated", - "markdownDescription": "Denies the is_decorated command without any pre-configured scope." - }, - { - "description": "Denies the is_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-enabled", - "markdownDescription": "Denies the is_enabled command without any pre-configured scope." - }, - { - "description": "Denies the is_focused command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-focused", - "markdownDescription": "Denies the is_focused command without any pre-configured scope." - }, - { - "description": "Denies the is_fullscreen command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-fullscreen", - "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." - }, - { - "description": "Denies the is_maximizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-maximizable", - "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." - }, - { - "description": "Denies the is_maximized command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-maximized", - "markdownDescription": "Denies the is_maximized command without any pre-configured scope." - }, - { - "description": "Denies the is_minimizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-minimizable", - "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." - }, - { - "description": "Denies the is_minimized command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-minimized", - "markdownDescription": "Denies the is_minimized command without any pre-configured scope." - }, - { - "description": "Denies the is_resizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-resizable", - "markdownDescription": "Denies the is_resizable command without any pre-configured scope." - }, - { - "description": "Denies the is_visible command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-visible", - "markdownDescription": "Denies the is_visible command without any pre-configured scope." - }, - { - "description": "Denies the maximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-maximize", - "markdownDescription": "Denies the maximize command without any pre-configured scope." - }, - { - "description": "Denies the minimize command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-minimize", - "markdownDescription": "Denies the minimize command without any pre-configured scope." - }, - { - "description": "Denies the monitor_from_point command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-monitor-from-point", - "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." - }, - { - "description": "Denies the outer_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-outer-position", - "markdownDescription": "Denies the outer_position command without any pre-configured scope." - }, - { - "description": "Denies the outer_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-outer-size", - "markdownDescription": "Denies the outer_size command without any pre-configured scope." - }, - { - "description": "Denies the primary_monitor command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-primary-monitor", - "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." - }, - { - "description": "Denies the request_user_attention command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-request-user-attention", - "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." - }, - { - "description": "Denies the scale_factor command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-scale-factor", - "markdownDescription": "Denies the scale_factor command without any pre-configured scope." - }, - { - "description": "Denies the set_always_on_bottom command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-always-on-bottom", - "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." - }, - { - "description": "Denies the set_always_on_top command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-always-on-top", - "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." - }, - { - "description": "Denies the set_background_color command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-background-color", - "markdownDescription": "Denies the set_background_color command without any pre-configured scope." - }, - { - "description": "Denies the set_badge_count command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-badge-count", - "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." - }, - { - "description": "Denies the set_badge_label command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-badge-label", - "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." - }, - { - "description": "Denies the set_closable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-closable", - "markdownDescription": "Denies the set_closable command without any pre-configured scope." - }, - { - "description": "Denies the set_content_protected command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-content-protected", - "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." - }, - { - "description": "Denies the set_cursor_grab command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-cursor-grab", - "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." - }, - { - "description": "Denies the set_cursor_icon command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-cursor-icon", - "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." - }, - { - "description": "Denies the set_cursor_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-cursor-position", - "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." - }, - { - "description": "Denies the set_cursor_visible command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-cursor-visible", - "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." - }, - { - "description": "Denies the set_decorations command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-decorations", - "markdownDescription": "Denies the set_decorations command without any pre-configured scope." - }, - { - "description": "Denies the set_effects command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-effects", - "markdownDescription": "Denies the set_effects command without any pre-configured scope." - }, - { - "description": "Denies the set_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-enabled", - "markdownDescription": "Denies the set_enabled command without any pre-configured scope." - }, - { - "description": "Denies the set_focus command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-focus", - "markdownDescription": "Denies the set_focus command without any pre-configured scope." - }, - { - "description": "Denies the set_focusable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-focusable", - "markdownDescription": "Denies the set_focusable command without any pre-configured scope." - }, - { - "description": "Denies the set_fullscreen command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-fullscreen", - "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." - }, - { - "description": "Denies the set_icon command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-icon", - "markdownDescription": "Denies the set_icon command without any pre-configured scope." - }, - { - "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-ignore-cursor-events", - "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." - }, - { - "description": "Denies the set_max_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-max-size", - "markdownDescription": "Denies the set_max_size command without any pre-configured scope." - }, - { - "description": "Denies the set_maximizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-maximizable", - "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." - }, - { - "description": "Denies the set_min_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-min-size", - "markdownDescription": "Denies the set_min_size command without any pre-configured scope." - }, - { - "description": "Denies the set_minimizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-minimizable", - "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." - }, - { - "description": "Denies the set_overlay_icon command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-overlay-icon", - "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." - }, - { - "description": "Denies the set_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-position", - "markdownDescription": "Denies the set_position command without any pre-configured scope." - }, - { - "description": "Denies the set_progress_bar command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-progress-bar", - "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." - }, - { - "description": "Denies the set_resizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-resizable", - "markdownDescription": "Denies the set_resizable command without any pre-configured scope." - }, - { - "description": "Denies the set_shadow command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-shadow", - "markdownDescription": "Denies the set_shadow command without any pre-configured scope." - }, - { - "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-simple-fullscreen", - "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." - }, - { - "description": "Denies the set_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-size", - "markdownDescription": "Denies the set_size command without any pre-configured scope." - }, - { - "description": "Denies the set_size_constraints command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-size-constraints", - "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." - }, - { - "description": "Denies the set_skip_taskbar command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-skip-taskbar", - "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." - }, - { - "description": "Denies the set_theme command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-theme", - "markdownDescription": "Denies the set_theme command without any pre-configured scope." - }, - { - "description": "Denies the set_title command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-title", - "markdownDescription": "Denies the set_title command without any pre-configured scope." - }, - { - "description": "Denies the set_title_bar_style command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-title-bar-style", - "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." - }, - { - "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-visible-on-all-workspaces", - "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." - }, - { - "description": "Denies the show command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-show", - "markdownDescription": "Denies the show command without any pre-configured scope." - }, - { - "description": "Denies the start_dragging command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-start-dragging", - "markdownDescription": "Denies the start_dragging command without any pre-configured scope." - }, - { - "description": "Denies the start_resize_dragging command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-start-resize-dragging", - "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." - }, - { - "description": "Denies the theme command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-theme", - "markdownDescription": "Denies the theme command without any pre-configured scope." - }, - { - "description": "Denies the title command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-title", - "markdownDescription": "Denies the title command without any pre-configured scope." - }, - { - "description": "Denies the toggle_maximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-toggle-maximize", - "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." - }, - { - "description": "Denies the unmaximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-unmaximize", - "markdownDescription": "Denies the unmaximize command without any pre-configured scope." - }, - { - "description": "Denies the unminimize command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-unminimize", - "markdownDescription": "Denies the unminimize command without any pre-configured scope." - }, - { - "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`", - "type": "string", - "const": "notification:default", - "markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`" - }, - { - "description": "Enables the batch command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-batch", - "markdownDescription": "Enables the batch command without any pre-configured scope." - }, - { - "description": "Enables the cancel command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-cancel", - "markdownDescription": "Enables the cancel command without any pre-configured scope." - }, - { - "description": "Enables the check_permissions command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-check-permissions", - "markdownDescription": "Enables the check_permissions command without any pre-configured scope." - }, - { - "description": "Enables the create_channel command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-create-channel", - "markdownDescription": "Enables the create_channel command without any pre-configured scope." - }, - { - "description": "Enables the delete_channel command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-delete-channel", - "markdownDescription": "Enables the delete_channel command without any pre-configured scope." - }, - { - "description": "Enables the get_active command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-get-active", - "markdownDescription": "Enables the get_active command without any pre-configured scope." - }, - { - "description": "Enables the get_pending command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-get-pending", - "markdownDescription": "Enables the get_pending command without any pre-configured scope." - }, - { - "description": "Enables the is_permission_granted command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-is-permission-granted", - "markdownDescription": "Enables the is_permission_granted command without any pre-configured scope." - }, - { - "description": "Enables the list_channels command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-list-channels", - "markdownDescription": "Enables the list_channels command without any pre-configured scope." - }, - { - "description": "Enables the notify command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-notify", - "markdownDescription": "Enables the notify command without any pre-configured scope." - }, - { - "description": "Enables the permission_state command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-permission-state", - "markdownDescription": "Enables the permission_state command without any pre-configured scope." - }, - { - "description": "Enables the register_action_types command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-register-action-types", - "markdownDescription": "Enables the register_action_types command without any pre-configured scope." - }, - { - "description": "Enables the register_listener command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-register-listener", - "markdownDescription": "Enables the register_listener command without any pre-configured scope." - }, - { - "description": "Enables the remove_active command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-remove-active", - "markdownDescription": "Enables the remove_active command without any pre-configured scope." - }, - { - "description": "Enables the request_permission command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-request-permission", - "markdownDescription": "Enables the request_permission command without any pre-configured scope." - }, - { - "description": "Enables the show command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-show", - "markdownDescription": "Enables the show command without any pre-configured scope." - }, - { - "description": "Denies the batch command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-batch", - "markdownDescription": "Denies the batch command without any pre-configured scope." - }, - { - "description": "Denies the cancel command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-cancel", - "markdownDescription": "Denies the cancel command without any pre-configured scope." - }, - { - "description": "Denies the check_permissions command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-check-permissions", - "markdownDescription": "Denies the check_permissions command without any pre-configured scope." - }, - { - "description": "Denies the create_channel command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-create-channel", - "markdownDescription": "Denies the create_channel command without any pre-configured scope." - }, - { - "description": "Denies the delete_channel command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-delete-channel", - "markdownDescription": "Denies the delete_channel command without any pre-configured scope." - }, - { - "description": "Denies the get_active command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-get-active", - "markdownDescription": "Denies the get_active command without any pre-configured scope." - }, - { - "description": "Denies the get_pending command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-get-pending", - "markdownDescription": "Denies the get_pending command without any pre-configured scope." - }, - { - "description": "Denies the is_permission_granted command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-is-permission-granted", - "markdownDescription": "Denies the is_permission_granted command without any pre-configured scope." - }, - { - "description": "Denies the list_channels command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-list-channels", - "markdownDescription": "Denies the list_channels command without any pre-configured scope." - }, - { - "description": "Denies the notify command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-notify", - "markdownDescription": "Denies the notify command without any pre-configured scope." - }, - { - "description": "Denies the permission_state command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-permission-state", - "markdownDescription": "Denies the permission_state command without any pre-configured scope." - }, - { - "description": "Denies the register_action_types command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-register-action-types", - "markdownDescription": "Denies the register_action_types command without any pre-configured scope." - }, - { - "description": "Denies the register_listener command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-register-listener", - "markdownDescription": "Denies the register_listener command without any pre-configured scope." - }, - { - "description": "Denies the remove_active command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-remove-active", - "markdownDescription": "Denies the remove_active command without any pre-configured scope." - }, - { - "description": "Denies the request_permission command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-request-permission", - "markdownDescription": "Denies the request_permission command without any pre-configured scope." - }, - { - "description": "Denies the show command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-show", - "markdownDescription": "Denies the show command without any pre-configured scope." - } - ] - }, - "Value": { - "description": "All supported ACL values.", - "anyOf": [ - { - "description": "Represents a null JSON value.", - "type": "null" - }, - { - "description": "Represents a [`bool`].", - "type": "boolean" - }, - { - "description": "Represents a valid ACL [`Number`].", - "allOf": [ - { - "$ref": "#/definitions/Number" - } - ] - }, - { - "description": "Represents a [`String`].", - "type": "string" - }, - { - "description": "Represents a list of other [`Value`]s.", - "type": "array", - "items": { - "$ref": "#/definitions/Value" - } - }, - { - "description": "Represents a map of [`String`] keys to [`Value`]s.", - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/Value" - } - } - ] - }, - "Number": { - "description": "A valid ACL number.", - "anyOf": [ - { - "description": "Represents an [`i64`].", - "type": "integer", - "format": "int64" - }, - { - "description": "Represents a [`f64`].", - "type": "number", - "format": "double" - } - ] - }, - "Target": { - "description": "Platform target.", - "oneOf": [ - { - "description": "MacOS.", - "type": "string", - "enum": [ - "macOS" - ] - }, - { - "description": "Windows.", - "type": "string", - "enum": [ - "windows" - ] - }, - { - "description": "Linux.", - "type": "string", - "enum": [ - "linux" - ] - }, - { - "description": "Android.", - "type": "string", - "enum": [ - "android" - ] - }, - { - "description": "iOS.", - "type": "string", - "enum": [ - "iOS" - ] - } - ] - } - } -} \ No newline at end of file diff --git a/app/src-tauri/gen/schemas/macOS-schema.json b/app/src-tauri/gen/schemas/macOS-schema.json deleted file mode 100644 index cc7a333..0000000 --- a/app/src-tauri/gen/schemas/macOS-schema.json +++ /dev/null @@ -1,2442 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "CapabilityFile", - "description": "Capability formats accepted in a capability file.", - "anyOf": [ - { - "description": "A single capability.", - "allOf": [ - { - "$ref": "#/definitions/Capability" - } - ] - }, - { - "description": "A list of capabilities.", - "type": "array", - "items": { - "$ref": "#/definitions/Capability" - } - }, - { - "description": "A list of capabilities.", - "type": "object", - "required": [ - "capabilities" - ], - "properties": { - "capabilities": { - "description": "The list of capabilities.", - "type": "array", - "items": { - "$ref": "#/definitions/Capability" - } - } - } - } - ], - "definitions": { - "Capability": { - "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", - "type": "object", - "required": [ - "identifier", - "permissions" - ], - "properties": { - "identifier": { - "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", - "type": "string" - }, - "description": { - "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", - "default": "", - "type": "string" - }, - "remote": { - "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", - "anyOf": [ - { - "$ref": "#/definitions/CapabilityRemote" - }, - { - "type": "null" - } - ] - }, - "local": { - "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", - "default": true, - "type": "boolean" - }, - "windows": { - "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", - "type": "array", - "items": { - "type": "string" - } - }, - "webviews": { - "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", - "type": "array", - "items": { - "type": "string" - } - }, - "permissions": { - "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", - "type": "array", - "items": { - "$ref": "#/definitions/PermissionEntry" - }, - "uniqueItems": true - }, - "platforms": { - "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Target" - } - } - } - }, - "CapabilityRemote": { - "description": "Configuration for remote URLs that are associated with the capability.", - "type": "object", - "required": [ - "urls" - ], - "properties": { - "urls": { - "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "PermissionEntry": { - "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", - "anyOf": [ - { - "description": "Reference a permission or permission set by identifier.", - "allOf": [ - { - "$ref": "#/definitions/Identifier" - } - ] - }, - { - "description": "Reference a permission or permission set by identifier and extends its scope.", - "type": "object", - "allOf": [ - { - "properties": { - "identifier": { - "description": "Identifier of the permission or permission set.", - "allOf": [ - { - "$ref": "#/definitions/Identifier" - } - ] - }, - "allow": { - "description": "Data that defines what is allowed by the scope.", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Value" - } - }, - "deny": { - "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", - "type": [ - "array", - "null" - ], - "items": { - "$ref": "#/definitions/Value" - } - } - } - } - ], - "required": [ - "identifier" - ] - } - ] - }, - "Identifier": { - "description": "Permission identifier", - "oneOf": [ - { - "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", - "type": "string", - "const": "core:default", - "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" - }, - { - "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`", - "type": "string", - "const": "core:app:default", - "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`" - }, - { - "description": "Enables the app_hide command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-app-hide", - "markdownDescription": "Enables the app_hide command without any pre-configured scope." - }, - { - "description": "Enables the app_show command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-app-show", - "markdownDescription": "Enables the app_show command without any pre-configured scope." - }, - { - "description": "Enables the bundle_type command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-bundle-type", - "markdownDescription": "Enables the bundle_type command without any pre-configured scope." - }, - { - "description": "Enables the default_window_icon command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-default-window-icon", - "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." - }, - { - "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-fetch-data-store-identifiers", - "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." - }, - { - "description": "Enables the identifier command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-identifier", - "markdownDescription": "Enables the identifier command without any pre-configured scope." - }, - { - "description": "Enables the name command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-name", - "markdownDescription": "Enables the name command without any pre-configured scope." - }, - { - "description": "Enables the register_listener command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-register-listener", - "markdownDescription": "Enables the register_listener command without any pre-configured scope." - }, - { - "description": "Enables the remove_data_store command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-remove-data-store", - "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." - }, - { - "description": "Enables the remove_listener command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-remove-listener", - "markdownDescription": "Enables the remove_listener command without any pre-configured scope." - }, - { - "description": "Enables the set_app_theme command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-set-app-theme", - "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." - }, - { - "description": "Enables the set_dock_visibility command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-set-dock-visibility", - "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." - }, - { - "description": "Enables the tauri_version command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-tauri-version", - "markdownDescription": "Enables the tauri_version command without any pre-configured scope." - }, - { - "description": "Enables the version command without any pre-configured scope.", - "type": "string", - "const": "core:app:allow-version", - "markdownDescription": "Enables the version command without any pre-configured scope." - }, - { - "description": "Denies the app_hide command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-app-hide", - "markdownDescription": "Denies the app_hide command without any pre-configured scope." - }, - { - "description": "Denies the app_show command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-app-show", - "markdownDescription": "Denies the app_show command without any pre-configured scope." - }, - { - "description": "Denies the bundle_type command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-bundle-type", - "markdownDescription": "Denies the bundle_type command without any pre-configured scope." - }, - { - "description": "Denies the default_window_icon command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-default-window-icon", - "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." - }, - { - "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-fetch-data-store-identifiers", - "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." - }, - { - "description": "Denies the identifier command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-identifier", - "markdownDescription": "Denies the identifier command without any pre-configured scope." - }, - { - "description": "Denies the name command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-name", - "markdownDescription": "Denies the name command without any pre-configured scope." - }, - { - "description": "Denies the register_listener command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-register-listener", - "markdownDescription": "Denies the register_listener command without any pre-configured scope." - }, - { - "description": "Denies the remove_data_store command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-remove-data-store", - "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." - }, - { - "description": "Denies the remove_listener command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-remove-listener", - "markdownDescription": "Denies the remove_listener command without any pre-configured scope." - }, - { - "description": "Denies the set_app_theme command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-set-app-theme", - "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." - }, - { - "description": "Denies the set_dock_visibility command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-set-dock-visibility", - "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." - }, - { - "description": "Denies the tauri_version command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-tauri-version", - "markdownDescription": "Denies the tauri_version command without any pre-configured scope." - }, - { - "description": "Denies the version command without any pre-configured scope.", - "type": "string", - "const": "core:app:deny-version", - "markdownDescription": "Denies the version command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", - "type": "string", - "const": "core:event:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" - }, - { - "description": "Enables the emit command without any pre-configured scope.", - "type": "string", - "const": "core:event:allow-emit", - "markdownDescription": "Enables the emit command without any pre-configured scope." - }, - { - "description": "Enables the emit_to command without any pre-configured scope.", - "type": "string", - "const": "core:event:allow-emit-to", - "markdownDescription": "Enables the emit_to command without any pre-configured scope." - }, - { - "description": "Enables the listen command without any pre-configured scope.", - "type": "string", - "const": "core:event:allow-listen", - "markdownDescription": "Enables the listen command without any pre-configured scope." - }, - { - "description": "Enables the unlisten command without any pre-configured scope.", - "type": "string", - "const": "core:event:allow-unlisten", - "markdownDescription": "Enables the unlisten command without any pre-configured scope." - }, - { - "description": "Denies the emit command without any pre-configured scope.", - "type": "string", - "const": "core:event:deny-emit", - "markdownDescription": "Denies the emit command without any pre-configured scope." - }, - { - "description": "Denies the emit_to command without any pre-configured scope.", - "type": "string", - "const": "core:event:deny-emit-to", - "markdownDescription": "Denies the emit_to command without any pre-configured scope." - }, - { - "description": "Denies the listen command without any pre-configured scope.", - "type": "string", - "const": "core:event:deny-listen", - "markdownDescription": "Denies the listen command without any pre-configured scope." - }, - { - "description": "Denies the unlisten command without any pre-configured scope.", - "type": "string", - "const": "core:event:deny-unlisten", - "markdownDescription": "Denies the unlisten command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", - "type": "string", - "const": "core:image:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" - }, - { - "description": "Enables the from_bytes command without any pre-configured scope.", - "type": "string", - "const": "core:image:allow-from-bytes", - "markdownDescription": "Enables the from_bytes command without any pre-configured scope." - }, - { - "description": "Enables the from_path command without any pre-configured scope.", - "type": "string", - "const": "core:image:allow-from-path", - "markdownDescription": "Enables the from_path command without any pre-configured scope." - }, - { - "description": "Enables the new command without any pre-configured scope.", - "type": "string", - "const": "core:image:allow-new", - "markdownDescription": "Enables the new command without any pre-configured scope." - }, - { - "description": "Enables the rgba command without any pre-configured scope.", - "type": "string", - "const": "core:image:allow-rgba", - "markdownDescription": "Enables the rgba command without any pre-configured scope." - }, - { - "description": "Enables the size command without any pre-configured scope.", - "type": "string", - "const": "core:image:allow-size", - "markdownDescription": "Enables the size command without any pre-configured scope." - }, - { - "description": "Denies the from_bytes command without any pre-configured scope.", - "type": "string", - "const": "core:image:deny-from-bytes", - "markdownDescription": "Denies the from_bytes command without any pre-configured scope." - }, - { - "description": "Denies the from_path command without any pre-configured scope.", - "type": "string", - "const": "core:image:deny-from-path", - "markdownDescription": "Denies the from_path command without any pre-configured scope." - }, - { - "description": "Denies the new command without any pre-configured scope.", - "type": "string", - "const": "core:image:deny-new", - "markdownDescription": "Denies the new command without any pre-configured scope." - }, - { - "description": "Denies the rgba command without any pre-configured scope.", - "type": "string", - "const": "core:image:deny-rgba", - "markdownDescription": "Denies the rgba command without any pre-configured scope." - }, - { - "description": "Denies the size command without any pre-configured scope.", - "type": "string", - "const": "core:image:deny-size", - "markdownDescription": "Denies the size command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", - "type": "string", - "const": "core:menu:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" - }, - { - "description": "Enables the append command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-append", - "markdownDescription": "Enables the append command without any pre-configured scope." - }, - { - "description": "Enables the create_default command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-create-default", - "markdownDescription": "Enables the create_default command without any pre-configured scope." - }, - { - "description": "Enables the get command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-get", - "markdownDescription": "Enables the get command without any pre-configured scope." - }, - { - "description": "Enables the insert command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-insert", - "markdownDescription": "Enables the insert command without any pre-configured scope." - }, - { - "description": "Enables the is_checked command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-is-checked", - "markdownDescription": "Enables the is_checked command without any pre-configured scope." - }, - { - "description": "Enables the is_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-is-enabled", - "markdownDescription": "Enables the is_enabled command without any pre-configured scope." - }, - { - "description": "Enables the items command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-items", - "markdownDescription": "Enables the items command without any pre-configured scope." - }, - { - "description": "Enables the new command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-new", - "markdownDescription": "Enables the new command without any pre-configured scope." - }, - { - "description": "Enables the popup command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-popup", - "markdownDescription": "Enables the popup command without any pre-configured scope." - }, - { - "description": "Enables the prepend command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-prepend", - "markdownDescription": "Enables the prepend command without any pre-configured scope." - }, - { - "description": "Enables the remove command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-remove", - "markdownDescription": "Enables the remove command without any pre-configured scope." - }, - { - "description": "Enables the remove_at command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-remove-at", - "markdownDescription": "Enables the remove_at command without any pre-configured scope." - }, - { - "description": "Enables the set_accelerator command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-accelerator", - "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." - }, - { - "description": "Enables the set_as_app_menu command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-as-app-menu", - "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." - }, - { - "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-as-help-menu-for-nsapp", - "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." - }, - { - "description": "Enables the set_as_window_menu command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-as-window-menu", - "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." - }, - { - "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-as-windows-menu-for-nsapp", - "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." - }, - { - "description": "Enables the set_checked command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-checked", - "markdownDescription": "Enables the set_checked command without any pre-configured scope." - }, - { - "description": "Enables the set_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-enabled", - "markdownDescription": "Enables the set_enabled command without any pre-configured scope." - }, - { - "description": "Enables the set_icon command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-icon", - "markdownDescription": "Enables the set_icon command without any pre-configured scope." - }, - { - "description": "Enables the set_text command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-set-text", - "markdownDescription": "Enables the set_text command without any pre-configured scope." - }, - { - "description": "Enables the text command without any pre-configured scope.", - "type": "string", - "const": "core:menu:allow-text", - "markdownDescription": "Enables the text command without any pre-configured scope." - }, - { - "description": "Denies the append command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-append", - "markdownDescription": "Denies the append command without any pre-configured scope." - }, - { - "description": "Denies the create_default command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-create-default", - "markdownDescription": "Denies the create_default command without any pre-configured scope." - }, - { - "description": "Denies the get command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-get", - "markdownDescription": "Denies the get command without any pre-configured scope." - }, - { - "description": "Denies the insert command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-insert", - "markdownDescription": "Denies the insert command without any pre-configured scope." - }, - { - "description": "Denies the is_checked command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-is-checked", - "markdownDescription": "Denies the is_checked command without any pre-configured scope." - }, - { - "description": "Denies the is_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-is-enabled", - "markdownDescription": "Denies the is_enabled command without any pre-configured scope." - }, - { - "description": "Denies the items command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-items", - "markdownDescription": "Denies the items command without any pre-configured scope." - }, - { - "description": "Denies the new command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-new", - "markdownDescription": "Denies the new command without any pre-configured scope." - }, - { - "description": "Denies the popup command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-popup", - "markdownDescription": "Denies the popup command without any pre-configured scope." - }, - { - "description": "Denies the prepend command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-prepend", - "markdownDescription": "Denies the prepend command without any pre-configured scope." - }, - { - "description": "Denies the remove command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-remove", - "markdownDescription": "Denies the remove command without any pre-configured scope." - }, - { - "description": "Denies the remove_at command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-remove-at", - "markdownDescription": "Denies the remove_at command without any pre-configured scope." - }, - { - "description": "Denies the set_accelerator command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-accelerator", - "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." - }, - { - "description": "Denies the set_as_app_menu command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-as-app-menu", - "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." - }, - { - "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-as-help-menu-for-nsapp", - "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." - }, - { - "description": "Denies the set_as_window_menu command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-as-window-menu", - "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." - }, - { - "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-as-windows-menu-for-nsapp", - "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." - }, - { - "description": "Denies the set_checked command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-checked", - "markdownDescription": "Denies the set_checked command without any pre-configured scope." - }, - { - "description": "Denies the set_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-enabled", - "markdownDescription": "Denies the set_enabled command without any pre-configured scope." - }, - { - "description": "Denies the set_icon command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-icon", - "markdownDescription": "Denies the set_icon command without any pre-configured scope." - }, - { - "description": "Denies the set_text command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-set-text", - "markdownDescription": "Denies the set_text command without any pre-configured scope." - }, - { - "description": "Denies the text command without any pre-configured scope.", - "type": "string", - "const": "core:menu:deny-text", - "markdownDescription": "Denies the text command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", - "type": "string", - "const": "core:path:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" - }, - { - "description": "Enables the basename command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-basename", - "markdownDescription": "Enables the basename command without any pre-configured scope." - }, - { - "description": "Enables the dirname command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-dirname", - "markdownDescription": "Enables the dirname command without any pre-configured scope." - }, - { - "description": "Enables the extname command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-extname", - "markdownDescription": "Enables the extname command without any pre-configured scope." - }, - { - "description": "Enables the is_absolute command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-is-absolute", - "markdownDescription": "Enables the is_absolute command without any pre-configured scope." - }, - { - "description": "Enables the join command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-join", - "markdownDescription": "Enables the join command without any pre-configured scope." - }, - { - "description": "Enables the normalize command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-normalize", - "markdownDescription": "Enables the normalize command without any pre-configured scope." - }, - { - "description": "Enables the resolve command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-resolve", - "markdownDescription": "Enables the resolve command without any pre-configured scope." - }, - { - "description": "Enables the resolve_directory command without any pre-configured scope.", - "type": "string", - "const": "core:path:allow-resolve-directory", - "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." - }, - { - "description": "Denies the basename command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-basename", - "markdownDescription": "Denies the basename command without any pre-configured scope." - }, - { - "description": "Denies the dirname command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-dirname", - "markdownDescription": "Denies the dirname command without any pre-configured scope." - }, - { - "description": "Denies the extname command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-extname", - "markdownDescription": "Denies the extname command without any pre-configured scope." - }, - { - "description": "Denies the is_absolute command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-is-absolute", - "markdownDescription": "Denies the is_absolute command without any pre-configured scope." - }, - { - "description": "Denies the join command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-join", - "markdownDescription": "Denies the join command without any pre-configured scope." - }, - { - "description": "Denies the normalize command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-normalize", - "markdownDescription": "Denies the normalize command without any pre-configured scope." - }, - { - "description": "Denies the resolve command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-resolve", - "markdownDescription": "Denies the resolve command without any pre-configured scope." - }, - { - "description": "Denies the resolve_directory command without any pre-configured scope.", - "type": "string", - "const": "core:path:deny-resolve-directory", - "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", - "type": "string", - "const": "core:resources:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" - }, - { - "description": "Enables the close command without any pre-configured scope.", - "type": "string", - "const": "core:resources:allow-close", - "markdownDescription": "Enables the close command without any pre-configured scope." - }, - { - "description": "Denies the close command without any pre-configured scope.", - "type": "string", - "const": "core:resources:deny-close", - "markdownDescription": "Denies the close command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`", - "type": "string", - "const": "core:tray:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`" - }, - { - "description": "Enables the get_by_id command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-get-by-id", - "markdownDescription": "Enables the get_by_id command without any pre-configured scope." - }, - { - "description": "Enables the new command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-new", - "markdownDescription": "Enables the new command without any pre-configured scope." - }, - { - "description": "Enables the remove_by_id command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-remove-by-id", - "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." - }, - { - "description": "Enables the set_icon command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-icon", - "markdownDescription": "Enables the set_icon command without any pre-configured scope." - }, - { - "description": "Enables the set_icon_as_template command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-icon-as-template", - "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." - }, - { - "description": "Enables the set_menu command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-menu", - "markdownDescription": "Enables the set_menu command without any pre-configured scope." - }, - { - "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-show-menu-on-left-click", - "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." - }, - { - "description": "Enables the set_temp_dir_path command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-temp-dir-path", - "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." - }, - { - "description": "Enables the set_title command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-title", - "markdownDescription": "Enables the set_title command without any pre-configured scope." - }, - { - "description": "Enables the set_tooltip command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-tooltip", - "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." - }, - { - "description": "Enables the set_visible command without any pre-configured scope.", - "type": "string", - "const": "core:tray:allow-set-visible", - "markdownDescription": "Enables the set_visible command without any pre-configured scope." - }, - { - "description": "Denies the get_by_id command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-get-by-id", - "markdownDescription": "Denies the get_by_id command without any pre-configured scope." - }, - { - "description": "Denies the new command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-new", - "markdownDescription": "Denies the new command without any pre-configured scope." - }, - { - "description": "Denies the remove_by_id command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-remove-by-id", - "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." - }, - { - "description": "Denies the set_icon command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-icon", - "markdownDescription": "Denies the set_icon command without any pre-configured scope." - }, - { - "description": "Denies the set_icon_as_template command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-icon-as-template", - "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." - }, - { - "description": "Denies the set_menu command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-menu", - "markdownDescription": "Denies the set_menu command without any pre-configured scope." - }, - { - "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-show-menu-on-left-click", - "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." - }, - { - "description": "Denies the set_temp_dir_path command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-temp-dir-path", - "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." - }, - { - "description": "Denies the set_title command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-title", - "markdownDescription": "Denies the set_title command without any pre-configured scope." - }, - { - "description": "Denies the set_tooltip command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-tooltip", - "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." - }, - { - "description": "Denies the set_visible command without any pre-configured scope.", - "type": "string", - "const": "core:tray:deny-set-visible", - "markdownDescription": "Denies the set_visible command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", - "type": "string", - "const": "core:webview:default", - "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" - }, - { - "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-clear-all-browsing-data", - "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." - }, - { - "description": "Enables the create_webview command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-create-webview", - "markdownDescription": "Enables the create_webview command without any pre-configured scope." - }, - { - "description": "Enables the create_webview_window command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-create-webview-window", - "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." - }, - { - "description": "Enables the get_all_webviews command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-get-all-webviews", - "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." - }, - { - "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-internal-toggle-devtools", - "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." - }, - { - "description": "Enables the print command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-print", - "markdownDescription": "Enables the print command without any pre-configured scope." - }, - { - "description": "Enables the reparent command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-reparent", - "markdownDescription": "Enables the reparent command without any pre-configured scope." - }, - { - "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-set-webview-auto-resize", - "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." - }, - { - "description": "Enables the set_webview_background_color command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-set-webview-background-color", - "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." - }, - { - "description": "Enables the set_webview_focus command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-set-webview-focus", - "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." - }, - { - "description": "Enables the set_webview_position command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-set-webview-position", - "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." - }, - { - "description": "Enables the set_webview_size command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-set-webview-size", - "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." - }, - { - "description": "Enables the set_webview_zoom command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-set-webview-zoom", - "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." - }, - { - "description": "Enables the webview_close command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-webview-close", - "markdownDescription": "Enables the webview_close command without any pre-configured scope." - }, - { - "description": "Enables the webview_hide command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-webview-hide", - "markdownDescription": "Enables the webview_hide command without any pre-configured scope." - }, - { - "description": "Enables the webview_position command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-webview-position", - "markdownDescription": "Enables the webview_position command without any pre-configured scope." - }, - { - "description": "Enables the webview_show command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-webview-show", - "markdownDescription": "Enables the webview_show command without any pre-configured scope." - }, - { - "description": "Enables the webview_size command without any pre-configured scope.", - "type": "string", - "const": "core:webview:allow-webview-size", - "markdownDescription": "Enables the webview_size command without any pre-configured scope." - }, - { - "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-clear-all-browsing-data", - "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." - }, - { - "description": "Denies the create_webview command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-create-webview", - "markdownDescription": "Denies the create_webview command without any pre-configured scope." - }, - { - "description": "Denies the create_webview_window command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-create-webview-window", - "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." - }, - { - "description": "Denies the get_all_webviews command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-get-all-webviews", - "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." - }, - { - "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-internal-toggle-devtools", - "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." - }, - { - "description": "Denies the print command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-print", - "markdownDescription": "Denies the print command without any pre-configured scope." - }, - { - "description": "Denies the reparent command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-reparent", - "markdownDescription": "Denies the reparent command without any pre-configured scope." - }, - { - "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-set-webview-auto-resize", - "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." - }, - { - "description": "Denies the set_webview_background_color command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-set-webview-background-color", - "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." - }, - { - "description": "Denies the set_webview_focus command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-set-webview-focus", - "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." - }, - { - "description": "Denies the set_webview_position command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-set-webview-position", - "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." - }, - { - "description": "Denies the set_webview_size command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-set-webview-size", - "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." - }, - { - "description": "Denies the set_webview_zoom command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-set-webview-zoom", - "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." - }, - { - "description": "Denies the webview_close command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-webview-close", - "markdownDescription": "Denies the webview_close command without any pre-configured scope." - }, - { - "description": "Denies the webview_hide command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-webview-hide", - "markdownDescription": "Denies the webview_hide command without any pre-configured scope." - }, - { - "description": "Denies the webview_position command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-webview-position", - "markdownDescription": "Denies the webview_position command without any pre-configured scope." - }, - { - "description": "Denies the webview_show command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-webview-show", - "markdownDescription": "Denies the webview_show command without any pre-configured scope." - }, - { - "description": "Denies the webview_size command without any pre-configured scope.", - "type": "string", - "const": "core:webview:deny-webview-size", - "markdownDescription": "Denies the webview_size command without any pre-configured scope." - }, - { - "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`", - "type": "string", - "const": "core:window:default", - "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`" - }, - { - "description": "Enables the available_monitors command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-available-monitors", - "markdownDescription": "Enables the available_monitors command without any pre-configured scope." - }, - { - "description": "Enables the center command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-center", - "markdownDescription": "Enables the center command without any pre-configured scope." - }, - { - "description": "Enables the close command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-close", - "markdownDescription": "Enables the close command without any pre-configured scope." - }, - { - "description": "Enables the create command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-create", - "markdownDescription": "Enables the create command without any pre-configured scope." - }, - { - "description": "Enables the current_monitor command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-current-monitor", - "markdownDescription": "Enables the current_monitor command without any pre-configured scope." - }, - { - "description": "Enables the cursor_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-cursor-position", - "markdownDescription": "Enables the cursor_position command without any pre-configured scope." - }, - { - "description": "Enables the destroy command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-destroy", - "markdownDescription": "Enables the destroy command without any pre-configured scope." - }, - { - "description": "Enables the get_all_windows command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-get-all-windows", - "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." - }, - { - "description": "Enables the hide command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-hide", - "markdownDescription": "Enables the hide command without any pre-configured scope." - }, - { - "description": "Enables the inner_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-inner-position", - "markdownDescription": "Enables the inner_position command without any pre-configured scope." - }, - { - "description": "Enables the inner_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-inner-size", - "markdownDescription": "Enables the inner_size command without any pre-configured scope." - }, - { - "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-internal-toggle-maximize", - "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." - }, - { - "description": "Enables the is_always_on_top command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-always-on-top", - "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." - }, - { - "description": "Enables the is_closable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-closable", - "markdownDescription": "Enables the is_closable command without any pre-configured scope." - }, - { - "description": "Enables the is_decorated command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-decorated", - "markdownDescription": "Enables the is_decorated command without any pre-configured scope." - }, - { - "description": "Enables the is_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-enabled", - "markdownDescription": "Enables the is_enabled command without any pre-configured scope." - }, - { - "description": "Enables the is_focused command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-focused", - "markdownDescription": "Enables the is_focused command without any pre-configured scope." - }, - { - "description": "Enables the is_fullscreen command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-fullscreen", - "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." - }, - { - "description": "Enables the is_maximizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-maximizable", - "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." - }, - { - "description": "Enables the is_maximized command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-maximized", - "markdownDescription": "Enables the is_maximized command without any pre-configured scope." - }, - { - "description": "Enables the is_minimizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-minimizable", - "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." - }, - { - "description": "Enables the is_minimized command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-minimized", - "markdownDescription": "Enables the is_minimized command without any pre-configured scope." - }, - { - "description": "Enables the is_resizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-resizable", - "markdownDescription": "Enables the is_resizable command without any pre-configured scope." - }, - { - "description": "Enables the is_visible command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-is-visible", - "markdownDescription": "Enables the is_visible command without any pre-configured scope." - }, - { - "description": "Enables the maximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-maximize", - "markdownDescription": "Enables the maximize command without any pre-configured scope." - }, - { - "description": "Enables the minimize command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-minimize", - "markdownDescription": "Enables the minimize command without any pre-configured scope." - }, - { - "description": "Enables the monitor_from_point command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-monitor-from-point", - "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." - }, - { - "description": "Enables the outer_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-outer-position", - "markdownDescription": "Enables the outer_position command without any pre-configured scope." - }, - { - "description": "Enables the outer_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-outer-size", - "markdownDescription": "Enables the outer_size command without any pre-configured scope." - }, - { - "description": "Enables the primary_monitor command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-primary-monitor", - "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." - }, - { - "description": "Enables the request_user_attention command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-request-user-attention", - "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." - }, - { - "description": "Enables the scale_factor command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-scale-factor", - "markdownDescription": "Enables the scale_factor command without any pre-configured scope." - }, - { - "description": "Enables the set_always_on_bottom command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-always-on-bottom", - "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." - }, - { - "description": "Enables the set_always_on_top command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-always-on-top", - "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." - }, - { - "description": "Enables the set_background_color command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-background-color", - "markdownDescription": "Enables the set_background_color command without any pre-configured scope." - }, - { - "description": "Enables the set_badge_count command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-badge-count", - "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." - }, - { - "description": "Enables the set_badge_label command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-badge-label", - "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." - }, - { - "description": "Enables the set_closable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-closable", - "markdownDescription": "Enables the set_closable command without any pre-configured scope." - }, - { - "description": "Enables the set_content_protected command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-content-protected", - "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." - }, - { - "description": "Enables the set_cursor_grab command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-cursor-grab", - "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." - }, - { - "description": "Enables the set_cursor_icon command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-cursor-icon", - "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." - }, - { - "description": "Enables the set_cursor_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-cursor-position", - "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." - }, - { - "description": "Enables the set_cursor_visible command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-cursor-visible", - "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." - }, - { - "description": "Enables the set_decorations command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-decorations", - "markdownDescription": "Enables the set_decorations command without any pre-configured scope." - }, - { - "description": "Enables the set_effects command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-effects", - "markdownDescription": "Enables the set_effects command without any pre-configured scope." - }, - { - "description": "Enables the set_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-enabled", - "markdownDescription": "Enables the set_enabled command without any pre-configured scope." - }, - { - "description": "Enables the set_focus command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-focus", - "markdownDescription": "Enables the set_focus command without any pre-configured scope." - }, - { - "description": "Enables the set_focusable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-focusable", - "markdownDescription": "Enables the set_focusable command without any pre-configured scope." - }, - { - "description": "Enables the set_fullscreen command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-fullscreen", - "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." - }, - { - "description": "Enables the set_icon command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-icon", - "markdownDescription": "Enables the set_icon command without any pre-configured scope." - }, - { - "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-ignore-cursor-events", - "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." - }, - { - "description": "Enables the set_max_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-max-size", - "markdownDescription": "Enables the set_max_size command without any pre-configured scope." - }, - { - "description": "Enables the set_maximizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-maximizable", - "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." - }, - { - "description": "Enables the set_min_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-min-size", - "markdownDescription": "Enables the set_min_size command without any pre-configured scope." - }, - { - "description": "Enables the set_minimizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-minimizable", - "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." - }, - { - "description": "Enables the set_overlay_icon command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-overlay-icon", - "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." - }, - { - "description": "Enables the set_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-position", - "markdownDescription": "Enables the set_position command without any pre-configured scope." - }, - { - "description": "Enables the set_progress_bar command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-progress-bar", - "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." - }, - { - "description": "Enables the set_resizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-resizable", - "markdownDescription": "Enables the set_resizable command without any pre-configured scope." - }, - { - "description": "Enables the set_shadow command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-shadow", - "markdownDescription": "Enables the set_shadow command without any pre-configured scope." - }, - { - "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-simple-fullscreen", - "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." - }, - { - "description": "Enables the set_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-size", - "markdownDescription": "Enables the set_size command without any pre-configured scope." - }, - { - "description": "Enables the set_size_constraints command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-size-constraints", - "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." - }, - { - "description": "Enables the set_skip_taskbar command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-skip-taskbar", - "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." - }, - { - "description": "Enables the set_theme command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-theme", - "markdownDescription": "Enables the set_theme command without any pre-configured scope." - }, - { - "description": "Enables the set_title command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-title", - "markdownDescription": "Enables the set_title command without any pre-configured scope." - }, - { - "description": "Enables the set_title_bar_style command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-title-bar-style", - "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." - }, - { - "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-set-visible-on-all-workspaces", - "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." - }, - { - "description": "Enables the show command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-show", - "markdownDescription": "Enables the show command without any pre-configured scope." - }, - { - "description": "Enables the start_dragging command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-start-dragging", - "markdownDescription": "Enables the start_dragging command without any pre-configured scope." - }, - { - "description": "Enables the start_resize_dragging command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-start-resize-dragging", - "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." - }, - { - "description": "Enables the theme command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-theme", - "markdownDescription": "Enables the theme command without any pre-configured scope." - }, - { - "description": "Enables the title command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-title", - "markdownDescription": "Enables the title command without any pre-configured scope." - }, - { - "description": "Enables the toggle_maximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-toggle-maximize", - "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." - }, - { - "description": "Enables the unmaximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-unmaximize", - "markdownDescription": "Enables the unmaximize command without any pre-configured scope." - }, - { - "description": "Enables the unminimize command without any pre-configured scope.", - "type": "string", - "const": "core:window:allow-unminimize", - "markdownDescription": "Enables the unminimize command without any pre-configured scope." - }, - { - "description": "Denies the available_monitors command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-available-monitors", - "markdownDescription": "Denies the available_monitors command without any pre-configured scope." - }, - { - "description": "Denies the center command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-center", - "markdownDescription": "Denies the center command without any pre-configured scope." - }, - { - "description": "Denies the close command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-close", - "markdownDescription": "Denies the close command without any pre-configured scope." - }, - { - "description": "Denies the create command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-create", - "markdownDescription": "Denies the create command without any pre-configured scope." - }, - { - "description": "Denies the current_monitor command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-current-monitor", - "markdownDescription": "Denies the current_monitor command without any pre-configured scope." - }, - { - "description": "Denies the cursor_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-cursor-position", - "markdownDescription": "Denies the cursor_position command without any pre-configured scope." - }, - { - "description": "Denies the destroy command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-destroy", - "markdownDescription": "Denies the destroy command without any pre-configured scope." - }, - { - "description": "Denies the get_all_windows command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-get-all-windows", - "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." - }, - { - "description": "Denies the hide command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-hide", - "markdownDescription": "Denies the hide command without any pre-configured scope." - }, - { - "description": "Denies the inner_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-inner-position", - "markdownDescription": "Denies the inner_position command without any pre-configured scope." - }, - { - "description": "Denies the inner_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-inner-size", - "markdownDescription": "Denies the inner_size command without any pre-configured scope." - }, - { - "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-internal-toggle-maximize", - "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." - }, - { - "description": "Denies the is_always_on_top command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-always-on-top", - "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." - }, - { - "description": "Denies the is_closable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-closable", - "markdownDescription": "Denies the is_closable command without any pre-configured scope." - }, - { - "description": "Denies the is_decorated command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-decorated", - "markdownDescription": "Denies the is_decorated command without any pre-configured scope." - }, - { - "description": "Denies the is_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-enabled", - "markdownDescription": "Denies the is_enabled command without any pre-configured scope." - }, - { - "description": "Denies the is_focused command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-focused", - "markdownDescription": "Denies the is_focused command without any pre-configured scope." - }, - { - "description": "Denies the is_fullscreen command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-fullscreen", - "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." - }, - { - "description": "Denies the is_maximizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-maximizable", - "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." - }, - { - "description": "Denies the is_maximized command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-maximized", - "markdownDescription": "Denies the is_maximized command without any pre-configured scope." - }, - { - "description": "Denies the is_minimizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-minimizable", - "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." - }, - { - "description": "Denies the is_minimized command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-minimized", - "markdownDescription": "Denies the is_minimized command without any pre-configured scope." - }, - { - "description": "Denies the is_resizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-resizable", - "markdownDescription": "Denies the is_resizable command without any pre-configured scope." - }, - { - "description": "Denies the is_visible command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-is-visible", - "markdownDescription": "Denies the is_visible command without any pre-configured scope." - }, - { - "description": "Denies the maximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-maximize", - "markdownDescription": "Denies the maximize command without any pre-configured scope." - }, - { - "description": "Denies the minimize command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-minimize", - "markdownDescription": "Denies the minimize command without any pre-configured scope." - }, - { - "description": "Denies the monitor_from_point command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-monitor-from-point", - "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." - }, - { - "description": "Denies the outer_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-outer-position", - "markdownDescription": "Denies the outer_position command without any pre-configured scope." - }, - { - "description": "Denies the outer_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-outer-size", - "markdownDescription": "Denies the outer_size command without any pre-configured scope." - }, - { - "description": "Denies the primary_monitor command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-primary-monitor", - "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." - }, - { - "description": "Denies the request_user_attention command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-request-user-attention", - "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." - }, - { - "description": "Denies the scale_factor command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-scale-factor", - "markdownDescription": "Denies the scale_factor command without any pre-configured scope." - }, - { - "description": "Denies the set_always_on_bottom command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-always-on-bottom", - "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." - }, - { - "description": "Denies the set_always_on_top command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-always-on-top", - "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." - }, - { - "description": "Denies the set_background_color command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-background-color", - "markdownDescription": "Denies the set_background_color command without any pre-configured scope." - }, - { - "description": "Denies the set_badge_count command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-badge-count", - "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." - }, - { - "description": "Denies the set_badge_label command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-badge-label", - "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." - }, - { - "description": "Denies the set_closable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-closable", - "markdownDescription": "Denies the set_closable command without any pre-configured scope." - }, - { - "description": "Denies the set_content_protected command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-content-protected", - "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." - }, - { - "description": "Denies the set_cursor_grab command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-cursor-grab", - "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." - }, - { - "description": "Denies the set_cursor_icon command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-cursor-icon", - "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." - }, - { - "description": "Denies the set_cursor_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-cursor-position", - "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." - }, - { - "description": "Denies the set_cursor_visible command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-cursor-visible", - "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." - }, - { - "description": "Denies the set_decorations command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-decorations", - "markdownDescription": "Denies the set_decorations command without any pre-configured scope." - }, - { - "description": "Denies the set_effects command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-effects", - "markdownDescription": "Denies the set_effects command without any pre-configured scope." - }, - { - "description": "Denies the set_enabled command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-enabled", - "markdownDescription": "Denies the set_enabled command without any pre-configured scope." - }, - { - "description": "Denies the set_focus command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-focus", - "markdownDescription": "Denies the set_focus command without any pre-configured scope." - }, - { - "description": "Denies the set_focusable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-focusable", - "markdownDescription": "Denies the set_focusable command without any pre-configured scope." - }, - { - "description": "Denies the set_fullscreen command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-fullscreen", - "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." - }, - { - "description": "Denies the set_icon command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-icon", - "markdownDescription": "Denies the set_icon command without any pre-configured scope." - }, - { - "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-ignore-cursor-events", - "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." - }, - { - "description": "Denies the set_max_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-max-size", - "markdownDescription": "Denies the set_max_size command without any pre-configured scope." - }, - { - "description": "Denies the set_maximizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-maximizable", - "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." - }, - { - "description": "Denies the set_min_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-min-size", - "markdownDescription": "Denies the set_min_size command without any pre-configured scope." - }, - { - "description": "Denies the set_minimizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-minimizable", - "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." - }, - { - "description": "Denies the set_overlay_icon command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-overlay-icon", - "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." - }, - { - "description": "Denies the set_position command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-position", - "markdownDescription": "Denies the set_position command without any pre-configured scope." - }, - { - "description": "Denies the set_progress_bar command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-progress-bar", - "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." - }, - { - "description": "Denies the set_resizable command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-resizable", - "markdownDescription": "Denies the set_resizable command without any pre-configured scope." - }, - { - "description": "Denies the set_shadow command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-shadow", - "markdownDescription": "Denies the set_shadow command without any pre-configured scope." - }, - { - "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-simple-fullscreen", - "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." - }, - { - "description": "Denies the set_size command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-size", - "markdownDescription": "Denies the set_size command without any pre-configured scope." - }, - { - "description": "Denies the set_size_constraints command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-size-constraints", - "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." - }, - { - "description": "Denies the set_skip_taskbar command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-skip-taskbar", - "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." - }, - { - "description": "Denies the set_theme command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-theme", - "markdownDescription": "Denies the set_theme command without any pre-configured scope." - }, - { - "description": "Denies the set_title command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-title", - "markdownDescription": "Denies the set_title command without any pre-configured scope." - }, - { - "description": "Denies the set_title_bar_style command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-title-bar-style", - "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." - }, - { - "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-set-visible-on-all-workspaces", - "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." - }, - { - "description": "Denies the show command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-show", - "markdownDescription": "Denies the show command without any pre-configured scope." - }, - { - "description": "Denies the start_dragging command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-start-dragging", - "markdownDescription": "Denies the start_dragging command without any pre-configured scope." - }, - { - "description": "Denies the start_resize_dragging command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-start-resize-dragging", - "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." - }, - { - "description": "Denies the theme command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-theme", - "markdownDescription": "Denies the theme command without any pre-configured scope." - }, - { - "description": "Denies the title command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-title", - "markdownDescription": "Denies the title command without any pre-configured scope." - }, - { - "description": "Denies the toggle_maximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-toggle-maximize", - "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." - }, - { - "description": "Denies the unmaximize command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-unmaximize", - "markdownDescription": "Denies the unmaximize command without any pre-configured scope." - }, - { - "description": "Denies the unminimize command without any pre-configured scope.", - "type": "string", - "const": "core:window:deny-unminimize", - "markdownDescription": "Denies the unminimize command without any pre-configured scope." - }, - { - "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`", - "type": "string", - "const": "notification:default", - "markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`" - }, - { - "description": "Enables the batch command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-batch", - "markdownDescription": "Enables the batch command without any pre-configured scope." - }, - { - "description": "Enables the cancel command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-cancel", - "markdownDescription": "Enables the cancel command without any pre-configured scope." - }, - { - "description": "Enables the check_permissions command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-check-permissions", - "markdownDescription": "Enables the check_permissions command without any pre-configured scope." - }, - { - "description": "Enables the create_channel command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-create-channel", - "markdownDescription": "Enables the create_channel command without any pre-configured scope." - }, - { - "description": "Enables the delete_channel command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-delete-channel", - "markdownDescription": "Enables the delete_channel command without any pre-configured scope." - }, - { - "description": "Enables the get_active command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-get-active", - "markdownDescription": "Enables the get_active command without any pre-configured scope." - }, - { - "description": "Enables the get_pending command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-get-pending", - "markdownDescription": "Enables the get_pending command without any pre-configured scope." - }, - { - "description": "Enables the is_permission_granted command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-is-permission-granted", - "markdownDescription": "Enables the is_permission_granted command without any pre-configured scope." - }, - { - "description": "Enables the list_channels command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-list-channels", - "markdownDescription": "Enables the list_channels command without any pre-configured scope." - }, - { - "description": "Enables the notify command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-notify", - "markdownDescription": "Enables the notify command without any pre-configured scope." - }, - { - "description": "Enables the permission_state command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-permission-state", - "markdownDescription": "Enables the permission_state command without any pre-configured scope." - }, - { - "description": "Enables the register_action_types command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-register-action-types", - "markdownDescription": "Enables the register_action_types command without any pre-configured scope." - }, - { - "description": "Enables the register_listener command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-register-listener", - "markdownDescription": "Enables the register_listener command without any pre-configured scope." - }, - { - "description": "Enables the remove_active command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-remove-active", - "markdownDescription": "Enables the remove_active command without any pre-configured scope." - }, - { - "description": "Enables the request_permission command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-request-permission", - "markdownDescription": "Enables the request_permission command without any pre-configured scope." - }, - { - "description": "Enables the show command without any pre-configured scope.", - "type": "string", - "const": "notification:allow-show", - "markdownDescription": "Enables the show command without any pre-configured scope." - }, - { - "description": "Denies the batch command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-batch", - "markdownDescription": "Denies the batch command without any pre-configured scope." - }, - { - "description": "Denies the cancel command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-cancel", - "markdownDescription": "Denies the cancel command without any pre-configured scope." - }, - { - "description": "Denies the check_permissions command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-check-permissions", - "markdownDescription": "Denies the check_permissions command without any pre-configured scope." - }, - { - "description": "Denies the create_channel command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-create-channel", - "markdownDescription": "Denies the create_channel command without any pre-configured scope." - }, - { - "description": "Denies the delete_channel command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-delete-channel", - "markdownDescription": "Denies the delete_channel command without any pre-configured scope." - }, - { - "description": "Denies the get_active command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-get-active", - "markdownDescription": "Denies the get_active command without any pre-configured scope." - }, - { - "description": "Denies the get_pending command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-get-pending", - "markdownDescription": "Denies the get_pending command without any pre-configured scope." - }, - { - "description": "Denies the is_permission_granted command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-is-permission-granted", - "markdownDescription": "Denies the is_permission_granted command without any pre-configured scope." - }, - { - "description": "Denies the list_channels command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-list-channels", - "markdownDescription": "Denies the list_channels command without any pre-configured scope." - }, - { - "description": "Denies the notify command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-notify", - "markdownDescription": "Denies the notify command without any pre-configured scope." - }, - { - "description": "Denies the permission_state command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-permission-state", - "markdownDescription": "Denies the permission_state command without any pre-configured scope." - }, - { - "description": "Denies the register_action_types command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-register-action-types", - "markdownDescription": "Denies the register_action_types command without any pre-configured scope." - }, - { - "description": "Denies the register_listener command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-register-listener", - "markdownDescription": "Denies the register_listener command without any pre-configured scope." - }, - { - "description": "Denies the remove_active command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-remove-active", - "markdownDescription": "Denies the remove_active command without any pre-configured scope." - }, - { - "description": "Denies the request_permission command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-request-permission", - "markdownDescription": "Denies the request_permission command without any pre-configured scope." - }, - { - "description": "Denies the show command without any pre-configured scope.", - "type": "string", - "const": "notification:deny-show", - "markdownDescription": "Denies the show command without any pre-configured scope." - } - ] - }, - "Value": { - "description": "All supported ACL values.", - "anyOf": [ - { - "description": "Represents a null JSON value.", - "type": "null" - }, - { - "description": "Represents a [`bool`].", - "type": "boolean" - }, - { - "description": "Represents a valid ACL [`Number`].", - "allOf": [ - { - "$ref": "#/definitions/Number" - } - ] - }, - { - "description": "Represents a [`String`].", - "type": "string" - }, - { - "description": "Represents a list of other [`Value`]s.", - "type": "array", - "items": { - "$ref": "#/definitions/Value" - } - }, - { - "description": "Represents a map of [`String`] keys to [`Value`]s.", - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/Value" - } - } - ] - }, - "Number": { - "description": "A valid ACL number.", - "anyOf": [ - { - "description": "Represents an [`i64`].", - "type": "integer", - "format": "int64" - }, - { - "description": "Represents a [`f64`].", - "type": "number", - "format": "double" - } - ] - }, - "Target": { - "description": "Platform target.", - "oneOf": [ - { - "description": "MacOS.", - "type": "string", - "enum": [ - "macOS" - ] - }, - { - "description": "Windows.", - "type": "string", - "enum": [ - "windows" - ] - }, - { - "description": "Linux.", - "type": "string", - "enum": [ - "linux" - ] - }, - { - "description": "Android.", - "type": "string", - "enum": [ - "android" - ] - }, - { - "description": "iOS.", - "type": "string", - "enum": [ - "iOS" - ] - } - ] - } - } -} \ No newline at end of file diff --git a/app/src-tauri/icons/128x128.png b/app/src-tauri/icons/128x128.png deleted file mode 100644 index 9d7d642..0000000 Binary files a/app/src-tauri/icons/128x128.png and /dev/null differ diff --git a/app/src-tauri/icons/128x128@2x.png b/app/src-tauri/icons/128x128@2x.png deleted file mode 100644 index ad92470..0000000 Binary files a/app/src-tauri/icons/128x128@2x.png and /dev/null differ diff --git a/app/src-tauri/icons/32x32.png b/app/src-tauri/icons/32x32.png deleted file mode 100644 index 4e56053..0000000 Binary files a/app/src-tauri/icons/32x32.png and /dev/null differ diff --git a/app/src-tauri/icons/64x64.png b/app/src-tauri/icons/64x64.png deleted file mode 100644 index 532ec82..0000000 Binary files a/app/src-tauri/icons/64x64.png and /dev/null differ diff --git a/app/src-tauri/icons/Square107x107Logo.png b/app/src-tauri/icons/Square107x107Logo.png deleted file mode 100644 index 4387f0e..0000000 Binary files a/app/src-tauri/icons/Square107x107Logo.png and /dev/null differ diff --git a/app/src-tauri/icons/Square142x142Logo.png b/app/src-tauri/icons/Square142x142Logo.png deleted file mode 100644 index 9355541..0000000 Binary files a/app/src-tauri/icons/Square142x142Logo.png and /dev/null differ diff --git a/app/src-tauri/icons/Square150x150Logo.png b/app/src-tauri/icons/Square150x150Logo.png deleted file mode 100644 index 96598e7..0000000 Binary files a/app/src-tauri/icons/Square150x150Logo.png and /dev/null differ diff --git a/app/src-tauri/icons/Square284x284Logo.png b/app/src-tauri/icons/Square284x284Logo.png deleted file mode 100644 index ebd5211..0000000 Binary files a/app/src-tauri/icons/Square284x284Logo.png and /dev/null differ diff --git a/app/src-tauri/icons/Square30x30Logo.png b/app/src-tauri/icons/Square30x30Logo.png deleted file mode 100644 index 7c64f3f..0000000 Binary files a/app/src-tauri/icons/Square30x30Logo.png and /dev/null differ diff --git a/app/src-tauri/icons/Square310x310Logo.png b/app/src-tauri/icons/Square310x310Logo.png deleted file mode 100644 index e33822c..0000000 Binary files a/app/src-tauri/icons/Square310x310Logo.png and /dev/null differ diff --git a/app/src-tauri/icons/Square44x44Logo.png b/app/src-tauri/icons/Square44x44Logo.png deleted file mode 100644 index 5da543b..0000000 Binary files a/app/src-tauri/icons/Square44x44Logo.png and /dev/null differ diff --git a/app/src-tauri/icons/Square71x71Logo.png b/app/src-tauri/icons/Square71x71Logo.png deleted file mode 100644 index e0af5fc..0000000 Binary files a/app/src-tauri/icons/Square71x71Logo.png and /dev/null differ diff --git a/app/src-tauri/icons/Square89x89Logo.png b/app/src-tauri/icons/Square89x89Logo.png deleted file mode 100644 index 04222f4..0000000 Binary files a/app/src-tauri/icons/Square89x89Logo.png and /dev/null differ diff --git a/app/src-tauri/icons/StoreLogo.png b/app/src-tauri/icons/StoreLogo.png deleted file mode 100644 index bfc6321..0000000 Binary files a/app/src-tauri/icons/StoreLogo.png and /dev/null differ diff --git a/app/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/app/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 2ffbf24..0000000 --- a/app/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index bd7ddec..0000000 Binary files a/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png deleted file mode 100644 index 2663f74..0000000 Binary files a/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png deleted file mode 100644 index 29dad1a..0000000 Binary files a/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index fc4060e..0000000 Binary files a/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png deleted file mode 100644 index 37d540d..0000000 Binary files a/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png deleted file mode 100644 index 627c9d3..0000000 Binary files a/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index cf7d87f..0000000 Binary files a/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index 144d149..0000000 Binary files a/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png deleted file mode 100644 index e8979c1..0000000 Binary files a/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index bb98182..0000000 Binary files a/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index c0ce17d..0000000 Binary files a/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png deleted file mode 100644 index ab4d555..0000000 Binary files a/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 841d9dc..0000000 Binary files a/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index ff2009c..0000000 Binary files a/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png deleted file mode 100644 index 9671b83..0000000 Binary files a/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src-tauri/icons/android/values/ic_launcher_background.xml b/app/src-tauri/icons/android/values/ic_launcher_background.xml deleted file mode 100644 index ea9c223..0000000 --- a/app/src-tauri/icons/android/values/ic_launcher_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #fff - \ No newline at end of file diff --git a/app/src-tauri/icons/icon.ico b/app/src-tauri/icons/icon.ico deleted file mode 100644 index 2996cff..0000000 Binary files a/app/src-tauri/icons/icon.ico and /dev/null differ diff --git a/app/src-tauri/icons/icon.png b/app/src-tauri/icons/icon.png deleted file mode 100644 index 5f6e6eb..0000000 Binary files a/app/src-tauri/icons/icon.png and /dev/null differ diff --git a/app/src-tauri/icons/ios/AppIcon-20x20@1x.png b/app/src-tauri/icons/ios/AppIcon-20x20@1x.png deleted file mode 100644 index 4c3c8ad..0000000 Binary files a/app/src-tauri/icons/ios/AppIcon-20x20@1x.png and /dev/null differ diff --git a/app/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/app/src-tauri/icons/ios/AppIcon-20x20@2x-1.png deleted file mode 100644 index 158350a..0000000 Binary files a/app/src-tauri/icons/ios/AppIcon-20x20@2x-1.png and /dev/null differ diff --git a/app/src-tauri/icons/ios/AppIcon-20x20@2x.png b/app/src-tauri/icons/ios/AppIcon-20x20@2x.png deleted file mode 100644 index 158350a..0000000 Binary files a/app/src-tauri/icons/ios/AppIcon-20x20@2x.png and /dev/null differ diff --git a/app/src-tauri/icons/ios/AppIcon-20x20@3x.png b/app/src-tauri/icons/ios/AppIcon-20x20@3x.png deleted file mode 100644 index 53b0528..0000000 Binary files a/app/src-tauri/icons/ios/AppIcon-20x20@3x.png and /dev/null differ diff --git a/app/src-tauri/icons/ios/AppIcon-29x29@1x.png b/app/src-tauri/icons/ios/AppIcon-29x29@1x.png deleted file mode 100644 index c813d19..0000000 Binary files a/app/src-tauri/icons/ios/AppIcon-29x29@1x.png and /dev/null differ diff --git a/app/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/app/src-tauri/icons/ios/AppIcon-29x29@2x-1.png deleted file mode 100644 index 3b96483..0000000 Binary files a/app/src-tauri/icons/ios/AppIcon-29x29@2x-1.png and /dev/null differ diff --git a/app/src-tauri/icons/ios/AppIcon-29x29@2x.png b/app/src-tauri/icons/ios/AppIcon-29x29@2x.png deleted file mode 100644 index 3b96483..0000000 Binary files a/app/src-tauri/icons/ios/AppIcon-29x29@2x.png and /dev/null differ diff --git a/app/src-tauri/icons/ios/AppIcon-29x29@3x.png b/app/src-tauri/icons/ios/AppIcon-29x29@3x.png deleted file mode 100644 index c1900f4..0000000 Binary files a/app/src-tauri/icons/ios/AppIcon-29x29@3x.png and /dev/null differ diff --git a/app/src-tauri/icons/ios/AppIcon-40x40@1x.png b/app/src-tauri/icons/ios/AppIcon-40x40@1x.png deleted file mode 100644 index 158350a..0000000 Binary files a/app/src-tauri/icons/ios/AppIcon-40x40@1x.png and /dev/null differ diff --git a/app/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/app/src-tauri/icons/ios/AppIcon-40x40@2x-1.png deleted file mode 100644 index f27f3ef..0000000 Binary files a/app/src-tauri/icons/ios/AppIcon-40x40@2x-1.png and /dev/null differ diff --git a/app/src-tauri/icons/ios/AppIcon-40x40@2x.png b/app/src-tauri/icons/ios/AppIcon-40x40@2x.png deleted file mode 100644 index f27f3ef..0000000 Binary files a/app/src-tauri/icons/ios/AppIcon-40x40@2x.png and /dev/null differ diff --git a/app/src-tauri/icons/ios/AppIcon-40x40@3x.png b/app/src-tauri/icons/ios/AppIcon-40x40@3x.png deleted file mode 100644 index 0fc2ef7..0000000 Binary files a/app/src-tauri/icons/ios/AppIcon-40x40@3x.png and /dev/null differ diff --git a/app/src-tauri/icons/ios/AppIcon-512@2x.png b/app/src-tauri/icons/ios/AppIcon-512@2x.png deleted file mode 100644 index 928a4f3..0000000 Binary files a/app/src-tauri/icons/ios/AppIcon-512@2x.png and /dev/null differ diff --git a/app/src-tauri/icons/ios/AppIcon-60x60@2x.png b/app/src-tauri/icons/ios/AppIcon-60x60@2x.png deleted file mode 100644 index 0fc2ef7..0000000 Binary files a/app/src-tauri/icons/ios/AppIcon-60x60@2x.png and /dev/null differ diff --git a/app/src-tauri/icons/ios/AppIcon-60x60@3x.png b/app/src-tauri/icons/ios/AppIcon-60x60@3x.png deleted file mode 100644 index dce4160..0000000 Binary files a/app/src-tauri/icons/ios/AppIcon-60x60@3x.png and /dev/null differ diff --git a/app/src-tauri/icons/ios/AppIcon-76x76@1x.png b/app/src-tauri/icons/ios/AppIcon-76x76@1x.png deleted file mode 100644 index ba6d2b1..0000000 Binary files a/app/src-tauri/icons/ios/AppIcon-76x76@1x.png and /dev/null differ diff --git a/app/src-tauri/icons/ios/AppIcon-76x76@2x.png b/app/src-tauri/icons/ios/AppIcon-76x76@2x.png deleted file mode 100644 index b955873..0000000 Binary files a/app/src-tauri/icons/ios/AppIcon-76x76@2x.png and /dev/null differ diff --git a/app/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/app/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png deleted file mode 100644 index b00a4b3..0000000 Binary files a/app/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and /dev/null differ diff --git a/app/src-tauri/src/deps.rs b/app/src-tauri/src/deps.rs deleted file mode 100644 index 4d79cf2..0000000 --- a/app/src-tauri/src/deps.rs +++ /dev/null @@ -1,123 +0,0 @@ -// 启动时检查 hub 工作所必需的依赖。 -// 缺啥直接告诉前端,由 UI 给出明确的安装命令引导,避免静默失败。 - -use serde::Serialize; -use std::path::Path; -use std::process::Command; - -#[derive(Debug, Clone, Serialize)] -pub struct DepStatus { - pub key: String, - pub label: String, - pub installed: bool, - pub path: Option, - pub install_cmd: String, - pub doc_url: Option, - pub required: bool, -} - -pub fn check_all() -> Vec { - vec![ - check_command( - "tmux", - "tmux", - "终端复用器(项目窗口管理必需)", - true, - "brew install tmux", - None, - ), - check_app( - "iterm2", - "iTerm", - "iTerm2", - "终端模拟器(tmux -CC 控制模式必需)", - true, - "brew install --cask iterm2", - Some("https://iterm2.com/"), - ), - check_command( - "claude", - "claude", - "Claude Code CLI", - true, - "npm install -g @anthropic-ai/claude-code", - Some("https://claude.com/claude-code"), - ), - check_command( - "git", - "git", - "项目扫描必需(Apple Silicon 自带,通常不缺)", - false, - "xcode-select --install", - None, - ), - check_command( - "terminal-notifier", - "terminal-notifier", - "桌面通知(缺失时降级用 osascript)", - false, - "brew install terminal-notifier", - None, - ), - ] -} - -fn check_command( - key: &str, - cmd: &str, - label: &str, - required: bool, - install_cmd: &str, - doc_url: Option<&str>, -) -> DepStatus { - let path = which(cmd); - let _ = label; - DepStatus { - key: key.to_string(), - label: label.to_string(), - installed: path.is_some(), - path, - install_cmd: install_cmd.to_string(), - doc_url: doc_url.map(String::from), - required, - } -} - -fn check_app( - key: &str, - app_basename: &str, - label: &str, - _desc: &str, - required: bool, - install_cmd: &str, - doc_url: Option<&str>, -) -> DepStatus { - // 优先在 /Applications,再看 ~/Applications - let candidates = [ - format!("/Applications/{}.app", app_basename), - format!( - "{}/Applications/{}.app", - std::env::var("HOME").unwrap_or_default(), - app_basename - ), - ]; - let path = candidates.into_iter().find(|p| Path::new(p).exists()); - DepStatus { - key: key.to_string(), - label: label.to_string(), - installed: path.is_some(), - path, - install_cmd: install_cmd.to_string(), - doc_url: doc_url.map(String::from), - required, - } -} - -fn which(cmd: &str) -> Option { - let out = Command::new("/usr/bin/which").arg(cmd).output().ok()?; - if !out.status.success() { - return None; - } - let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if s.is_empty() { None } else { Some(s) } -} diff --git a/app/src-tauri/src/init.rs b/app/src-tauri/src/init.rs deleted file mode 100644 index 625eb22..0000000 --- a/app/src-tauri/src/init.rs +++ /dev/null @@ -1,164 +0,0 @@ -// 启动时自动初始化用户数据目录: -// ~/Library/Application Support/claude-hub/ -// ├── scripts/ ← 从 .app Resources 同步过来(每次启动覆盖,保证升级) -// ├── sounds/ ← 同上 -// └── projects.yaml ← 首次启动创建空模板 -// -// 这样 .app 是真正独立可分发的:装上就能用,不依赖项目仓库存在。 - -use std::fs; -use std::path::{Path, PathBuf}; -use tauri::{AppHandle, Manager}; - -const YAML_TEMPLATE: &str = "# Claude Hub 项目清单\n\ -# 用 UI 上的「扫描」按钮自动发现项目,或手动添加。\n\ -\n\ -scan_dirs:\n\ - - ~/Documents/code\n\ -\n\ -projects: []\n"; - -/// 用户数据目录:~/Library/Application Support/claude-hub -pub fn app_data_dir() -> PathBuf { - let home = std::env::var("HOME").unwrap_or_default(); - PathBuf::from(home).join("Library/Application Support/claude-hub") -} - -/// projects.yaml 路径 -pub fn yaml_path() -> PathBuf { - app_data_dir().join("projects.yaml") -} - -/// 用户目录里的 scripts 路径(.app 启动时同步过来) -pub fn scripts_dir() -> PathBuf { - app_data_dir().join("scripts") -} - -/// macOS GUI app(双击 .app)从 launchd 拿到的 PATH 只有 /usr/bin:/bin:/usr/sbin:/sbin, -/// 不含 brew、也不含用户级运行时(bun/npm/cargo 等装的工具), -/// 导致 Command::new("tmux"/"claude") 静默失败。这里启动时主动扩展, -/// 优先尝试继承用户 shell 的真实 PATH(拿到 .bun/bin 等用户自定义位置), -/// 失败再 fallback 到常见标准路径列表。 -pub fn ensure_path() { - if inherit_login_shell_path() { - return; - } - fallback_extend_path(); -} - -/// 通过跑一次 user shell 拿真实 PATH(含 ~/.bun/bin、~/.cargo/bin 等用户配置) -fn inherit_login_shell_path() -> bool { - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); - // -l 加载 ~/.zprofile,-i 加载 ~/.zshrc,覆盖大多数用户自定义 PATH 的写法 - let output = match std::process::Command::new(&shell) - .args(["-l", "-i", "-c", "echo -n $PATH"]) - .output() - { - Ok(o) if o.status.success() => o, - _ => return false, - }; - let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); - // 起码得有 4 段且包含一个常见 bin 目录才算成功,否则就 fallback - if path.split(':').count() < 4 { - return false; - } - std::env::set_var("PATH", path); - true -} - -/// fallback:把 brew + 常见用户级 bin 加进 PATH(只加实际存在的目录) -fn fallback_extend_path() { - let home = std::env::var("HOME").unwrap_or_default(); - let candidates: Vec = vec![ - // 用户级运行时 - format!("{}/.bun/bin", home), - format!("{}/.cargo/bin", home), - format!("{}/.local/bin", home), - format!("{}/.deno/bin", home), - format!("{}/.volta/bin", home), - format!("{}/.npm-global/bin", home), - format!("{}/bin", home), - // 系统级 - "/opt/homebrew/bin".to_string(), - "/opt/homebrew/sbin".to_string(), - "/usr/local/bin".to_string(), - "/usr/local/sbin".to_string(), - ]; - let extras: Vec = candidates - .into_iter() - .filter(|p| std::path::Path::new(p).exists()) - .collect(); - - let current = std::env::var("PATH").unwrap_or_default(); - let parts: Vec<&str> = current.split(':').collect(); - let to_add: Vec<&str> = extras - .iter() - .map(|s| s.as_str()) - .filter(|p| !parts.contains(p)) - .collect(); - if !to_add.is_empty() { - let new_path = if current.is_empty() { - to_add.join(":") - } else { - format!("{}:{}", to_add.join(":"), current) - }; - std::env::set_var("PATH", new_path); - } -} - -/// 启动时调用:扩展 PATH + 创建目录 + 同步嵌入的 scripts/sounds + 确保 yaml 存在 -pub fn run(app: &AppHandle) { - ensure_path(); - - let data = app_data_dir(); - if let Err(e) = fs::create_dir_all(&data) { - eprintln!("init: 创建数据目录失败: {}", e); - return; - } - - // 把 .app/Contents/Resources/scripts 和 sounds 同步到用户目录 - if let Ok(resource_dir) = app.path().resource_dir() { - sync_dir(&resource_dir.join("scripts"), &data.join("scripts"), true); - sync_dir(&resource_dir.join("sounds"), &data.join("sounds"), false); - } - - // 首次创建 projects.yaml(已存在则保留用户内容) - let yaml = data.join("projects.yaml"); - if !yaml.exists() { - let _ = fs::write(&yaml, YAML_TEMPLATE); - } -} - -/// 复制 src 下所有文件到 dst(覆盖),可选 chmod +x 让脚本可执行 -fn sync_dir(src: &Path, dst: &Path, executable: bool) { - if !src.is_dir() { - return; - } - if let Err(e) = fs::create_dir_all(dst) { - eprintln!("init: 创建 {:?} 失败: {}", dst, e); - return; - } - let entries = match fs::read_dir(src) { - Ok(e) => e, - Err(_) => return, - }; - for entry in entries.flatten() { - let from = entry.path(); - let to = dst.join(entry.file_name()); - if from.is_dir() { - sync_dir(&from, &to, executable); - continue; - } - if fs::copy(&from, &to).is_ok() && executable { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Ok(meta) = fs::metadata(&to) { - let mut perm = meta.permissions(); - perm.set_mode(0o755); - let _ = fs::set_permissions(&to, perm); - } - } - } - } -} diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs deleted file mode 100644 index 9596952..0000000 --- a/app/src-tauri/src/main.rs +++ /dev/null @@ -1,169 +0,0 @@ -// Claude Hub - 通知托盘 + 状态面板 - -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - -mod deps; -mod init; -mod projects; -mod term; -mod usage; -mod watcher; - -use serde::{Deserialize, Serialize}; -use std::sync::Mutex; -use tauri::{ - Manager, - menu::{Menu, MenuItem}, - tray::{MouseButton, TrayIconBuilder, TrayIconEvent}, -}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HubEvent { - #[serde(rename = "type")] - pub event_type: String, - pub project: String, - pub message: String, - pub timestamp: String, -} - -pub struct AppState { - pub events: Mutex>, -} - -#[tauri::command] -fn get_events(state: tauri::State) -> Vec { - state.events.lock().unwrap().clone() -} - -#[tauri::command] -fn get_projects() -> Vec { - projects::load_projects() -} - -#[tauri::command] -fn get_usage_stats(range: String) -> usage::UsageStats { - usage::compute_usage(&range) -} - -#[tauri::command] -fn focus_project(project: String) { - #[cfg(target_os = "macos")] - { - let script = format!( - r#" - tell application "iTerm2" to activate - do shell script "tmux select-window -t hub:{}" - "#, - project - ); - std::process::Command::new("osascript") - .arg("-e") - .arg(&script) - .spawn() - .ok(); - } -} - -#[tauri::command] -fn open_project(name: String, path: String, mode: String) { - projects::open_in_tmux(&name, &path, &mode); -} - -#[tauri::command] -fn scan_projects(base: Option) -> Result { - projects::scan_and_save(base) -} - -#[tauri::command] -fn reveal_data_dir() { - let dir = init::app_data_dir(); - let _ = std::process::Command::new("open").arg(dir).spawn(); -} - -#[tauri::command] -fn check_dependencies() -> Vec { - deps::check_all() -} - -#[tauri::command] -fn get_terminal_info() -> term::TerminalInfo { - term::detect_info() -} - -#[tauri::command] -fn hub_server_legacy() -> bool { - term::hub_server_owned_by_app() -} - -fn main() { - tauri::Builder::default() - .plugin(tauri_plugin_notification::init()) - .manage(AppState { - events: Mutex::new(Vec::new()), - }) - .invoke_handler(tauri::generate_handler![ - get_events, - get_projects, - get_usage_stats, - focus_project, - open_project, - scan_projects, - reveal_data_dir, - check_dependencies, - get_terminal_info, - hub_server_legacy - ]) - .setup(|app| { - // 窗口关闭 → 隐藏 - let handle = app.handle().clone(); - if let Some(window) = app.get_webview_window("main") { - window.on_window_event(move |event| { - if let tauri::WindowEvent::CloseRequested { api, .. } = event { - api.prevent_close(); - if let Some(win) = handle.get_webview_window("main") { - win.hide().ok(); - } - } - }); - } - - // 右键菜单只放"退出",左键点击负责 toggle 窗口 - let quit_item = MenuItem::with_id(app, "quit", "退出 Claude Hub", true, None::<&str>)?; - let menu = Menu::with_items(app, &[&quit_item])?; - - // 托盘图标(必须显式设置 icon,TrayIconBuilder 不继承 config) - let _tray = TrayIconBuilder::new() - .icon(tauri::image::Image::from_bytes(include_bytes!("../icons/32x32.png"))?) - .menu(&menu) - .show_menu_on_left_click(false) - .tooltip("Claude Hub") - .on_menu_event(|app, event| { - if event.id.as_ref() == "quit" { - app.exit(0); - } - }) - .on_tray_icon_event(|tray, event| { - if let TrayIconEvent::Click { - button: MouseButton::Left, - .. - } = event - { - if let Some(window) = tray.app_handle().get_webview_window("main") { - window.show().ok(); - window.set_focus().ok(); - } - } - }) - .build(app)?; - - // 自动初始化用户数据目录(首次启动 / 升级时同步嵌入的 scripts/sounds) - init::run(app.handle()); - - // 文件监听 - watcher::start_watching(app.handle().clone()); - - Ok(()) - }) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); -} diff --git a/app/src-tauri/src/projects.rs b/app/src-tauri/src/projects.rs deleted file mode 100644 index dfdb048..0000000 --- a/app/src-tauri/src/projects.rs +++ /dev/null @@ -1,314 +0,0 @@ -// 读取 projects.yaml + 检查 tmux 窗口状态 - -use crate::init; -use serde::Serialize; -use std::collections::HashSet; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; - -#[derive(Debug, Clone, Default, Serialize)] -pub struct GitInfo { - pub is_git: bool, - pub branch: String, - pub changes: u32, - pub ahead: u32, - pub behind: u32, - pub has_upstream: bool, -} - -#[derive(Debug, Clone, Serialize)] -pub struct Project { - pub name: String, - pub path: String, - pub aliases: Vec, - pub description: String, - pub running: bool, - pub git: GitInfo, -} - -/// 从 projects.yaml 读取项目列表,并检查 tmux hub 窗口状态 -pub fn load_projects() -> Vec { - let yaml_path = init::yaml_path(); - let home = std::env::var("HOME").unwrap_or_default(); - - let content = match fs::read_to_string(&yaml_path) { - Ok(c) => c, - Err(_) => return vec![], - }; - - let running_windows = get_tmux_windows(); - let mut projects = parse_yaml(&content); - - for p in &mut projects { - p.running = running_windows.contains(&p.name); - // 展开 ~ 为绝对路径 - if p.path.starts_with("~/") { - p.path = format!("{}/{}", home, &p.path[2..]); - } - p.git = collect_git_info(&p.path); - } - - projects -} - -/// 单次 git 命令获取分支、未提交数、ahead/behind -fn collect_git_info(path: &str) -> GitInfo { - let output = match Command::new("git") - .args(["-C", path, "status", "--porcelain=v2", "--branch"]) - .output() - { - Ok(o) if o.status.success() => o, - _ => return GitInfo::default(), - }; - - let mut info = GitInfo { - is_git: true, - ..Default::default() - }; - - let stdout = String::from_utf8_lossy(&output.stdout); - let mut changes: u32 = 0; - - for line in stdout.lines() { - if let Some(rest) = line.strip_prefix("# branch.head ") { - info.branch = rest.trim().to_string(); - } else if line.starts_with("# branch.upstream ") { - info.has_upstream = true; - } else if let Some(rest) = line.strip_prefix("# branch.ab ") { - // 格式:+ - - let mut parts = rest.split_whitespace(); - if let Some(a) = parts.next() { - info.ahead = a.trim_start_matches('+').parse().unwrap_or(0); - } - if let Some(b) = parts.next() { - info.behind = b.trim_start_matches('-').parse().unwrap_or(0); - } - } else if !line.starts_with('#') && !line.is_empty() { - changes += 1; - } - } - - info.changes = changes; - info -} - -/// 获取 tmux hub session 的窗口名列表 -fn get_tmux_windows() -> Vec { - let output = Command::new("tmux") - .args(["list-windows", "-t", "hub", "-F", "#{window_name}"]) - .output(); - - match output { - Ok(o) if o.status.success() => { - String::from_utf8_lossy(&o.stdout) - .lines() - .map(|s| s.to_string()) - .collect() - } - _ => vec![], - } -} - -/// 简单解析 projects.yaml(不依赖 yaml crate) -fn parse_yaml(content: &str) -> Vec { - let mut projects = Vec::new(); - let mut current: Option = None; - - for line in content.lines() { - let trimmed = line.trim(); - - // 新项目开始 - if trimmed.starts_with("- name:") { - if let Some(p) = current.take() { - projects.push(p); - } - let name = trimmed.trim_start_matches("- name:").trim().to_string(); - current = Some(Project { - name, - path: String::new(), - aliases: vec![], - description: String::new(), - running: false, - git: GitInfo::default(), - }); - } else if let Some(ref mut p) = current { - if trimmed.starts_with("path:") { - p.path = trimmed.trim_start_matches("path:").trim().to_string(); - } else if trimmed.starts_with("description:") { - p.description = trimmed - .trim_start_matches("description:") - .trim() - .trim_matches('"') - .to_string(); - } else if trimmed.starts_with("aliases:") { - let aliases_str = trimmed.trim_start_matches("aliases:").trim(); - if aliases_str.starts_with('[') && aliases_str.ends_with(']') { - p.aliases = aliases_str[1..aliases_str.len() - 1] - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - } - } - } - } - - if let Some(p) = current { - projects.push(p); - } - - projects -} - -/// 在 tmux hub 中打开项目 -pub fn open_in_tmux(name: &str, path: &str, mode: &str) { - use crate::term; - - // finder / vscode:不需要 tmux - if mode == "finder" { - Command::new("open").arg(path).status().ok(); - return; - } - if mode == "vscode" { - Command::new("code").arg(path).status().ok(); - return; - } - - let scripts_dir = init::scripts_dir(); - let menu_script = scripts_dir - .join("project-menu.sh") - .to_string_lossy() - .to_string(); - - // 拼接要在 tmux window 内执行的命令(None = 不跑命令,只开 shell) - let inner_cmd: Option = match mode { - "claude" => Some(format!("cd '{}' && claude", path)), - "claude-skip" => Some(format!( - "cd '{}' && claude --dangerously-skip-permissions", - path - )), - "terminal" => None, - "menu" => Some(format!("bash '{}' '{}' '{}'", menu_script, name, path)), - _ => return, - }; - - let hub_exists = Command::new("tmux") - .args(["has-session", "-t", "hub"]) - .status() - .map(|s| s.success()) - .unwrap_or(false); - - if !hub_exists { - // 关键:第一次创建 hub session 必须由终端跑, - // 这样 tmux server 父进程是终端,claude 子进程能复用终端的 Keychain ACL。 - term::create_hub_session(name, path, inner_cmd.as_deref()); - return; - } - - // session 已存在 → client 操作(.app 直接跑),不影响 server 进程链 - let mut args: Vec<&str> = vec!["new-window", "-t", "hub", "-n", name, "-c", path]; - let cmd_owned; - if let Some(c) = inner_cmd { - cmd_owned = c; - args.push(&cmd_owned); - } - Command::new("tmux").args(&args).status().ok(); - - // 终端是否已连到 hub?没连就让终端 attach - let has_client = Command::new("tmux") - .args(["list-clients", "-t", "hub"]) - .output() - .map(|o| !String::from_utf8_lossy(&o.stdout).trim().is_empty()) - .unwrap_or(false); - if !has_client { - term::attach_existing_hub(); - } -} - -/// 扫描指定目录下的 git 仓库,把没出现在 yaml 里的项目追加进去 -/// base 为 None 时默认 ~/Documents/code -pub fn scan_and_save(base: Option) -> Result { - let home = std::env::var("HOME").unwrap_or_default(); - let base_dir = base - .map(|s| { - if s.starts_with("~/") { - format!("{}/{}", home, &s[2..]) - } else { - s - } - }) - .unwrap_or_else(|| format!("{}/Documents/code", home)); - - let mut found: Vec<(String, String)> = Vec::new(); - walk_for_git(&PathBuf::from(&base_dir), 0, &mut found); - - let yaml_path = init::yaml_path(); - let existing = fs::read_to_string(&yaml_path).unwrap_or_default(); - - // 收集已有项目的 path,避免重复添加 - let mut existing_paths: HashSet = HashSet::new(); - for line in existing.lines() { - let t = line.trim_start(); - if let Some(rest) = t.strip_prefix("path:") { - existing_paths.insert(rest.trim().to_string()); - } - } - - let mut added = 0; - let mut additions = String::new(); - for (name, path) in &found { - if existing_paths.contains(path) { - continue; - } - additions.push_str(&format!( - "\n - name: {}\n aliases: []\n path: {}\n description: \"\"\n tags: []\n", - name, path - )); - added += 1; - } - - if added > 0 { - let mut content = if existing.trim().is_empty() { - "# Claude Hub 项目清单\n\nprojects:\n".to_string() - } else if !existing.contains("projects:") { - format!("{}\nprojects:\n", existing.trim_end()) - } else { - existing - }; - content.push_str(&additions); - fs::write(&yaml_path, content).map_err(|e| e.to_string())?; - } - - Ok(added) -} - -fn walk_for_git(dir: &Path, depth: u8, out: &mut Vec<(String, String)>) { - if depth > 3 || !dir.is_dir() { - return; - } - if dir.join(".git").exists() { - if let Some(name) = dir.file_name().and_then(|n| n.to_str()) { - out.push((name.to_string(), dir.to_string_lossy().to_string())); - } - return; - } - let entries = match fs::read_dir(dir) { - Ok(e) => e, - Err(_) => return, - }; - for entry in entries.flatten() { - let p = entry.path(); - if !p.is_dir() { - continue; - } - let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(""); - if name.starts_with('.') - || matches!(name, "node_modules" | "target" | "dist" | "build" | "vendor") - { - continue; - } - walk_for_git(&p, depth + 1, out); - } -} - diff --git a/app/src-tauri/src/term.rs b/app/src-tauri/src/term.rs deleted file mode 100644 index e0b2f5c..0000000 --- a/app/src-tauri/src/term.rs +++ /dev/null @@ -1,214 +0,0 @@ -// 终端检测与派发:让 tmux server 由用户终端创建, -// claude 进程链祖先就是终端,能复用终端在 macOS Keychain 上的 ACL。 -// -// 核心约定: -// - 第一次创建 hub session(new-session)必须由终端跑 → tmux server 父进程是终端 -// - 后续 new-window / send-keys 是 client 操作,谁跑都行(不影响进程链) - -use serde::Serialize; -use std::path::Path; -use std::process::Command; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -pub enum TerminalKind { - ITerm, - Warp, - WezTerm, - Terminal, // /System/Applications/Utilities/Terminal.app(系统自带,永远可用) -} - -impl TerminalKind { - pub fn supported(self) -> bool { - matches!(self, TerminalKind::ITerm | TerminalKind::Terminal) - } -} - -#[derive(Serialize)] -pub struct TerminalInfo { - pub kind: String, - pub label: String, - pub path: String, - pub supported: bool, -} - -/// 按优先级查找已安装的终端,跳过未支持的。 -/// 优先级:iTerm > Warp > WezTerm > Terminal -pub fn detect() -> TerminalKind { - let candidates = [ - TerminalKind::ITerm, - TerminalKind::Warp, - TerminalKind::WezTerm, - ]; - for kind in candidates { - if installed_path(kind).is_some() && kind.supported() { - return kind; - } - } - TerminalKind::Terminal // 系统自带,永远兜底 -} - -pub fn detect_info() -> TerminalInfo { - let kind = detect(); - let path = installed_path(kind).unwrap_or_else(|| match kind { - TerminalKind::Terminal => "/System/Applications/Utilities/Terminal.app".to_string(), - _ => String::new(), - }); - let label = match kind { - TerminalKind::ITerm => "iTerm.app", - TerminalKind::Warp => "Warp.app(暂不支持)", - TerminalKind::WezTerm => "WezTerm.app(暂不支持)", - TerminalKind::Terminal => "Terminal.app", - }; - TerminalInfo { - kind: kind_str(kind).to_string(), - label: label.to_string(), - path, - supported: kind.supported(), - } -} - -fn kind_str(k: TerminalKind) -> &'static str { - match k { - TerminalKind::ITerm => "iterm", - TerminalKind::Warp => "warp", - TerminalKind::WezTerm => "wezterm", - TerminalKind::Terminal => "terminal", - } -} - -fn installed_path(kind: TerminalKind) -> Option { - let bundle = match kind { - TerminalKind::ITerm => "iTerm.app", - TerminalKind::Warp => "Warp.app", - TerminalKind::WezTerm => "WezTerm.app", - TerminalKind::Terminal => return Some("/System/Applications/Utilities/Terminal.app".into()), - }; - let home = std::env::var("HOME").unwrap_or_default(); - let candidates = [ - format!("/Applications/{}", bundle), - format!("{}/Applications/{}", home, bundle), - ]; - candidates.into_iter().find(|p| Path::new(p).exists()) -} - -/// 通过当前终端跑一条 shell 命令。命令会被 zsh/bash 解析。 -pub fn run_in_terminal(cmd: &str) { - let kind = detect(); - let escaped = cmd.replace('\\', "\\\\").replace('"', "\\\""); - let script = match kind { - TerminalKind::ITerm => format!( - r#" - tell application "iTerm2" - activate - if (count of windows) = 0 then - create window with default profile - else - tell current window - create tab with default profile - end tell - end if - tell current session of current window - write text "{}" - end tell - end tell - "#, - escaped - ), - // Warp/WezTerm 暂未实现,fallback 到 Terminal.app - _ => format!( - r#" - tell application "Terminal" - activate - do script "{}" - end tell - "#, - escaped - ), - }; - let _ = Command::new("osascript").arg("-e").arg(&script).status(); -} - -/// 第一次创建 hub session(必须由终端跑)+ attach。 -/// 这样 tmux server 的父进程是终端,claude 子进程能复用终端的 Keychain ACL。 -pub fn create_hub_session(window_name: &str, work_dir: &str, inner_cmd: Option<&str>) { - let kind = detect(); - let create = if let Some(inner) = inner_cmd { - format!( - "tmux new-session -d -s hub -n {} -c {} {}", - sh_quote(window_name), - sh_quote(work_dir), - sh_quote(inner) - ) - } else { - format!( - "tmux new-session -d -s hub -n {} -c {}", - sh_quote(window_name), - sh_quote(work_dir) - ) - }; - let attach = match kind { - TerminalKind::ITerm => "tmux -CC attach -t hub", - _ => "tmux attach -t hub", - }; - run_in_terminal(&format!("{} && {}", create, attach)); -} - -/// 让终端 attach 到已存在的 hub session -pub fn attach_existing_hub() { - let kind = detect(); - let cmd = match kind { - TerminalKind::ITerm => "tmux -CC attach -t hub", - _ => "tmux attach -t hub", - }; - run_in_terminal(cmd); -} - -/// 单引号包裹做 shell 转义 -fn sh_quote(s: &str) -> String { - format!("'{}'", s.replace('\'', "'\\''")) -} - -/// 检测当前 hub tmux server 是否由 Claude Hub.app 创建(旧版本遗留)。 -/// 返回 true 表示需要提示用户 kill-server 重新由终端创建。 -pub fn hub_server_owned_by_app() -> bool { - // 拿 hub session 客户端列表为空 + server pid 的父进程名是 "claude-hub" 才判定 - // 简单做法:找 tmux server 进程,看它的父进程 name - let out = match Command::new("/bin/ps") - .args(["-A", "-o", "pid=,ppid=,command="]) - .output() - { - Ok(o) => o, - Err(_) => return false, - }; - let text = String::from_utf8_lossy(&out.stdout); - let mut tmux_ppid: Option<&str> = None; - for line in text.lines() { - let trimmed = line.trim_start(); - // 找 tmux server,命令含 "tmux" 且不含 "tmux attach" 等 client 标记 - // 实际 server 进程命令名通常是 "tmux: server" 或 "tmux" - if (trimmed.contains("tmux: server") || trimmed.contains("tmux new-session")) - && !trimmed.contains("attach") - { - let mut it = trimmed.split_whitespace(); - it.next(); - if let Some(ppid) = it.next() { - tmux_ppid = Some(ppid); - break; - } - } - } - let ppid = match tmux_ppid { - Some(p) => p, - None => return false, - }; - for line in text.lines() { - let mut it = line.trim_start().split_whitespace(); - if let Some(pid) = it.next() { - if pid == ppid { - return line.to_lowercase().contains("claude-hub") - || line.contains("Claude Hub.app"); - } - } - } - false -} diff --git a/app/src-tauri/src/usage.rs b/app/src-tauri/src/usage.rs deleted file mode 100644 index 047a8e7..0000000 --- a/app/src-tauri/src/usage.rs +++ /dev/null @@ -1,309 +0,0 @@ -// 扫描 ~/.claude/projects/ 下的会话 JSONL,按项目统计 token 使用量 - -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::fs; -use std::io::{BufRead, BufReader}; -use std::path::PathBuf; - -#[derive(Debug, Clone, Serialize)] -pub struct ModelUsage { - pub model: String, - pub display_name: String, - pub message_count: u32, - pub output_tokens: u64, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ProjectUsage { - pub name: String, - pub path: String, - pub session_count: u32, - pub message_count: u32, - pub input_tokens: u64, - pub output_tokens: u64, - pub cache_read_tokens: u64, - pub cache_create_tokens: u64, - pub last_active: String, - pub models: Vec, -} - -#[derive(Debug, Clone, Serialize)] -pub struct UsageStats { - pub projects: Vec, - pub total_sessions: u32, - pub total_messages: u32, - pub total_input_tokens: u64, - pub total_output_tokens: u64, -} - -#[derive(Deserialize)] -struct JLine { - #[serde(rename = "type")] - line_type: Option, - message: Option, - timestamp: Option, -} - -#[derive(Deserialize)] -struct JMessage { - model: Option, - usage: Option, -} - -#[derive(Deserialize)] -struct JUsage { - input_tokens: Option, - output_tokens: Option, - cache_read_input_tokens: Option, - cache_creation_input_tokens: Option, -} - -/// 从 projects.yaml 构建路径 → 项目名的映射 -fn build_path_map() -> HashMap { - let home = std::env::var("HOME").unwrap_or_default(); - let yaml_path = format!("{}/Documents/code/claude-hub/projects.yaml", home); - let content = match fs::read_to_string(&yaml_path) { - Ok(c) => c, - Err(_) => return HashMap::new(), - }; - - let mut map = HashMap::new(); - let mut current_name = String::new(); - - for line in content.lines() { - let trimmed = line.trim(); - if trimmed.starts_with("- name:") { - current_name = trimmed.trim_start_matches("- name:").trim().to_string(); - } else if trimmed.starts_with("path:") && !current_name.is_empty() { - let mut path = trimmed.trim_start_matches("path:").trim().to_string(); - if path.starts_with("~/") { - path = format!("{}/{}", home, &path[2..]); - } - // 编码路径:/Users/dev/Documents/code/xxx → -Users-dev-Documents-code-xxx - let encoded = path.replace('/', "-"); - // 去掉开头的 - - let encoded = encoded.trim_start_matches('-').to_string(); - map.insert(encoded, current_name.clone()); - } - } - - map -} - -/// 将目录名解码为可读路径 -fn decode_dir_name(dir_name: &str) -> String { - format!("/{}", dir_name.replace('-', "/")) -} - -/// 计算截止日期 -fn cutoff_date(range: &str) -> Option { - let now = chrono::Utc::now(); - let days = match range { - "7d" => 7, - "30d" => 30, - _ => return None, - }; - let cutoff = now - chrono::Duration::days(days); - Some(cutoff.format("%Y-%m-%dT%H:%M:%S").to_string()) -} - -/// 将模型 ID 转为人类可读名称 -fn format_model_name(model_id: &str) -> String { - match model_id { - s if s.starts_with("claude-opus-4-6") => "Opus 4.6".to_string(), - s if s.starts_with("claude-opus-4-5") => "Opus 4.5".to_string(), - s if s.starts_with("claude-sonnet-4-6") => "Sonnet 4.6".to_string(), - s if s.starts_with("claude-sonnet-4-5") => "Sonnet 4.5".to_string(), - s if s.starts_with("claude-haiku-4-5") => "Haiku 4.5".to_string(), - s if s.starts_with("claude-3-5-sonnet") => "Sonnet 3.5".to_string(), - s if s.starts_with("claude-3-5-haiku") => "Haiku 3.5".to_string(), - s if s.starts_with("claude-3-opus") => "Opus 3".to_string(), - _ => model_id.to_string(), - } -} - -pub fn compute_usage(range: &str) -> UsageStats { - let home = std::env::var("HOME").unwrap_or_default(); - let projects_dir = PathBuf::from(&home).join(".claude").join("projects"); - let path_map = build_path_map(); - let cutoff = cutoff_date(range); - - let mut project_usages: HashMap = HashMap::new(); - // 按项目 → 模型 → (message_count, output_tokens) 聚合 - let mut project_models: HashMap> = HashMap::new(); - - let entries = match fs::read_dir(&projects_dir) { - Ok(e) => e, - Err(_) => { - return UsageStats { - projects: vec![], - total_sessions: 0, - total_messages: 0, - total_input_tokens: 0, - total_output_tokens: 0, - } - } - }; - - for entry in entries.flatten() { - let entry_path = entry.path(); - if !entry_path.is_dir() { - continue; - } - - let dir_name = entry.file_name().to_string_lossy().to_string(); - - // 匹配项目名 - let project_name = path_map - .get(&dir_name) - .cloned() - .unwrap_or_else(|| { - // 尝试从目录名提取最后一段作为项目名 - dir_name.rsplit('-').next().unwrap_or(&dir_name).to_string() - }); - - let decoded_path = decode_dir_name(&dir_name); - - // 扫描 JSONL 文件 - let jsonl_files: Vec = match fs::read_dir(&entry_path) { - Ok(entries) => entries - .flatten() - .filter(|e| { - e.path() - .extension() - .is_some_and(|ext| ext == "jsonl") - }) - .map(|e| e.path()) - .collect(), - Err(_) => continue, - }; - - if jsonl_files.is_empty() { - continue; - } - - let usage = project_usages.entry(dir_name.clone()).or_insert(ProjectUsage { - name: project_name, - path: decoded_path, - session_count: 0, - message_count: 0, - input_tokens: 0, - output_tokens: 0, - cache_read_tokens: 0, - cache_create_tokens: 0, - last_active: String::new(), - models: vec![], - }); - - let models = project_models.entry(dir_name.clone()).or_default(); - - for jsonl_path in &jsonl_files { - let file = match fs::File::open(jsonl_path) { - Ok(f) => f, - Err(_) => continue, - }; - - let mut session_has_data = false; - let reader = BufReader::new(file); - - for line in reader.lines() { - let line = match line { - Ok(l) => l, - Err(_) => continue, - }; - - // 快速过滤:只解析包含 "type":"assistant" 的行 - if !line.contains("\"type\":\"assistant\"") { - continue; - } - - let parsed: JLine = match serde_json::from_str(&line) { - Ok(p) => p, - Err(_) => continue, - }; - - if parsed.line_type.as_deref() != Some("assistant") { - continue; - } - - // 时间范围过滤 - if let Some(ref cutoff_str) = cutoff { - if let Some(ref ts) = parsed.timestamp { - if ts.as_str() < cutoff_str.as_str() { - continue; - } - } - } - - if let Some(msg) = parsed.message { - if let Some(u) = msg.usage { - session_has_data = true; - usage.message_count += 1; - let out_tokens = u.output_tokens.unwrap_or(0); - usage.input_tokens += u.input_tokens.unwrap_or(0); - usage.output_tokens += out_tokens; - usage.cache_read_tokens += u.cache_read_input_tokens.unwrap_or(0); - usage.cache_create_tokens += u.cache_creation_input_tokens.unwrap_or(0); - - // 按模型聚合 - if let Some(ref model_id) = msg.model { - let entry = models.entry(model_id.clone()).or_insert((0, 0)); - entry.0 += 1; - entry.1 += out_tokens; - } - - // 更新最后活跃时间 - if let Some(ref ts) = parsed.timestamp { - if ts.as_str() > usage.last_active.as_str() { - usage.last_active = ts.clone(); - } - } - } - } - } - - if session_has_data { - usage.session_count += 1; - } - } - } - - // 将模型数据填入项目 - for (dir_name, model_map) in &project_models { - if let Some(usage) = project_usages.get_mut(dir_name) { - let mut model_list: Vec = model_map - .iter() - .map(|(model_id, (count, tokens))| ModelUsage { - model: model_id.clone(), - display_name: format_model_name(model_id), - message_count: *count, - output_tokens: *tokens, - }) - .collect(); - // 按 output_tokens 降序 - model_list.sort_by(|a, b| b.output_tokens.cmp(&a.output_tokens)); - usage.models = model_list; - } - } - - // 收集并排序(按 output_tokens 降序) - let mut projects: Vec = project_usages - .into_values() - .filter(|p| p.message_count > 0) - .collect(); - projects.sort_by(|a, b| b.output_tokens.cmp(&a.output_tokens)); - - let total_sessions = projects.iter().map(|p| p.session_count).sum(); - let total_messages = projects.iter().map(|p| p.message_count).sum(); - let total_input_tokens = projects.iter().map(|p| p.input_tokens).sum(); - let total_output_tokens = projects.iter().map(|p| p.output_tokens).sum(); - - UsageStats { - projects, - total_sessions, - total_messages, - total_input_tokens, - total_output_tokens, - } -} diff --git a/app/src-tauri/src/watcher.rs b/app/src-tauri/src/watcher.rs deleted file mode 100644 index a53d2f2..0000000 --- a/app/src-tauri/src/watcher.rs +++ /dev/null @@ -1,123 +0,0 @@ -// 监听 /tmp/hub-signals/events/ 目录 -// 新 JSON 文件出现 → 读取 → 发通知 → 推送到前端 - -use crate::{AppState, HubEvent}; -use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; -use std::fs; -use std::path::Path; -use std::sync::mpsc; -use std::thread; -use tauri::{AppHandle, Emitter, Manager}; -use tauri_plugin_notification::NotificationExt; - -const EVENTS_DIR: &str = "/tmp/hub-signals/events"; - -pub fn start_watching(app: AppHandle) { - // 确保目录存在 - fs::create_dir_all(EVENTS_DIR).ok(); - - thread::spawn(move || { - let (tx, rx) = mpsc::channel(); - - let mut watcher = RecommendedWatcher::new(tx, Config::default()) - .expect("Failed to create watcher"); - - watcher - .watch(Path::new(EVENTS_DIR), RecursiveMode::NonRecursive) - .expect("Failed to watch events directory"); - - // 处理文件事件 - for result in rx { - match result { - Ok(event) => { - if matches!(event.kind, EventKind::Create(_)) { - for path in event.paths { - if path.extension().map_or(false, |e| e == "json") { - handle_event_file(&app, &path); - } - } - } - } - Err(e) => eprintln!("Watch error: {}", e), - } - } - }); -} - -fn handle_event_file(app: &AppHandle, path: &Path) { - // 读取 JSON - let content = match fs::read_to_string(path) { - Ok(c) => c, - Err(_) => return, - }; - - let event: HubEvent = match serde_json::from_str(&content) { - Ok(e) => e, - Err(_) => return, - }; - - // 发系统通知 - send_notification(app, &event); - - // 推送到前端面板 - app.emit("hub-event", &event).ok(); - - // 保存到状态 - if let Some(state) = app.try_state::() { - let mut events = state.events.lock().unwrap(); - events.insert(0, event); - if events.len() > 50 { - events.truncate(50); - } - } - - // 删除已处理的文件 - fs::remove_file(path).ok(); -} - -fn send_notification(app: &AppHandle, event: &HubEvent) { - let (title, _icon) = match event.event_type.as_str() { - "stop" => (format!("✅ {}", event.project), "done"), - "permission" => (format!("⚠️ {} 需要授权", event.project), "attention"), - _ => (event.project.clone(), "info"), - }; - - app.notification() - .builder() - .title(&title) - .body(&event.message) - .show() - .ok(); - - // macOS: 播放语音 - #[cfg(target_os = "macos")] - { - let hub_dir = std::env::var("HOME") - .map(|h| std::path::PathBuf::from(h).join("Documents/code/claude-hub/sounds")) - .unwrap_or_default(); - - let pattern = if event.event_type == "stop" { "done" } else { "auth" }; - if let Ok(entries) = fs::read_dir(&hub_dir) { - let files: Vec<_> = entries - .filter_map(|e| e.ok()) - .filter(|e| { - e.file_name() - .to_string_lossy() - .starts_with(pattern) - }) - .collect(); - - if !files.is_empty() { - let idx = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos() as usize % files.len()) - .unwrap_or(0); - - std::process::Command::new("afplay") - .arg(files[idx].path()) - .spawn() - .ok(); - } - } - } -} diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json deleted file mode 100644 index f7ec002..0000000 --- a/app/src-tauri/tauri.conf.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/nicholasxjy/nicholasxjy.github.io/refs/heads/main/tauri-v2-schema.json", - "productName": "Claude Hub", - "version": "0.1.0", - "identifier": "dev.hengjun.claude-hub", - "build": { - "frontendDist": "../src" - }, - "app": { - "withGlobalTauri": true, - "windows": [ - { - "title": "Claude Hub", - "width": 360, - "height": 480, - "resizable": true, - "visible": true, - "decorations": true, - "transparent": false - } - ] - }, - "plugins": {}, - "bundle": { - "active": true, - "targets": ["app", "dmg"], - "icon": [ - "icons/32x32.png", - "icons/128x128.png", - "icons/128x128@2x.png", - "icons/icon.icns", - "icons/icon.ico" - ], - "category": "DeveloperTool", - "shortDescription": "Claude Hub 调度中心", - "longDescription": "本地开发调度中心:tmux 项目窗口管理 + Claude 通知聚合 + 用量统计", - "resources": { - "../../scripts": "scripts", - "../../sounds": "sounds" - } - } -} diff --git a/app/src/index.html b/app/src/index.html deleted file mode 100644 index 18c0831..0000000 --- a/app/src/index.html +++ /dev/null @@ -1,68 +0,0 @@ - - - - - - Claude Hub - - - -
-
-

Claude Hub

-
- - -
- 就绪 -
- - - - - -
- - -
-
加载中...
-
-
- - - - -
-

最近通知

-
-
等待通知...
-
-
-
- - - - - - - diff --git a/app/src/main.js b/app/src/main.js deleted file mode 100644 index ebf2f61..0000000 --- a/app/src/main.js +++ /dev/null @@ -1,517 +0,0 @@ -// Claude Hub 状态面板 - -const { listen } = window.__TAURI__.event; -const { invoke } = window.__TAURI__.core; - -const state = { - projects: [], - events: [], - menuTarget: null, // 当前弹出菜单的项目 - searchQuery: '', - currentTab: 'projects', - usageRange: 'all', - usageData: null, -}; - -async function init() { - // 监听 Rust 后端事件 - await listen('hub-event', (event) => { - handleEvent(event.payload); - }); - - // 启动时跑一次依赖检查 - refreshDeps(); - - // 加载项目列表 - await refreshProjects(); - - // 加载历史事件 - try { - const history = await invoke('get_events'); - history.forEach(handleEvent); - } catch (e) {} - - // 每 5 秒刷新项目状态 - setInterval(refreshProjects, 5000); - - // 搜索框 - const searchInput = document.getElementById('searchInput'); - searchInput.addEventListener('input', (e) => { - state.searchQuery = e.target.value.toLowerCase(); - renderProjects(); - }); - - // 点击空白处关闭菜单 - document.addEventListener('click', (e) => { - if (!e.target.closest('.context-menu') && !e.target.closest('.project-item')) { - closeMenu(); - } - }); -} - -async function refreshProjects() { - try { - state.projects = await invoke('get_projects'); - if (state.currentTab === 'projects') { - renderProjects(); - } - updateGlobalStatus(); - } catch (e) {} -} - -function handleEvent(data) { - // 更新对应项目的最新状态 - const proj = state.projects.find(p => p.name === data.project); - if (proj) { - proj.lastEvent = data; - } - - state.events.unshift(data); - if (state.events.length > 20) state.events.pop(); - - renderProjects(); - renderEvents(); - updateGlobalStatus(); -} - -// Tab 切换 -function switchTab(tab) { - state.currentTab = tab; - document.querySelectorAll('.tab').forEach(t => { - t.classList.toggle('active', t.dataset.tab === tab); - }); - document.getElementById('projectsView').style.display = tab === 'projects' ? '' : 'none'; - document.getElementById('usageView').style.display = tab === 'usage' ? '' : 'none'; - - if (tab === 'usage' && !state.usageData) { - loadUsage(state.usageRange); - } -} - -// 加载用量数据 -async function loadUsage(range) { - state.usageRange = range; - - // 更新按钮状态 - document.querySelectorAll('.range-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.range === range); - }); - - document.getElementById('usageList').innerHTML = '
统计中...
'; - - try { - state.usageData = await invoke('get_usage_stats', { range }); - renderUsage(); - } catch (e) { - document.getElementById('usageList').innerHTML = '
加载失败
'; - } -} - -function renderUsage() { - const data = state.usageData; - if (!data) return; - - // 汇总 - const summary = document.getElementById('usageSummary'); - summary.innerHTML = ` -
- ${data.total_sessions} - 会话 -
-
- ${formatNum(data.total_messages)} - 消息 -
-
- ${formatTokens(data.total_input_tokens)} - 输入 -
-
- ${formatTokens(data.total_output_tokens)} - 输出 -
- `; - - // 排行榜 - const list = document.getElementById('usageList'); - if (data.projects.length === 0) { - list.innerHTML = '
无数据
'; - return; - } - - const maxTokens = data.projects[0].output_tokens || 1; - - list.innerHTML = data.projects.map(p => { - const pct = Math.max(2, (p.output_tokens / maxTokens) * 100); - const lastTime = p.last_active ? formatDate(p.last_active) : ''; - - const modelTags = (p.models || []).map(m => { - return `${m.display_name} ${formatTokens(m.output_tokens)}`; - }).join(''); - - return ` -
-
- ${p.name} - ${formatTokens(p.output_tokens)} -
-
-
-
-
- ${p.session_count} 会话 · ${formatNum(p.message_count)} 消息 - ${lastTime} -
- ${modelTags ? `
${modelTags}
` : ''} -
- `; - }).join(''); -} - -function renderProjects() { - const list = document.getElementById('projectList'); - - if (state.projects.length === 0) { - list.innerHTML = ` -
-
还没添加项目
-
点击下方按钮自动扫描 ~/Documents/code 下的 git 仓库
-
- - -
-
- `; - return; - } - - // 搜索过滤(匹配名称、描述、路径) - const filtered = state.projects.filter(p => { - if (!state.searchQuery) return true; - const q = state.searchQuery; - return (p.name || '').toLowerCase().includes(q) - || (p.description || '').toLowerCase().includes(q) - || (p.path || '').toLowerCase().includes(q); - }); - - if (filtered.length === 0 && state.searchQuery) { - list.innerHTML = '
无匹配项目
'; - return; - } - - // 运行中的排前面 - const sorted = [...filtered].sort((a, b) => { - if (a.running !== b.running) return b.running ? 1 : -1; - return 0; - }); - - list.innerHTML = sorted.map(p => { - const statusClass = p.running ? 'running' : 'idle'; - const lastEvent = p.lastEvent; - let statusText = p.running ? '运行中' : ''; - let timeText = ''; - - if (lastEvent) { - statusText = lastEvent.type === 'stop' ? '完成' : '等待授权'; - timeText = formatTime(lastEvent.timestamp); - } - - return ` -
-
-
-
- ${p.name} - ${renderGitBadge(p.git)} -
- ${p.description || ''} -
-
- ${statusText} - ${timeText} -
-
- `; - }).join(''); - - // 绑定点击 - list.querySelectorAll('.project-item').forEach(el => { - el.addEventListener('click', (e) => { - const name = el.dataset.name; - const path = el.dataset.path; - const running = el.dataset.running === 'true'; - - if (running) { - invoke('focus_project', { project: name }); - } else { - showMenu(e, name, path); - } - }); - - // 右键:无论运行与否都弹操作菜单(打开 Finder/VS Code/再开窗口 等) - el.addEventListener('contextmenu', (e) => { - e.preventDefault(); - showMenu(e, el.dataset.name, el.dataset.path); - }); - }); -} - -function renderGitBadge(git) { - if (!git || !git.is_git) { - return '无 git'; - } - const isClean = git.changes === 0; - const statusClass = isClean ? 'clean' : 'dirty'; - const statusIcon = isClean ? '✓' : '●'; - const statusText = isClean ? '干净' : `${git.changes}`; - const statusTitle = isClean ? '工作区干净' : `${git.changes} 个未提交变更`; - - let extra = ''; - if (git.ahead > 0) extra += `↑${git.ahead}`; - if (git.behind > 0) extra += `↓${git.behind}`; - if (!git.has_upstream && git.branch) { - extra += `无远端`; - } - - return ` - ${git.branch || '?'} - ${statusIcon} ${statusText} - ${extra} - `; -} - -function showMenu(event, name, path) { - closeMenu(); - state.menuTarget = { name, path }; - - const menu = document.getElementById('contextMenu'); - menu.style.display = 'block'; - - // 定位到点击位置 - const rect = event.currentTarget.getBoundingClientRect(); - menu.style.top = rect.bottom + 4 + 'px'; - menu.style.left = rect.left + 'px'; - - event.stopPropagation(); -} - -function closeMenu() { - document.getElementById('contextMenu').style.display = 'none'; - state.menuTarget = null; -} - -function menuAction(mode) { - if (!state.menuTarget) return; - const { name, path } = state.menuTarget; - invoke('open_project', { name, path, mode }); - closeMenu(); - - // 延迟刷新 - setTimeout(refreshProjects, 2000); -} - -function renderEvents() { - const list = document.getElementById('eventList'); - - if (state.events.length === 0) { - list.innerHTML = '
等待通知...
'; - return; - } - - list.innerHTML = state.events.slice(0, 10).map(e => ` -
- ${formatTime(e.timestamp)} - ${e.type === 'stop' ? '●' : '⚠'} - ${e.project} ${e.message} -
- `).join(''); -} - -function formatTime(ts) { - try { - const d = new Date(ts); - return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); - } catch { - return ts || ''; - } -} - -function formatDate(ts) { - try { - const d = new Date(ts); - return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }); - } catch { - return ''; - } -} - -function formatNum(n) { - if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'; - if (n >= 1000) return (n / 1000).toFixed(1) + 'K'; - return String(n); -} - -function formatTokens(n) { - if (n >= 1000000000) return (n / 1000000000).toFixed(1) + 'B'; - if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'; - if (n >= 1000) return (n / 1000).toFixed(1) + 'K'; - return String(n); -} - -function updateGlobalStatus() { - const badge = document.getElementById('globalStatus'); - const hasRunning = state.projects.some(p => p.running); - const hasPermission = state.events.some(e => e.type === 'permission' && - (Date.now() - new Date(e.timestamp).getTime()) < 60000); - - if (hasPermission) { - badge.textContent = '需要操作'; - badge.className = 'status-badge attention'; - } else if (hasRunning) { - badge.textContent = `${state.projects.filter(p => p.running).length} 个运行中`; - badge.className = 'status-badge busy'; - } else { - badge.textContent = '就绪'; - badge.className = 'status-badge'; - } -} - -async function refreshDeps() { - try { - const [list, terminal, legacy] = await Promise.all([ - invoke('check_dependencies'), - invoke('get_terminal_info'), - invoke('hub_server_legacy'), - ]); - state.deps = list; - state.terminal = terminal; - state.legacyServer = legacy; - renderDeps(); - } catch (e) {} -} - -function renderDeps() { - const panel = document.getElementById('depsPanel'); - if (!panel || !state.deps) return; - const missing = state.deps.filter(d => !d.installed); - const term = state.terminal || {}; - const legacy = !!state.legacyServer; - const termWarn = term && term.supported === false; - - // 全部 OK 且无遗留 server → 隐藏面板 - if (missing.length === 0 && !termWarn && !legacy) { - panel.style.display = 'none'; - return; - } - - const requiredMissing = missing.filter(d => d.required); - const optionalMissing = missing.filter(d => !d.required); - - let title; - if (legacy) title = '⚠️ 检测到旧版 tmux server'; - else if (requiredMissing.length > 0) title = '⚠️ 缺少必需依赖'; - else if (termWarn) title = '⚠️ 终端不受支持'; - else title = '可选依赖未装'; - - let body = ''; - - // 旧版 server 提示 - if (legacy) { - body += ` -
-
- 现有 hub server 由 .app 创建(无法读 Keychain 登录态) -
- tmux kill-server -
- `; - } - - // 终端不支持提示 - if (termWarn) { - body += ` -
-
- 检测到终端 ${term.label || ''},第一版仅支持 iTerm / Terminal.app -
- brew install --cask iterm2 -
- `; - } - - // 缺失依赖 - for (const d of [...requiredMissing, ...optionalMissing]) { - body += ` -
-
- ${d.label} - ${d.required ? '必需' : '可选'} -
- ${d.install_cmd} -
- `; - } - - panel.style.display = ''; - panel.innerHTML = ` -
- ${title} - -
- ${body} - - `; -} - -function escapeAttr(s) { - return String(s).replace(/'/g, "\\'"); -} - -async function copyText(el, text) { - try { - await navigator.clipboard.writeText(text); - const orig = el.textContent; - el.textContent = '已复制 ✓'; - el.classList.add('copied'); - setTimeout(() => { - el.textContent = orig; - el.classList.remove('copied'); - }, 1200); - } catch (e) {} -} - -async function scanProjects() { - try { - const added = await invoke('scan_projects', { base: null }); - await refreshProjects(); - const msg = added > 0 ? `扫描完成,新增 ${added} 个项目` : '已是最新,未发现新项目'; - flashStatus(msg); - } catch (e) { - flashStatus('扫描失败:' + e, true); - } -} - -async function revealDataDir() { - try { await invoke('reveal_data_dir'); } catch (e) {} -} - -// 临时在 globalStatus 闪一条提示 -function flashStatus(text, error = false) { - const badge = document.getElementById('globalStatus'); - const prevText = badge.textContent; - const prevClass = badge.className; - badge.textContent = text; - badge.className = 'status-badge ' + (error ? 'attention' : 'busy'); - setTimeout(() => updateGlobalStatus(), 2400); -} - -// 暴露给 HTML onclick -window.menuAction = menuAction; -window.switchTab = switchTab; -window.loadUsage = loadUsage; -window.scanProjects = scanProjects; -window.revealDataDir = revealDataDir; -window.refreshDeps = refreshDeps; -window.copyText = copyText; - -init(); diff --git a/app/src/style.css b/app/src/style.css deleted file mode 100644 index bd5583b..0000000 --- a/app/src/style.css +++ /dev/null @@ -1,572 +0,0 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background: #1a1a2e; - color: #e0e0e0; - font-size: 13px; - overflow: hidden; - user-select: none; -} - -.container { - padding: 16px; - height: 100vh; - display: flex; - flex-direction: column; -} - -/* Header */ -header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; - padding-bottom: 10px; - border-bottom: 1px solid #2a2a4a; -} - -header h1 { - font-size: 15px; - font-weight: 600; - color: #fff; -} - -.status-badge { - font-size: 11px; - padding: 2px 8px; - border-radius: 10px; - background: #2d4a3e; - color: #4ade80; -} -.status-badge.busy { background: #2d3a5e; color: #60a5fa; } -.status-badge.attention { background: #4a2d2d; color: #f87171; } - -/* Tabs */ -.tabs { - display: flex; - gap: 2px; - background: #16162a; - border-radius: 6px; - padding: 2px; -} - -.tab { - border: none; - background: transparent; - color: #666; - font-size: 12px; - padding: 4px 12px; - border-radius: 4px; - cursor: pointer; - transition: all 0.15s; -} - -.tab:hover { - color: #aaa; -} - -.tab.active { - background: #2a2a4a; - color: #e0e0e0; -} - -/* 依赖检查面板(仅缺失时显示) */ -.deps-panel { - background: #2a1f1a; - border: 1px solid #4a2a1a; - border-radius: 8px; - padding: 10px 12px; - margin-bottom: 10px; -} -.deps-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 6px; -} -.deps-title { - font-size: 12px; - font-weight: 600; - color: #f9a14a; -} -.deps-recheck { - background: transparent; - border: 1px solid #4a3a2a; - color: #888; - font-size: 11px; - width: 22px; height: 22px; - border-radius: 4px; - cursor: pointer; -} -.deps-recheck:hover { color: #f9a14a; border-color: #6a4a2a; } -.dep-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 4px 0; - font-size: 11px; -} -.dep-row.optional .dep-tag { background: #2a2a4a; color: #777; } -.dep-info { display: flex; align-items: center; gap: 6px; min-width: 0; } -.dep-label { color: #ccc; } -.dep-tag { - background: #4a2a1a; - color: #f9a14a; - font-size: 9px; - padding: 1px 5px; - border-radius: 3px; -} -.dep-cmd { - background: #16162a; - color: #8bb4e0; - padding: 3px 7px; - border-radius: 4px; - font-family: ui-monospace, monospace; - font-size: 10px; - cursor: pointer; - user-select: none; - border: 1px solid #2a2a4a; - transition: all 0.15s; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 180px; -} -.dep-cmd:hover { background: #1e2a3a; border-color: #3a3a5a; color: #b8d4f0; } -.dep-cmd.copied { background: #1e3a2a; color: #4ade80; border-color: #2e5a3e; } -.deps-footer { - margin-top: 8px; - font-size: 10px; - color: #777; - text-align: right; -} -.deps-footer b { color: #aaa; font-weight: 500; } - -/* Views */ -.view { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; -} - -/* 搜索框 */ -.search-box { - margin-bottom: 8px; - display: flex; - gap: 6px; - align-items: center; -} - -.search-box input { - flex: 1; - min-width: 0; - padding: 6px 10px; - border: 1px solid #2a2a4a; - border-radius: 6px; - background: #16162a; - color: #e0e0e0; - font-size: 12px; - outline: none; - transition: border-color 0.15s; -} - -.search-box input::placeholder { - color: #555; -} - -.search-box input:focus { - border-color: #4a4a7a; -} - -.icon-btn { - border: 1px solid #2a2a4a; - background: #16162a; - color: #aaa; - font-size: 11px; - padding: 6px 10px; - border-radius: 6px; - cursor: pointer; - transition: all 0.15s; - white-space: nowrap; -} -.icon-btn:hover { background: #2a2a4a; color: #e0e0e0; border-color: #3a3a5a; } -.icon-btn:active { transform: scale(0.97); } - -/* 空状态引导 */ -.empty-cta { - padding: 28px 16px; - text-align: center; -} -.empty-title { - font-size: 14px; - color: #aaa; - margin-bottom: 8px; -} -.empty-hint { - font-size: 12px; - color: #666; - margin-bottom: 16px; - line-height: 1.5; -} -.empty-hint code { - background: #16162a; - padding: 1px 5px; - border-radius: 3px; - font-family: ui-monospace, monospace; - color: #8bb4e0; - font-size: 11px; -} -.empty-actions { - display: flex; - gap: 8px; - justify-content: center; - flex-wrap: wrap; -} -.btn-primary, .btn-ghost { - border: none; - font-size: 12px; - padding: 7px 14px; - border-radius: 6px; - cursor: pointer; - transition: all 0.15s; -} -.btn-primary { background: #3b82f6; color: #fff; } -.btn-primary:hover { background: #2563eb; } -.btn-ghost { background: #2a2a4a; color: #aaa; } -.btn-ghost:hover { background: #3a3a5a; color: #e0e0e0; } - -/* 项目列表 */ -.projects { - flex: 1; - overflow-y: auto; - margin-bottom: 12px; -} - -.project-item { - display: flex; - align-items: center; - padding: 8px 10px; - border-radius: 6px; - cursor: pointer; - transition: background 0.15s; - margin-bottom: 2px; -} - -.project-item:hover { - background: #2a2a4a; -} - -.project-item.running { - background: #1a2a3a; -} - -.project-item.running:hover { - background: #243a4e; -} - -.project-dot { - width: 8px; - height: 8px; - border-radius: 50%; - margin-right: 10px; - flex-shrink: 0; -} - -.project-dot.idle { background: #444; } -.project-dot.running { background: #4ade80; box-shadow: 0 0 6px #4ade8066; } - -.project-info { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; -} - -.project-name-row { - display: flex; - align-items: center; - gap: 4px; - flex-wrap: wrap; -} - -.project-name { - font-weight: 500; - font-size: 13px; - color: #e0e0e0; -} - -/* git 徽章 */ -.git-pill { - font-size: 10px; - line-height: 1.5; - padding: 0 6px; - border-radius: 3px; - font-variant-numeric: tabular-nums; - white-space: nowrap; - max-width: 140px; - overflow: hidden; - text-overflow: ellipsis; -} -.git-pill.branch { background: #1e2a3a; color: #8bb4e0; } -.git-pill.clean { background: #1e3a2a; color: #4ade80; } -.git-pill.dirty { background: #3a2e1e; color: #fbbf24; } -.git-pill.ahead { background: #2a2e3a; color: #fbbf24; } -.git-pill.behind { background: #3a1e1e; color: #f87171; } -.git-pill.no-upstream{ background: #2a2a3a; color: #777; } -.git-pill.no-git { background: transparent; color: #555; padding: 0; } -.project-item.idle .git-pill.branch { color: #5a7a9a; } - -.project-item.idle .project-name { - color: #888; -} - -.project-desc { - font-size: 11px; - color: #555; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.project-right { - display: flex; - flex-direction: column; - align-items: flex-end; - margin-left: 8px; - flex-shrink: 0; -} - -.project-status { - font-size: 11px; - color: #4ade80; -} - -.project-item.idle .project-status { - color: #555; -} - -.project-time { - font-size: 10px; - color: #444; - font-variant-numeric: tabular-nums; -} - -/* 右键菜单 */ -.context-menu { - position: fixed; - background: #2a2a4a; - border: 1px solid #3a3a5a; - border-radius: 8px; - padding: 4px 0; - min-width: 200px; - box-shadow: 0 8px 24px rgba(0,0,0,0.4); - z-index: 100; -} - -.menu-item { - padding: 8px 16px; - cursor: pointer; - font-size: 13px; - transition: background 0.1s; -} - -.menu-item:hover { - background: #3a3a5a; -} - -.menu-item.separator { - height: 1px; - background: #3a3a5a; - padding: 0; - margin: 4px 8px; - cursor: default; -} - -/* Usage 视图 */ -.usage-summary { - display: flex; - gap: 4px; - margin-bottom: 8px; -} - -.summary-item { - flex: 1; - text-align: center; - padding: 8px 4px; - background: #16162a; - border-radius: 6px; -} - -.summary-value { - display: block; - font-size: 16px; - font-weight: 600; - color: #fff; - font-variant-numeric: tabular-nums; -} - -.summary-label { - font-size: 10px; - color: #666; - margin-top: 2px; -} - -.usage-range { - display: flex; - gap: 4px; - margin-bottom: 10px; -} - -.range-btn { - border: 1px solid #2a2a4a; - background: transparent; - color: #666; - font-size: 11px; - padding: 3px 10px; - border-radius: 4px; - cursor: pointer; - transition: all 0.15s; -} - -.range-btn:hover { - color: #aaa; - border-color: #3a3a5a; -} - -.range-btn.active { - background: #2a2a4a; - color: #e0e0e0; - border-color: #3a3a5a; -} - -.usage-list { - flex: 1; - overflow-y: auto; - margin-bottom: 12px; -} - -.usage-item { - padding: 6px 0; - border-bottom: 1px solid #1e1e38; -} - -.usage-item:last-child { - border-bottom: none; -} - -.usage-row { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 4px; -} - -.usage-name { - font-size: 12px; - font-weight: 500; - color: #e0e0e0; -} - -.usage-tokens { - font-size: 12px; - font-weight: 600; - color: #60a5fa; - font-variant-numeric: tabular-nums; -} - -.usage-bar-bg { - height: 4px; - background: #1e1e38; - border-radius: 2px; - margin-bottom: 3px; -} - -.usage-bar { - height: 100%; - background: linear-gradient(90deg, #3b82f6, #60a5fa); - border-radius: 2px; - transition: width 0.3s; -} - -.usage-meta { - display: flex; - justify-content: space-between; - font-size: 10px; - color: #555; -} - -.usage-models { - display: flex; - flex-wrap: wrap; - gap: 4px; - margin-top: 4px; -} - -.model-tag { - font-size: 10px; - padding: 1px 6px; - border-radius: 3px; - background: #1e2a3a; - color: #8bb4e0; - border: 1px solid #2a3a4e; -} - -.model-tag em { - font-style: normal; - color: #557; - margin-left: 2px; -} - -/* 通知列表 */ -.events { - border-top: 1px solid #2a2a4a; - padding-top: 10px; - max-height: 140px; - overflow-y: auto; -} - -.events h2 { - font-size: 12px; - color: #666; - margin-bottom: 6px; - font-weight: 500; -} - -.event-item { - display: flex; - align-items: center; - padding: 3px 0; - font-size: 12px; -} - -.event-time { - color: #555; - margin-right: 8px; - font-variant-numeric: tabular-nums; -} - -.event-icon { margin-right: 6px; } -.event-icon.stop { color: #4ade80; } -.event-icon.permission { color: #f87171; } - -.event-message { color: #aaa; } - -.empty { - color: #444; - font-style: italic; - padding: 12px 0; - text-align: center; -} - -/* 滚动条 */ -::-webkit-scrollbar { width: 4px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; } diff --git a/scripts/build-app.sh b/scripts/build-app.sh deleted file mode 100755 index cb3362e..0000000 --- a/scripts/build-app.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/bash -# 编译 Claude Hub 托盘 app(release 版本) -# 需要:Rust 工具链、Xcode CLT、pkg-config、openssl - -CYAN='\033[0;36m' -GREEN='\033[0;32m' -RED='\033[0;31m' -YELLOW='\033[1;33m' -NC='\033[0m' - -HUB_DIR="$(cd "$(dirname "$0")/.." && pwd)" -APP_DIR="$HUB_DIR/app/src-tauri" - -echo -e "${CYAN}编译 Claude Hub 托盘 app...${NC}" -echo "" - -# 检查 Rust -if ! command -v cargo &>/dev/null; then - echo -e "${YELLOW}未检测到 Rust,正在安装...${NC}" - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y - source "$HOME/.cargo/env" -fi -echo -e "${GREEN}✓ cargo $(cargo --version | cut -d' ' -f2)${NC}" - -# 检查 Xcode CLT(macOS 编译必需) -if ! xcode-select -p &>/dev/null; then - echo -e "${RED}✗ 需要 Xcode Command Line Tools${NC}" - echo " 运行: xcode-select --install" - exit 1 -fi -echo -e "${GREEN}✓ Xcode CLT${NC}" - -echo "" -cd "$APP_DIR" || { echo -e "${RED}✗ 目录不存在: $APP_DIR${NC}"; exit 1; } -cargo build --release - -if [ $? -eq 0 ]; then - echo "" - echo -e "${GREEN}✓ 编译完成: $APP_DIR/target/release/claude-hub${NC}" - echo -e " 架构: $(file "$APP_DIR/target/release/claude-hub" | grep -oE 'arm64|x86_64')" -else - echo "" - echo -e "${RED}✗ 编译失败${NC}" - exit 1 -fi diff --git a/scripts/build-swift-app.sh b/scripts/build-swift-app.sh new file mode 100755 index 0000000..f1979b2 --- /dev/null +++ b/scripts/build-swift-app.sh @@ -0,0 +1,198 @@ +#!/bin/bash +# 构建 Claude Hub.app(SwiftUI 版)。 +# +# 用 SwiftPM + 手工组装 bundle,而不是 .xcodeproj: +# - 手写 xcodeproj 的 XML 极易出错且几乎无法 code review; +# - SwiftPM 保住了 `swift test`(84 个测试); +# - 和这个脚本原有的签名策略是一路的。 +# +# 签名策略(沿用 Rust 版 build-app.sh 的做法,必须保留): +# 固定自签名证书 + 固定 bundle identifier +# 这样 macOS 的 TCC 授权(Automation 控制 iTerm、通知权限)在重新编译后 +# 不会失效。换证书或换 identifier 都会让用户每次重编都要重新授权一遍。 + +set -euo pipefail + +HUB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PKG_DIR="${HUB_DIR}/HubKit" +APP_NAME="Claude Hub" +BUNDLE_ID="dev.hengjun.claude-hub" +CERT_NAME="Claude Hub Dev" +CONFIG="${1:-release}" + +if [ "$CONFIG" = "release" ]; then + BUILD_DIR="$PKG_DIR/.build/release" +else + BUILD_DIR="$PKG_DIR/.build/debug" +fi +# 暂存目录,组装 + 签名在这里做,装完就删。 +# +# **不能留一份长期存在的副本**(原来放在 dist/):LaunchServices 会把每一个 +# .app 都按 bundle identifier 注册,同一个 id 对应多个 bundle 时, +# `open -b` 打开哪一个不可预期、通知和 TCC 授权会互相抢 —— 这正是这次 +# 要修的原始问题(当时 /Applications、Tauri 构建产物、dist/ 三份同 id)。 +APP_PATH="${PKG_DIR}/.build/stage/${APP_NAME}.app" + +log() { printf '\033[36m▸\033[0m %s\n' "$1"; } +fail() { printf '\033[31m✗\033[0m %s\n' "$1" >&2; exit 1; } + +# ---------- 环境检查 ---------- + +command -v swift >/dev/null || fail "找不到 swift,请先安装 Xcode" +xcodebuild -checkFirstLaunchStatus >/dev/null 2>&1 \ + || fail "Xcode 首次启动检查未通过,请先运行:sudo xcodebuild -runFirstLaunch" + +SDK_VERSION=$(xcrun --show-sdk-version) +MAJOR=${SDK_VERSION%%.*} +if [ "$MAJOR" -lt 26 ]; then + fail "需要 macOS 26 SDK(当前 ${SDK_VERSION})。灵动岛依赖 Liquid Glass API。" +fi + +# ---------- 编译 ---------- + +# 变量名后面紧跟中文标点时必须用花括号:bash 会把全角逗号的字节 +# 当成变量名的一部分,报 "CONFIG?: unbound variable"。 +log "编译(${CONFIG},SDK ${SDK_VERSION})…" +cd "$PKG_DIR" +swift build -c "$CONFIG" --product ClaudeHub +swift build -c "$CONFIG" --product hubctl + +[ -f "$BUILD_DIR/ClaudeHub" ] || fail "没找到构建产物 $BUILD_DIR/ClaudeHub" + +# ---------- 组装 bundle ---------- + +log "组装 ${APP_NAME}.app…" +rm -rf "${APP_PATH}" +mkdir -p "${APP_PATH}/Contents/MacOS" "${APP_PATH}/Contents/Resources" + +cp "$BUILD_DIR/ClaudeHub" "${APP_PATH}/Contents/MacOS/ClaudeHub" + +# hubctl 放 Contents/Helpers/ 而**不是** Contents/MacOS/。 +# +# 实测教训:放在 MacOS/ 里并且和主程序共用同一个 bundle identifier 时, +# macOS 会在 exec 的瞬间 SIGKILL 它(Apple Silicon 上签名不匹配就是直接杀, +# 表现为 "EXIT=137",没有任何错误信息)。MacOS/ 目录约定只放 +# CFBundleExecutable 指向的那一个可执行文件,辅助工具应该放 Helpers/。 +mkdir -p "${APP_PATH}/Contents/Helpers" +cp "$BUILD_DIR/hubctl" "${APP_PATH}/Contents/Helpers/hubctl" + +if [ -f "${HUB_DIR}/Resources/AppIcon.icns" ]; then + cp "${HUB_DIR}/Resources/AppIcon.icns" "${APP_PATH}/Contents/Resources/AppIcon.icns" +fi + +cat > "${APP_PATH}/Contents/Info.plist" < + + + + CFBundleName ${APP_NAME} + CFBundleDisplayName ${APP_NAME} + CFBundleExecutable ClaudeHub + CFBundleIdentifier $BUNDLE_ID + CFBundlePackageType APPL + CFBundleShortVersionString2.0.0 + CFBundleVersion 2 + CFBundleIconFile AppIcon + LSMinimumSystemVersion 26.0 + + LSUIElement + + NSAppleEventsUsageDescription + Claude Hub 需要控制终端来跳转到对应的会话标签页。 + + +PLIST + +# ---------- 签名 ---------- + +# 签名顺序很重要:**先签嵌套的可执行文件,再签外层 bundle**。 +# +# 不能用 `--deep`:它会把外层的 --identifier 强加到 hubctl 上, +# 两个不同的二进制声称同一个 bundle identifier,exec 时会被直接 SIGKILL。 +# +# 也不加 `--options runtime`(hardened runtime):自签名 + 未公证的情况下 +# 它不提供任何实际保护,反而会引入额外的加载限制。 +sign_one() { + local target="$1" identifier="$2" + if [ -n "$SIGN_IDENTITY" ]; then + codesign --force --sign "$SIGN_IDENTITY" --identifier "$identifier" "$target" + else + codesign --force --sign - --identifier "$identifier" "$target" + fi +} + +if ! security find-certificate -c "${CERT_NAME}" >/dev/null 2>&1; then + SIGN_IDENTITY="" + log "未找到自签名证书「${CERT_NAME}」" + cat <<'HINT' + + 首次构建需要创建一个自签名证书,这样 TCC 授权(控制 iTerm、发通知) + 在每次重新编译后仍然有效,不用反复授权。 + + 手动创建一次即可: + 1. 打开「钥匙串访问」 + 2. 菜单:钥匙串访问 → 证书助理 → 创建证书… + 3. 名称填:Claude Hub Dev + 4. 身份类型:自签名根证书 + 5. 证书类型:代码签名 + 6. 创建 + + 没有证书时会退回 ad-hoc 签名,功能正常,但每次重编都要重新授权。 + +HINT + log "本次使用 ad-hoc 签名" +else + SIGN_IDENTITY="${CERT_NAME}" + log "用「${CERT_NAME}」签名…" +fi + +# 内层:hubctl 用自己的 identifier。 +sign_one "${APP_PATH}/Contents/Helpers/hubctl" "${BUNDLE_ID}.hubctl" +# 外层:bundle 用主 identifier。此时会把 Helpers/ 的内容封进 CodeResources。 +sign_one "${APP_PATH}" "$BUNDLE_ID" + +codesign --verify --verbose=1 "${APP_PATH}" 2>&1 | sed 's/^/ /' +codesign --verify --verbose=1 "${APP_PATH}/Contents/Helpers/hubctl" 2>&1 | sed 's/^/ /' + +# 冒烟测试:确认 hubctl 真的能跑起来。签名不对的话它会被瞬间 SIGKILL, +# 而那种失败没有任何错误输出,不主动测就会一路带到用户那里。 +if "${APP_PATH}/Contents/Helpers/hubctl" doctor >/dev/null 2>&1 || [ $? -eq 1 ]; then + log "hubctl 冒烟测试通过" +else + fail "hubctl 无法执行(退出码 $?)—— 多半是签名问题" +fi + +# ---------- 安装到 /Applications ---------- +# +# 必须装进 /Applications 而不是从 dist/ 直接跑。 +# +# 踩过的坑:仓库里曾经同时存在三个 bundle 都叫 dev.hengjun.claude-hub +# (/Applications 里的旧 Tauri 版、Tauri 的构建产物、dist/ 里的新版)。 +# 同一个 bundle identifier 对应多个 .app 时: +# - LaunchServices 注册多条记录,`open -b` 打开哪一个不可预期; +# - UNUserNotificationCenter 的授权和 TCC 授权是按 identifier 存的,两个 app 互相抢; +# - 任何 `pgrep -f "Claude Hub.app"` 式的存活判断都会误匹配。 +# 所以:一个 identifier 只允许对应 /Applications 里那一个 bundle。 +INSTALL_PATH="/Applications/${APP_NAME}.app" + +log "安装到 ${INSTALL_PATH}…" +# 先退掉正在跑的旧实例,否则覆盖正在执行的二进制会得到一个半死不活的进程。 +if pgrep -x ClaudeHub >/dev/null 2>&1; then + pkill -x ClaudeHub + # 等它真的退出。SIGTERM 后 applicationWillTerminate 要收尾 socket 和 tmux 监听。 + for _ in $(seq 1 20); do + pgrep -x ClaudeHub >/dev/null 2>&1 || break + sleep 0.2 + done + log "已退出正在运行的实例" +fi + +rm -rf "${INSTALL_PATH}" +cp -R "${APP_PATH}" "${INSTALL_PATH}" +# 暂存副本必须删掉,理由见 APP_PATH 处的说明。 +rm -rf "${APP_PATH}" + +log "完成:${INSTALL_PATH}" +echo +echo " 启动:open -a '${APP_NAME}'" +echo " 安装 hook:bash '${HUB_DIR}/scripts/setup-swift-hooks.sh'" diff --git a/scripts/cc.sh b/scripts/cc.sh index c8d654d..3dce707 100755 --- a/scripts/cc.sh +++ b/scripts/cc.sh @@ -7,19 +7,22 @@ # 2. 在 claude-hub 目录启动 claude(调度中心) HUB_DIR="$HOME/Documents/code/claude-hub" -APP_RELEASE="$HUB_DIR/app/src-tauri/target/release/claude-hub" +APP_PATH="/Applications/Claude Hub.app" # 1. 启动托盘 app -if ! pgrep -f "claude-hub" >/dev/null 2>&1; then - if [ -f "$APP_RELEASE" ]; then - "$APP_RELEASE" & - disown - echo "✓ Claude Hub 托盘已启动" +# +# 用 `pgrep -x ClaudeHub` 判断存活,**不能**用 `pgrep -f "Claude Hub"`: +# 后者会匹配到任何路径里含这个串的进程(历史上仓库里同时存在过三个同名 bundle), +# 于是明明没在跑也会被判成"已运行",app 永远起不来。-x 精确匹配进程名。 +if ! pgrep -x ClaudeHub >/dev/null 2>&1; then + if [ -d "$APP_PATH" ]; then + open -a "$APP_PATH" + echo "✓ Claude Hub 已启动" else - echo "⚠ 未找到编译产物,请先执行: cd $HUB_DIR/app && cargo build --release" + echo "⚠ 未找到 $APP_PATH,请先执行: bash $HUB_DIR/scripts/build-swift-app.sh" fi else - echo "✓ Claude Hub 托盘已在运行" + echo "✓ Claude Hub 已在运行" fi # 2. 启动 Claude 调度中心 diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..70c5e04 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,183 @@ +#!/bin/bash +# 打发布包:universal .app + .dmg。 +# +# 和 build-swift-app.sh 的分工: +# build-swift-app.sh 日常开发。只编当前架构,装进 /Applications,几秒钟。 +# release.sh 发布。universal 双架构 + dmg + 校验和,几分钟。 +# +# 刻意**不**装进 /Applications:发布构建和你正在用的那个是两回事, +# 打个包不该把你手上跑着的实例换掉。要用新版自己 open dmg 装。 + +set -euo pipefail + +HUB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PKG_DIR="${HUB_DIR}/HubKit" +APP_NAME="Claude Hub" +BUNDLE_ID="dev.hengjun.claude-hub" +CERT_NAME="Claude Hub Dev" + +VERSION="${1:-}" +if [ -z "${VERSION}" ]; then + VERSION=$(grep -m1 'CFBundleShortVersionString' "${HUB_DIR}/scripts/build-swift-app.sh" \ + | sed -E 's/.*([^<]+)<\/string>.*/\1/') +fi + +# 构建号用提交数,比手工维护可靠:它单调递增,且能反查到具体代码。 +BUILD_NUMBER=$(git -C "${HUB_DIR}" rev-list --count HEAD 2>/dev/null || echo 1) +COMMIT=$(git -C "${HUB_DIR}" rev-parse --short HEAD 2>/dev/null || echo "unknown") +DIRTY="" +if ! git -C "${HUB_DIR}" diff --quiet HEAD 2>/dev/null; then DIRTY="+dirty"; fi + +STAGE="${PKG_DIR}/.build/release-stage" +APP_PATH="${STAGE}/${APP_NAME}.app" +OUT_DIR="${HUB_DIR}/release" +DMG_PATH="${OUT_DIR}/${APP_NAME// /-}-${VERSION}.dmg" + +log() { printf '\033[36m▸\033[0m %s\n' "$1"; } +ok() { printf '\033[32m✓\033[0m %s\n' "$1"; } +warn() { printf '\033[33m!\033[0m %s\n' "$1"; } +fail() { printf '\033[31m✗\033[0m %s\n' "$1" >&2; exit 1; } + +# ---------- 环境 ---------- + +command -v swift >/dev/null || fail "找不到 swift,请先安装 Xcode" +SDK_VERSION=$(xcrun --show-sdk-version) +[ "${SDK_VERSION%%.*}" -ge 26 ] || fail "需要 macOS 26 SDK(当前 ${SDK_VERSION})" + +log "版本 ${VERSION} (build ${BUILD_NUMBER}, ${COMMIT}${DIRTY})" +if [ -n "${DIRTY}" ]; then + warn "工作区有未提交的改动 —— 这个包对应不到任何一个提交" +fi + +# ---------- 测试 ---------- +# +# 发布前必须全绿。日常构建可以跳过测试,发布不行。 +log "跑测试…" +cd "${PKG_DIR}" +if ! swift test 2>&1 | tail -3; then + fail "测试没过,不打包" +fi + +# ---------- 编译(双架构)---------- +# +# `--arch arm64 --arch x86_64` 让 SwiftPM 直接产出 fat binary。 +# macOS 26 是最后一个支持 Intel 的版本,还有人在上面跑,所以带上 x86_64。 +log "编译 universal(arm64 + x86_64)…" +swift build -c release --arch arm64 --arch x86_64 --product ClaudeHub +swift build -c release --arch arm64 --arch x86_64 --product hubctl + +BUILD_DIR="${PKG_DIR}/.build/apple/Products/Release" +[ -f "${BUILD_DIR}/ClaudeHub" ] || fail "没找到构建产物 ${BUILD_DIR}/ClaudeHub" + +for binary in ClaudeHub hubctl; do + archs=$(lipo -archs "${BUILD_DIR}/${binary}") + case "$archs" in + *arm64*x86_64*|*x86_64*arm64*) ok "${binary}: ${archs}" ;; + *) fail "${binary} 不是 universal(${archs})" ;; + esac +done + +# ---------- 组装 ---------- + +log "组装 ${APP_NAME}.app…" +rm -rf "${STAGE}" +mkdir -p "${APP_PATH}/Contents/MacOS" \ + "${APP_PATH}/Contents/Resources" \ + "${APP_PATH}/Contents/Helpers" + +cp "${BUILD_DIR}/ClaudeHub" "${APP_PATH}/Contents/MacOS/ClaudeHub" +# hubctl 放 Helpers/ 而不是 MacOS/ —— 见 HubKit/NOTES.md, +# 和主程序共用 bundle identifier 会被 exec 瞬间 SIGKILL。 +cp "${BUILD_DIR}/hubctl" "${APP_PATH}/Contents/Helpers/hubctl" + +[ -f "${HUB_DIR}/Resources/AppIcon.icns" ] \ + && cp "${HUB_DIR}/Resources/AppIcon.icns" "${APP_PATH}/Contents/Resources/AppIcon.icns" + +cat > "${APP_PATH}/Contents/Info.plist" < + + + + CFBundleName ${APP_NAME} + CFBundleDisplayName ${APP_NAME} + CFBundleExecutable ClaudeHub + CFBundleIdentifier ${BUNDLE_ID} + CFBundlePackageType APPL + CFBundleShortVersionString${VERSION} + CFBundleVersion ${BUILD_NUMBER} + CFBundleIconFile AppIcon + LSMinimumSystemVersion 26.0 + LSUIElement + NSAppleEventsUsageDescription + Claude Hub 需要控制终端来跳转到对应的会话标签页。 + + HubGitCommit ${COMMIT}${DIRTY} + + +PLIST + +# ---------- 签名 ---------- +# +# 先签内层再签外层,不用 --deep(理由见 NOTES.md)。 +if security find-certificate -c "${CERT_NAME}" >/dev/null 2>&1; then + SIGN_IDENTITY="${CERT_NAME}" + log "用「${CERT_NAME}」签名…" +else + SIGN_IDENTITY="-" + warn "没有自签名证书「${CERT_NAME}」,退回 ad-hoc 签名" +fi + +codesign --force --sign "${SIGN_IDENTITY}" --identifier "${BUNDLE_ID}.hubctl" \ + "${APP_PATH}/Contents/Helpers/hubctl" +codesign --force --sign "${SIGN_IDENTITY}" --identifier "${BUNDLE_ID}" "${APP_PATH}" +codesign --verify --deep --strict "${APP_PATH}" || fail "签名校验失败" +ok "签名通过" + +# 冒烟测试:签名不对的话 hubctl 会被瞬间 SIGKILL,而那种失败没有任何输出。 +"${APP_PATH}/Contents/Helpers/hubctl" doctor >/dev/null 2>&1 || true +if [ "$?" -gt 1 ]; then fail "hubctl 无法执行 —— 多半是签名问题"; fi +ok "hubctl 冒烟测试通过" + +# ---------- dmg ---------- + +log "打 dmg…" +mkdir -p "${OUT_DIR}" +rm -f "${DMG_PATH}" + +DMG_STAGE="${STAGE}/dmg" +rm -rf "${DMG_STAGE}" +mkdir -p "${DMG_STAGE}" +cp -R "${APP_PATH}" "${DMG_STAGE}/" +# 拖进去就装:给个 /Applications 的替身。 +ln -s /Applications "${DMG_STAGE}/Applications" +cp "${HUB_DIR}/scripts/setup-swift-hooks.sh" "${DMG_STAGE}/安装 hook.command" 2>/dev/null || true +chmod +x "${DMG_STAGE}/安装 hook.command" 2>/dev/null || true + +hdiutil create -volname "${APP_NAME} ${VERSION}" \ + -srcfolder "${DMG_STAGE}" -ov -format UDZO "${DMG_PATH}" >/dev/null +rm -rf "${DMG_STAGE}" + +SIZE=$(du -h "${DMG_PATH}" | cut -f1) +SHA=$(shasum -a 256 "${DMG_PATH}" | cut -d' ' -f1) + +echo +ok "完成:${DMG_PATH}" +echo " 版本 ${VERSION} (build ${BUILD_NUMBER}, ${COMMIT}${DIRTY})" +echo " 架构 arm64 + x86_64" +echo " 大小 ${SIZE}" +echo " SHA256 ${SHA}" +echo +cat <<'NOTICE' +分发提醒:这个包是**自签名、未公证**的。 +你自己的机器上没问题(证书在你钥匙串里),但别人下载后打开会被 Gatekeeper 拦, +提示"无法打开,因为无法验证开发者"。对方需要: + + 右键点 app → 打开 → 再点"打开" + +或者在终端里: + + xattr -dr com.apple.quarantine "/Applications/Claude Hub.app" + +要做到双击即用,需要 Apple Developer Program($99/年)的 +Developer ID 证书 + notarytool 公证。 +NOTICE diff --git a/scripts/setup-swift-hooks.sh b/scripts/setup-swift-hooks.sh new file mode 100755 index 0000000..aedc56d --- /dev/null +++ b/scripts/setup-swift-hooks.sh @@ -0,0 +1,131 @@ +#!/bin/bash +# 把 Claude Hub 的 hook 装进 ~/.claude/settings.json。 +# +# 替换掉旧的 bash hook(hub-hook-stop.sh / hub-hook-permission.sh)。旧实现的问题: +# 1. 用 `tmux display-message -p '#W'` 且**没带 -t "$TMUX_PANE"**, +# 拿到的是当前 active 窗口名而不是自己所在的窗口 —— 用户切个 tab +# 通知就挂到别的项目上了; +# 2. 完全没读 stdin,session_id / cwd / last_assistant_message 全丢; +# 3. terminal-notifier 的 -activate 只能前置 iTerm,选不了具体 tab, +# 这正是「点了通知没跳到对应窗口」的直接原因; +# 4. 配的是 "async": false 且 hook 里同步 afplay, +# **每轮回复结束都被语音播放阻塞几百毫秒**。 +# +# 新实现用 hubctl(Swift 二进制):读 stdin、走 Unix socket、 +# Hub 没运行时立刻放行(fail-open)。 + +set -euo pipefail + +# 这个脚本有两种跑法,路径解析必须都成立: +# 1. 从仓库里跑:`bash scripts/setup-swift-hooks.sh` +# 2. 从 dmg 里双击「安装 hook.command」—— 这时上一级是 /Volumes, +# 仓库根本不存在。所以 HUB_DIR 只是**可选**的额外查找位置, +# 真正的主来源是已安装的 /Applications/Claude Hub.app。 +HUB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if [ ! -d "$HUB_DIR/HubKit" ]; then HUB_DIR=""; fi + +SETTINGS="$HOME/.claude/settings.json" +BIN_DIR="$HOME/.local/bin" +HUBCTL="$BIN_DIR/hubctl" + +log() { printf '\033[36m▸\033[0m %s\n' "$1"; } +ok() { printf '\033[32m✓\033[0m %s\n' "$1"; } +warn() { printf '\033[33m!\033[0m %s\n' "$1"; } +fail() { printf '\033[31m✗\033[0m %s\n' "$1" >&2; exit 1; } + +# ---------- 定位 hubctl ---------- + +SOURCE="" +for candidate in \ + "/Applications/Claude Hub.app/Contents/Helpers/hubctl" \ + "$HUB_DIR/HubKit/.build/release/hubctl" \ + "$HUB_DIR/HubKit/.build/debug/hubctl"; do + if [ -x "$candidate" ]; then SOURCE="$candidate"; break; fi +done + +if [ -z "$SOURCE" ]; then + fail "找不到 hubctl。先把 Claude Hub.app 拖进「应用程序」,或运行 bash scripts/build-swift-app.sh" +fi + +mkdir -p "$BIN_DIR" +cp "$SOURCE" "$HUBCTL" +chmod +x "$HUBCTL" +ok "已安装 hubctl → $HUBCTL" + +# ---------- 写 settings.json ---------- + +[ -f "$SETTINGS" ] || echo '{}' > "$SETTINGS" +cp "$SETTINGS" "$SETTINGS.bak.$(date +%Y%m%d%H%M%S)" +log "已备份 $SETTINGS" + +HUBCTL="$HUBCTL" python3 - "$SETTINGS" <<'PYTHON' +import json, os, sys + +path = sys.argv[1] +hubctl = os.environ["HUBCTL"] + +with open(path) as handle: + settings = json.load(handle) + +hooks = settings.setdefault("hooks", {}) + +def strip_legacy(entries): + """移除旧的 bash hook,保留用户自己配的其它 hook。""" + kept = [] + for entry in entries or []: + commands = [h.get("command", "") for h in entry.get("hooks", [])] + if any("hub-hook-" in c or "hubctl hook" in c for c in commands): + continue + kept.append(entry) + return kept + +def install(event, argument, *, blocking): + entries = strip_legacy(hooks.get(event)) + hook = {"type": "command", "command": f"{hubctl} hook {argument}"} + if blocking: + # PreToolUse 要等岛上的决策。 + # + # 90 秒是超时阶梯(HookTimeouts)的最外层,必须大于 hubctl 的 75 秒读超时: + # 如果 Claude 在 hubctl 输出拒绝之前就把它杀掉,那等同于没有输出, + # 也就是**放行** —— 正好是安全默认值的反面。 + hook["timeout"] = 90 + else: + # async: true 很关键 —— 旧配置是 false 且 hook 里同步 afplay, + # 导致每轮回复结束都被阻塞。 + hook["async"] = True + entries.append({"hooks": [hook]}) + hooks[event] = entries + +install("Stop", "stop", blocking=False) +install("Notification", "notification", blocking=False) +install("SessionEnd", "sessionend", blocking=False) +install("PreToolUse", "pretool", blocking=True) + +with open(path, "w") as handle: + json.dump(settings, handle, indent=2, ensure_ascii=False) + handle.write("\n") + +print("已配置 hook:Stop / Notification / SessionEnd / PreToolUse") +PYTHON + +ok "已更新 $SETTINGS" + +# ---------- 自检 ---------- + +echo +log "自检" +if "$HUBCTL" doctor; then + ok "Claude Hub 正在运行,hook 链路已通" +else + warn "Claude Hub 没在运行 —— hook 会全部放行(fail-open,不会卡住 Claude)" + echo ' 启动:open -a "Claude Hub"' +fi + +echo +ok "完成。新开的 Claude 会话会自动生效;已有会话需要重启才会读到新配置。" + +# 从 dmg 双击运行时终端窗口会立刻消失,看不到上面这些输出。 +if [ -z "$HUB_DIR" ] && [ -t 0 ]; then + echo + read -r -p "按回车关闭…" _ +fi diff --git a/scripts/setup.sh b/scripts/setup.sh index b9cf940..fdca0a2 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -29,66 +29,14 @@ if [ ! -f "$SETTINGS_FILE" ]; then echo -e "${YELLOW}创建了 $SETTINGS_FILE${NC}" fi -# 注入 hooks(用 python3 安全修改 JSON) -python3 << PYTHON -import json - -settings_file = "$SETTINGS_FILE" -hub_dir = "$HUB_DIR" - -with open(settings_file, 'r') as f: - settings = json.load(f) - -if "hooks" not in settings: - settings["hooks"] = {} - -hooks_to_add = { - "Stop": { - "script": "hub-hook-stop.sh", - "async": True - }, - "Notification": { - "script": "hub-hook-permission.sh", - "async": True - } -} - -changed = False -for event, config in hooks_to_add.items(): - hook_entry = { - "hooks": [{ - "type": "command", - "command": f"bash {hub_dir}/scripts/{config['script']}", - "async": config["async"] - }] - } - - if event not in settings["hooks"]: - settings["hooks"][event] = [] - - # 检查是否已存在 - existing = [h for h in settings["hooks"][event] - if any(config["script"] in hh.get("command", "") - for hh in h.get("hooks", []))] - - if not existing: - settings["hooks"][event].append(hook_entry) - changed = True - print(f" + {event} hook ({config['script']})") - else: - print(f" ✓ {event} hook 已存在") - -if changed: - with open(settings_file, 'w') as f: - json.dump(settings, f, indent=2, ensure_ascii=False) - print(" 配置已更新") -else: - print(" 无需更新") -PYTHON - -if [ $? -eq 0 ]; then - echo -e "${GREEN}✓ Hooks 配置完成${NC}" -else +# 注入 hooks +# +# 这里**不再自己写 settings.json**,一律转交 setup-swift-hooks.sh。 +# +# 原因:这个脚本以前会注入 hub-hook-stop.sh / hub-hook-permission.sh 那套 bash hook。 +# 它们和现在的 hubctl 是互斥的两套实现,而且这个脚本跑在后面就会把 hubctl 的配置 +# 覆盖回去 —— 「装完 hook 又不生效」的经典来源。配置只允许有一个出口。 +if ! bash "$HUB_DIR/scripts/setup-swift-hooks.sh"; then echo -e "${RED}✗ hook 配置失败${NC}" exit 1 fi @@ -150,7 +98,7 @@ for cmd in tmux say osascript terminal-notifier; do fi done -for script in hub-hook-stop.sh hub-hook-permission.sh hub-run.sh hub-notify.sh; do +for script in hub-run.sh hub-notify.sh project-menu.sh welcome.sh; do if [ -f "$HUB_DIR/scripts/$script" ]; then echo -e "${GREEN}✓ $script${NC}" else @@ -161,18 +109,19 @@ done echo "" echo -e "${GREEN}${BOLD}初始化完成!${NC}" echo "" -echo -e "两个全局 hook 已配置:" -echo -e " ${CYAN}Stop${NC} → Claude 做完事通知你(桌面弹窗 + 语音)" -echo -e " ${CYAN}Notification${NC} → Claude 需要授权时通知你" +echo -e "全局 hook 已配置:" +echo -e " ${CYAN}Stop${NC} → 灵动岛闯入提示 + 可点击跳转的通知" +echo -e " ${CYAN}Notification${NC} → Claude 需要输入/授权时提示" +echo -e " ${CYAN}SessionEnd${NC} → 会话结束时清理岛上的条目" +echo -e " ${CYAN}PreToolUse${NC} → 高风险操作在岛上审批" echo "" echo -e "${YELLOW}子窗口的 Claude 需要重启才能生效。${NC}" -# 编译托盘 app(如果还没编译) -APP_BIN="$HUB_DIR/app/src-tauri/target/release/claude-hub" -if [ ! -f "$APP_BIN" ]; then +# 编译并安装 app +if [ ! -d "/Applications/Claude Hub.app" ]; then echo "" - echo -e "${YELLOW}托盘 app 尚未编译,正在编译(首次需要几分钟)...${NC}" - bash "$HUB_DIR/scripts/build-app.sh" + echo -e "${YELLOW}Claude Hub.app 尚未安装,正在编译(首次需要几分钟)...${NC}" + bash "$HUB_DIR/scripts/build-swift-app.sh" else - echo -e "${GREEN}✓ 托盘 app 已编译${NC}" + echo -e "${GREEN}✓ Claude Hub.app 已安装${NC}" fi