From 1a983b57b848c8f05b3da7e46d18f609a82338d2 Mon Sep 17 00:00:00 2001 From: maguowei Date: Sat, 15 Aug 2026 22:23:04 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(tray):=20=E5=8A=A0=E5=9B=BA=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E8=81=9A=E7=84=A6=20tty=20=E5=8C=B9=E9=85=8D=E4=B8=8E?= =?UTF-8?q?=20pid=20=E8=BA=AB=E4=BB=BD=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ghostty 聚焦改为显式 spec 组合,herdr 默认会话宿主跳不再排除 herdr client - AppleScript try 只包 tty 属性读取;空 cwd 不生成兜底分支;移除 EmptyCwd 死变体 - 按会话文件 procStart 与 ps etime 校验 pid 未被回收,不一致时拒绝聚焦; 菜单项与可点击通知携带 procStart 快照 - 聚焦链路合并为单次 ps spawn;herdr 宿主跳的 lsof 延迟到精确匹配未命中后 - 同步 platform-support 双语文档、tray rules 与 ADR 0004 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/rules/projects-tray-diagnostics.md | 9 +- docs/adr/0004-herdr-session-focus.md | 7 +- docs/platform-support.md | 2 +- docs/platform-support.zh-CN.md | 2 +- docs/user-manual.md | 2 +- docs/user-manual.zh-CN.md | 2 +- src-tauri/src/herdr.rs | 42 +- src-tauri/src/macos_notifications.rs | 9 + src-tauri/src/terminal_focus.rs | 773 +++++++++++++++++---- src-tauri/src/tray.rs | 193 +++-- 10 files changed, 844 insertions(+), 197 deletions(-) diff --git a/.claude/rules/projects-tray-diagnostics.md b/.claude/rules/projects-tray-diagnostics.md index 0b4ba69..b10cd63 100644 --- a/.claude/rules/projects-tray-diagnostics.md +++ b/.claude/rules/projects-tray-diagnostics.md @@ -74,16 +74,17 @@ paths: - 主托盘负责配置切换和页面导航;会话托盘负责 `~/.claude/sessions/*.json` 的状态摘要。 - 设置抽屉负责 UI 语言、主题、本机自启动、默认终端、默认编辑器、托盘展示和诊断入口;主题仍由前端 localStorage 偏好控制,不属于后端 `AppPreferences`。 - 会话文件只读取普通 `.json` 文件,缺少 `pid`、`sessionId`、`cwd`、`status` 或字段为空时应跳过。 -- 会话菜单项 id 需要能安全携带 `pid` 和 `cwd`;`cwd` 可能包含中文、空格、引号和 `::`。 +- 会话菜单项 id 需要能安全携带 `pid`、`cwd` 和创建时的 `procStart` 快照;`cwd` 可能包含中文、空格、引号和 `::`。 - Terminal.app 与 iTerm2 通过 `pid -> tty -> AppleScript` 精确聚焦已有 tab。 -- Ghostty 的 AppleScript 没有 pid/tty API;普通会话按 client title 排除 herdr terminal 后,再按 `working directory` 唯一匹配;herdr 命名会话优先按 client title 匹配,cwd 仅在唯一时兜底;不要使用 `select tab t of w` 这类循环变量 specifier,容易触发 `-1700` 类型错误。 +- 聚焦链路用一次 `ps eww -o tty=,etime=,command=` 同时取 TERM_PROGRAM、控制终端与运行时长,不要拆成多次 spawn;pid 按会话文件 `procStart`(UTC)与 etime 推算的启动时间校验身份,`procStart` 缺失、非法或不一致(pid 已被回收)时拒绝整个聚焦请求,不得回退到 Ghostty cwd 或 herdr 的 pid 匹配。菜单项与可点击通知必须携带创建时的 `procStart` 快照,点击时不得按 pid 重读会话文件。 +- Ghostty 的 `tty` 属性由上游 #11592 引入(随 PR #11922 合入 main、尚未发布;1.3.x 的 sdef 没有,属性读取会抛 `-1700`):`try` 只包住 `tty of term` 属性读取,探测失败置标志跳过后续比较,`focus`/`return` 必须留在 try 外——新版按 tty 精确命中,旧版自然落到兜底。空 cwd 不生成 cwd 兜底分支(AppleScript 的 `"" is ""` 为 true,会误命中无 shell 集成的 tab)。普通会话按 client title 排除 herdr terminal 后再按 `working directory` 唯一匹配;herdr 宿主跳(含默认会话)永不排除 herdr client;不要使用 `select tab t of w` 这类循环变量 specifier,容易触发 `-1700` 类型错误。 - herdr 会话(跑在 herdr pane 里的 Claude Code)走"两跳聚焦":先连 herdr unix socket API 按 pid 定位 pane 并 `pane.focus`,再找到附着的 herdr client 进程(其 tty 即宿主 tab 的 tty)复用宿主终端 AppleScript。逻辑集中在 `src-tauri/src/herdr.rs`,`terminal_focus.rs` 只做编排。 - herdr 检测:先读会话进程 env(`HERDR_SESSION` / `HERDR_SOCKET_PATH` 标记,命名会话才有),无标记时沿 ppid 链找名为 `herdr` 的祖先进程(默认会话没有 env 标记,只能靠进程树)。 - herdr socket 路径:`HERDR_SOCKET_PATH` > `/herdr/sessions//herdr.sock`(命名会话)> `/herdr/herdr.sock`;`HERDR_SESSION` 必须按 herdr 命名规则白名单校验,防路径穿越。 - herdr pane 匹配:pid 精确匹配优先(`pane.list` + 逐 pane `pane.process_info`,命中 foreground pid / shell_pid / 进程组 id),失配或歧义时用 `agent.list` 按 cwd 兜底;兜底也只允许唯一匹配。 -- herdr 宿主跳:扫描 `herdr` 进程(comm 可能带完整路径,如 `/opt/homebrew/bin/herdr`,需同时匹配)+ 会话一致性(env 的 `HERDR_SESSION`/`HERDR_SOCKET_PATH`,或 argv 的 `--session ` / `session attach `——herdr 0.7.x 的 client env 不带标记,会话名只在 argv)+ 真实 tty 过滤(daemon 无 tty 天然排除);Ghostty 宿主优先按命名会话的 client title 匹配,cwd(`lsof`)仅作唯一结果兜底,不要用 pane 的 cwd。 +- herdr 宿主跳:扫描 `herdr` 进程(comm 可能带完整路径,如 `/opt/homebrew/bin/herdr`,需同时匹配)+ 会话一致性(env 的 `HERDR_SESSION`/`HERDR_SOCKET_PATH`,或 argv 的 `--session ` / `session attach `——herdr 0.7.x 的 client env 不带标记,会话名只在 argv)+ 真实 tty 过滤(daemon 无 tty 天然排除);Ghostty 宿主优先按命名会话的 client title 匹配,client tty 次之,cwd(`lsof`,仅在 title/tty 都未命中后才解析)作唯一结果兜底,不要用 pane 的 cwd。 - herdr 降级语义:socket 跳失败(`HerdrNotRunning` / `HerdrPaneNotFound`)才向用户报错;宿主跳失败或找不到 client(detach / ssh 远程附着)按"部分成功"只记 warn,不弹通知。 -- 聚焦可用性门禁按会话判定(`session_focus_available`):macOS 且(默认终端支持聚焦,或 pid 自身宿主终端支持,或会话在 herdr 里);全局快捷键只挑可聚焦会话。 +- 聚焦可用性门禁按会话判定(`session_focus_available`):macOS、会话 `procStart` 可验证,且(默认终端支持聚焦,或 pid 自身宿主终端支持,或会话在 herdr 里);全局快捷键只挑可聚焦会话。 - 未命中或聚焦失败只记录 warn 日志,不要自动新开窗口或 tab。 - `osascript` 可能较慢,托盘点击 handler 不应阻塞 UI 事件循环。 diff --git a/docs/adr/0004-herdr-session-focus.md b/docs/adr/0004-herdr-session-focus.md index dd0a04d..a1b4102 100644 --- a/docs/adr/0004-herdr-session-focus.md +++ b/docs/adr/0004-herdr-session-focus.md @@ -8,10 +8,10 @@ ## Decision -1. **两跳聚焦**:第一跳连 herdr socket API,`pane.list` + 逐 pane `pane.process_info` 按 pid 精确匹配(命中 foreground pid / shell_pid / 进程组 id),失配时 `agent.list` 按 cwd 兜底且只允许唯一匹配;命中后 `pane.focus`。第二跳扫描本机 `herdr` 进程,按会话一致性(env 标记或 argv 会话参数)+ 真实 tty 过滤找到 client(daemon 无 tty 天然排除),其 tty 即宿主 tab tty,复用现有 Terminal/iTerm AppleScript;Ghostty 普通会话先按 terminal title 排除 herdr client,再对 client cwd(`lsof`)做唯一匹配;herdr 宿主优先按命名会话的 client title 匹配,cwd 仅在唯一时兜底。socket 跳失败即报错并跳过宿主跳。 +1. **两跳聚焦**:第一跳连 herdr socket API,`pane.list` + 逐 pane `pane.process_info` 按 pid 精确匹配(命中 foreground pid / shell_pid / 进程组 id),失配时 `agent.list` 按 cwd 兜底且只允许唯一匹配;命中后 `pane.focus`。第二跳扫描本机 `herdr` 进程,按会话一致性(env 标记或 argv 会话参数)+ 真实 tty 过滤找到 client(daemon 无 tty 天然排除),其 tty 即宿主 tab tty,复用现有 Terminal/iTerm AppleScript;Ghostty 侧 tty 优先(`tty` 属性上游 #11592 起、随 PR #11922 合入 main 尚未发布,旧版脚本内 `try` 降级),普通会话先按 terminal title 排除 herdr client,再对会话文件记录的 cwd 做唯一匹配;herdr 宿主(含默认会话,永不排除 herdr client)优先按命名会话的 client title 匹配,client tty 次之,都未命中才解析 client cwd(`lsof`,延迟到精确匹配失败后)做唯一兜底。socket 跳失败即报错并跳过宿主跳。 2. **降级语义**:socket 跳成功但宿主跳失败或找不到 client(detach / ssh 远程附着)按"部分成功"处理,只记 warn 不弹通知——herdr 内部焦点已切换,没有窗口可激活不是错误。 3. **检测**:先读 pane 进程 env(`HERDR_SESSION` / `HERDR_SOCKET_PATH` 标记),无标记再沿 ppid 链找名为 `herdr` 的祖先进程(覆盖默认会话);`HERDR_SESSION` 按 herdr 命名规则白名单校验防路径穿越。 -4. **门禁放宽**:聚焦可用性从"只看默认终端 slug"改为按会话判定(macOS 且默认终端支持,或 pid 自身宿主终端支持,或会话在 herdr 里);全局快捷键只挑可聚焦会话。不做"菜单扫描成本"的缓存——ps 调用在菜单重建频率下可忽略。 +4. **门禁放宽**:聚焦可用性从"只看默认终端 slug"改为按会话判定(macOS、会话 `procStart` 可验证,且默认终端支持,或 pid 自身宿主终端支持,或会话在 herdr 里);全局快捷键只挑可聚焦会话。不做"菜单扫描成本"的缓存——ps 调用在菜单重建频率下可忽略。 5. **实现位置**:herdr 侧逻辑独立成 `herdr.rs`,`terminal_focus.rs` 只做编排,AppleScript 模板不重复。socket 用 std `UnixStream`,零新依赖,1s 读写超时防后台线程卡死。 ## Consequences @@ -19,6 +19,7 @@ - 协议按 herdr 源码快照实现(无官方 schema 文件);herdr 协议若变动,socket 调用失败会走 `HerdrNotRunning` / 通用 `ScriptError` 兜底,不会崩溃,但需要跟进。 - 宿主终端必须仍受支持(Terminal / iTerm / Ghostty)才有完整两跳;herdr 跑在 Warp 等无 AppleScript 宿主里只能得到内部聚焦 + warn。 - 多 tab 附着同一 herdr 会话时取第一个 client(UI 内容相同,聚焦任一都正确);本地找不到 client 时不激活窗口。 -- Ghostty 没有 pid/tty AppleScript API;命名会话的 title 可区分同 cwd 的多个 client,title 未命中且 cwd 多匹配时宁可降级为内部聚焦成功,也不误激活其它会话。 +- Ghostty 的 `tty` 属性由上游 #11592 引入(随 PR #11922 合入 main、尚未发布,1.3.x 的 sdef 没有):`try` 只包住属性读取,新版按 tty 精确命中,旧版探测失败后落到 title/cwd 兜底;空 cwd 不生成兜底分支(`"" is ""` 为 true,会误命中无 shell 集成的 tab)。命名会话的 title 可区分同 cwd 的多个 client,title 未命中且 cwd 多匹配时宁可降级为内部聚焦成功,也不误激活其它会话。 - 普通 Ghostty 会话与 herdr client 共享 cwd 时,普通路径必须先排除 herdr title;若剩余普通 terminal 仍多于一个,继续降级失败,避免无法精确识别时误激活其它会话。 +- pid 身份校验:会话文件 `procStart`(UTC)必须存在、可解析且与 `ps etime` 推算的进程启动时间一致;缺失、非法或不一致时拒绝整个聚焦请求,不再把 cwd / herdr 的 pid 兜底用于可能已回收的 pid。菜单项与可点击通知携带创建时的 `procStart` 快照,点击时不按 pid 重读会话文件。 - 后续其它多路复用器(如 tmux)支持会以"检测 → 内部聚焦 API → 宿主激活"三段式结构为参照,但各自机制不同,不应直接套用 herdr 实现。 diff --git a/docs/platform-support.md b/docs/platform-support.md index 7ff4ab3..80fa9ef 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -71,7 +71,7 @@ Legend: ✅ full support; ⚠️ degraded / limited; ❌ unavailable; N/A the pl - **Highest level of support.** All features are available, including terminal session focus, clickable notifications, and Dock activation policy switching. - The application data directory is intentionally placed at `~/.config/code-manager/` rather than the system-standard `~/Library/Application Support/`, to ease cross-platform backup and script access (see the section below). -- Terminal session focus follows `pid → tty → AppleScript`, supporting Terminal.app / iTerm2; because Ghostty does not yet expose pid/tty via AppleScript (Ghostty Issue #11592), it can only be approximately matched by `cwd`; Warp has no official AppleScript, so its tray menu item is set to disabled. +- Terminal session focus follows `pid → tty → AppleScript`, supporting Terminal.app / iTerm2 / Ghostty; Ghostty matches the tab by its `tty` property (upstream #11592, merged to main via PR #11922 but not yet in a stable release), falling back to unique-`cwd` matching on older versions; Warp has no official AppleScript, so its tray menu item is set to disabled. - If Gatekeeper blocks the first open, you can remove the quarantine attribute: ```bash xattr -rd com.apple.quarantine /Applications/code-manager.app diff --git a/docs/platform-support.zh-CN.md b/docs/platform-support.zh-CN.md index 80e04b2..4e2ca2c 100644 --- a/docs/platform-support.zh-CN.md +++ b/docs/platform-support.zh-CN.md @@ -71,7 +71,7 @@ Code Manager 的核心配置管理与界面在 macOS / Linux / Windows 上对等 - **支持度最高**。所有功能均可用,包括终端会话聚焦、可点击通知和 Dock 激活策略切换。 - 应用数据目录刻意放在 `~/.config/code-manager/` 而非系统标准的 `~/Library/Application Support/`,便于跨平台备份与脚本访问(详见下节)。 -- 终端会话聚焦走 `pid → tty → AppleScript`,支持 Terminal.app / iTerm2;Ghostty 因为 AppleScript 还没暴露 pid/tty(Ghostty Issue #11592),只能按 `cwd` 近似匹配;Warp 没有官方 AppleScript,托盘菜单项会被置为 disabled。 +- 终端会话聚焦走 `pid → tty → AppleScript`,支持 Terminal.app / iTerm2 / Ghostty;Ghostty 按 `tty` 属性精确匹配(上游 #11592 引入,随 PR #11922 合入 main,尚未发布),旧版降级为按 `cwd` 唯一匹配;Warp 没有官方 AppleScript,托盘菜单项会被置为 disabled。 - 首次打开如果被 Gatekeeper 拦截,可移除隔离属性: ```bash xattr -rd com.apple.quarantine /Applications/code-manager.app diff --git a/docs/user-manual.md b/docs/user-manual.md index f74a27c..b252c05 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -276,7 +276,7 @@ Code Manager stays in the system tray (menu bar), with a menu divided into two p - Main tray: switch the current configuration, quickly jump to each page, and quit the application. Switching the configuration is equivalent to enabling the corresponding configuration on the Configurations page. - Session tray: reads `~/.claude/sessions/*.json` and summarizes the current Claude sessions by status (awaiting input / working / idle). Whether it is shown, the character limit, the session count style, and the pending breathing indicator are all adjusted in Settings (see below). -**Session focus**: on supported platforms, clicking a session entry or using the session focus shortcut returns you to the corresponding terminal tab. This capability is **macOS only**; it precisely focuses Terminal.app and iTerm2 via `pid → tty → AppleScript`, approximately matches Ghostty by working directory, and does not yet support Warp due to the lack of an official AppleScript. Linux and Windows do not support automatic focus, and clicking a session will not switch the terminal. See [Platform Support Differences](./platform-support.md) for details. +**Session focus**: on supported platforms, clicking a session entry or using the session focus shortcut returns you to the corresponding terminal tab. This capability is **macOS only**; it focuses Terminal.app and iTerm2 via `pid → tty → AppleScript`, and Ghostty first matches its `tty` property before using a unique working-directory fallback on older versions or when tty matching misses. Warp is not supported because it has no official AppleScript. Linux and Windows do not support automatic focus, and clicking a session will not switch the terminal. See [Platform Support Differences](./platform-support.md) for details. **LED light effect integration (macOS only)**: once enabled, the red/green status of the session tray is mirrored to the light effects of an external ANTICATER USB device, which is handy for getting a hardware light cue about session status when you are not watching the menu bar. The configuration entry is in the device integration area of Settings. diff --git a/docs/user-manual.zh-CN.md b/docs/user-manual.zh-CN.md index eac2c2b..da690c3 100644 --- a/docs/user-manual.zh-CN.md +++ b/docs/user-manual.zh-CN.md @@ -276,7 +276,7 @@ Code Manager 常驻系统托盘(菜单栏),菜单分两部分: - 主托盘:切换当前配置、快速跳转到各页面、退出应用。切换配置等价于在配置页启用对应配置。 - 会话托盘:读取 `~/.claude/sessions/*.json`,按状态汇总当前 Claude 会话(等待输入 / 工作中 / 空闲)。是否显示、字符限制、会话计数样式和待处理呼吸灯都在设置中调整(见下文)。 -**会话聚焦**:在支持的平台点击会话条目,或使用会话聚焦快捷键,可回到对应终端 tab。该能力**仅 macOS** 可用,通过 `pid → tty → AppleScript` 精确聚焦 Terminal.app 与 iTerm2,Ghostty 按工作目录近似匹配,Warp 因缺少官方 AppleScript 暂不支持。Linux 与 Windows 不支持自动聚焦,点击会话不会切换终端。详见 [平台支持差异](./platform-support.zh-CN.md)。 +**会话聚焦**:在支持的平台点击会话条目,或使用会话聚焦快捷键,可回到对应终端 tab。该能力**仅 macOS** 可用,Terminal.app 与 iTerm2 走 `pid → tty → AppleScript` 精确聚焦,Ghostty 优先按 `tty` 属性匹配,旧版本或 tty 未命中时再按工作目录做唯一兜底。Warp 因缺少官方 AppleScript 暂不支持。Linux 与 Windows 不支持自动聚焦,点击会话不会切换终端。详见 [平台支持差异](./platform-support.zh-CN.md)。 **LED 灯效联动(仅 macOS)**:启用后,会话托盘的红绿状态会镜像到外接 ANTICATER USB 设备灯效,适合不盯着菜单栏时用硬件灯提示会话状态。配置入口在设置的设备联动区。 diff --git a/src-tauri/src/herdr.rs b/src-tauri/src/herdr.rs index 1c06607..a645cee 100644 --- a/src-tauri/src/herdr.rs +++ b/src-tauri/src/herdr.rs @@ -62,7 +62,16 @@ pub struct HerdrSessionContext { /// 都没有再沿 ppid 链找名为 `herdr` 的祖先(覆盖默认会话)。 pub fn detect_herdr_session(pid: u32) -> Option { let env_output = ps_env_output(pid)?; - let ctx = herdr_context_from_ps_output(&env_output); + detect_herdr_session_from_env(pid, &env_output) +} + +/// 基于调用方已获取的进程环境做 herdr 检测(复用同一次 `ps` 输出,避免重复 spawn)。 +/// 环境为空(进程已退出)时按非 herdr 处理。 +pub(crate) fn detect_herdr_session_from_env( + pid: u32, + env_output: &str, +) -> Option { + let ctx = herdr_context_from_ps_output(env_output); if ctx.session_name.is_some() || ctx.socket_override.is_some() { return Some(ctx); } @@ -123,15 +132,20 @@ pub fn focus_herdr_session( .or(ctx.host_terminal) .unwrap_or(fallback_slug); let host_result = match host_slug { - // Ghostty 没有 tty API:命名会话优先按 client title 匹配,cwd 只做唯一兜底; - // cwd 必须是 client 进程的 cwd(用户敲 `herdr` 的目录),而不是 pane 的 cwd。 - "ghostty" => match process_cwd(client.pid) { - Some(client_cwd) => crate::terminal_focus::focus_ghostty_via_herdr_session( - &client_cwd, + // Ghostty 没有 pid API,但上游 #11592 起有 tty 属性:client.tty 就是宿主 tab 的 tty + // (tty 是发现 client 的过滤条件,必有值)。title/tty 精确匹配不依赖 cwd; + // client cwd(用户敲 `herdr` 的目录,不是 pane 的 cwd)由 lsof 延迟解析, + // 只在精确匹配未命中后才做唯一兜底。 + "ghostty" => { + let run_script = + |script: &str| crate::terminal_focus::run_osascript_returning_bool(script); + crate::terminal_focus::focus_ghostty_herdr_host( + client.tty.as_str(), ctx.session_name.as_deref(), - ), - None => Err(FocusFailure::EmptyCwd), - }, + || process_cwd(client.pid), + &run_script, + ) + } // tty 类终端复用 terminal_focus 的单点映射,client.tty 直接聚焦,不重复 pid 反查。 slug => match crate::terminal_focus::tty_terminal_script(slug) { Some((label, build_script)) => { @@ -448,15 +462,15 @@ fn focus_pane(socket_path: &Path, pane_id: &str) -> Result<(), SocketError> { fn find_attached_client(ctx: &HerdrSessionContext) -> Option { let processes = list_herdr_processes()?; processes.into_iter().find_map(|(pid, argv)| { - let env = ps_env_output(pid)?; - if !client_matches_session(&env, &argv, ctx) { + // 一次 ps 同时取环境与 tty,替代 ps_env_output + pid_to_tty 两次 spawn。 + let info = crate::terminal_focus::ps_tty_and_env(pid)?; + if !client_matches_session(&info.env_output, &argv, ctx) { return None; } - let tty = crate::terminal_focus::pid_to_tty(pid)?; Some(HerdrClientInfo { pid, - tty, - host_terminal: crate::terminal_focus::terminal_app_from_ps_output(&env), + tty: info.tty?, + host_terminal: crate::terminal_focus::terminal_app_from_ps_output(&info.env_output), }) }) } diff --git a/src-tauri/src/macos_notifications.rs b/src-tauri/src/macos_notifications.rs index 0a09669..f554ce3 100644 --- a/src-tauri/src/macos_notifications.rs +++ b/src-tauri/src/macos_notifications.rs @@ -21,6 +21,7 @@ const PAYLOAD_SESSION_ID: &str = "session_id"; const PAYLOAD_PID: &str = "pid"; const PAYLOAD_CWD: &str = "cwd"; const PAYLOAD_TERMINAL_APP: &str = "terminal_app"; +const PAYLOAD_PROC_START: &str = "proc_start"; const NOTIFICATION_THREAD: &str = "pending-session-focus"; static NOTIFICATION_DELEGATE_SETUP: Once = Once::new(); @@ -190,12 +191,14 @@ pub(crate) fn parse_pending_session_payload( let session_id = non_empty_payload_value(fields, PAYLOAD_SESSION_ID)?; let cwd = non_empty_payload_value(fields, PAYLOAD_CWD)?; let terminal_app = non_empty_payload_value(fields, PAYLOAD_TERMINAL_APP)?; + let proc_start = non_empty_payload_value(fields, PAYLOAD_PROC_START)?; Some(PendingSessionFocusTarget { pid, cwd, session_id, terminal_app, + proc_start, }) } @@ -216,6 +219,7 @@ fn build_pending_session_user_info( NSString::from_str(PAYLOAD_PID), NSString::from_str(PAYLOAD_CWD), NSString::from_str(PAYLOAD_TERMINAL_APP), + NSString::from_str(PAYLOAD_PROC_START), ]; let values = [ NSString::from_str(PENDING_SESSION_FOCUS_KIND), @@ -223,6 +227,7 @@ fn build_pending_session_user_info( NSString::from_str(&target.pid.to_string()), NSString::from_str(&target.cwd), NSString::from_str(&target.terminal_app), + NSString::from_str(&target.proc_start), ]; let key_refs = keys.iter().map(|key| &**key).collect::>(); let value_refs = values.iter().map(|value| &**value).collect::>(); @@ -260,6 +265,7 @@ fn payload_fields_from_user_info( PAYLOAD_PID, PAYLOAD_CWD, PAYLOAD_TERMINAL_APP, + PAYLOAD_PROC_START, ] .into_iter() .filter_map(|key| { @@ -306,6 +312,7 @@ fn focus_pending_session_from_notification( target.pid, &target.cwd, &target.terminal_app, + Some(&target.proc_start), ) { let prefs = crate::config::load_registry_or_default().app; crate::tray::notify_session_focus_failure( @@ -345,6 +352,7 @@ mod tests { (PAYLOAD_PID, "4242"), (PAYLOAD_CWD, "/Users/demo/work/code-manager"), (PAYLOAD_TERMINAL_APP, "ghostty"), + (PAYLOAD_PROC_START, "Wed Aug 12 15:27:23 2026"), ]); let target = parse_pending_session_payload(&fields).expect("payload should parse"); @@ -356,6 +364,7 @@ mod tests { cwd: "/Users/demo/work/code-manager".to_string(), session_id: "session-123".to_string(), terminal_app: "ghostty".to_string(), + proc_start: "Wed Aug 12 15:27:23 2026".to_string(), } ); } diff --git a/src-tauri/src/terminal_focus.rs b/src-tauri/src/terminal_focus.rs index 4c85b5a..93858ee 100644 --- a/src-tauri/src/terminal_focus.rs +++ b/src-tauri/src/terminal_focus.rs @@ -1,9 +1,16 @@ //! 托盘会话项点击后的"聚焦终端 tab"实现。 //! //! 设计要点: +//! - 一次 `ps eww -p -o tty=,etime=,command=` 同时取进程环境(TERM_PROGRAM / +//! herdr 标记)、控制终端与运行时长,检测、终端识别与 tty 反查不再各自 spawn。 +//! - pid 回收校验:会话文件记录的 `procStart`(UTC)必须能与按 etime 推算的进程启动时间 +//! 对上;缺失、非法或不一致时拒绝使用该 pid,避免把 tty / herdr pane 交给新进程。 //! - Terminal.app / iTerm2 走 pid → tty → AppleScript 精确定位。 -//! - Ghostty 1.3 的 AppleScript 还没暴露 pid/tty(见 Issue #11592);herdr 命名会话优先按 -//! client title 匹配,普通会话排除 herdr client 后按 working directory 唯一匹配。 +//! - Ghostty 的 `tty` 属性由上游 #11592 引入(1.4 起随 PR #11922 发布,1.3.x 的 sdef +//! 还没有):脚本里只对 `tty of term` 属性读取做 try 包裹,新版按 tty 精确命中, +//! 旧版探测失败后自然落到既有兜底——herdr 宿主按命名会话 title 匹配,普通会话 +//! 排除 herdr client 后按 working directory 唯一匹配。空 cwd 不生成兜底分支, +//! 避免 AppleScript 的 `"" is ""` 误命中没有工作目录的 tab。 //! - Warp 没有官方 AppleScript,托盘菜单项会被设为 disabled,正常不会调到本模块。 //! - 命中失败会记 warn 日志,并把失败原因作为 Err 返回给调用方用于给用户反馈。 //! 调用方负责决定是否新开窗口;本模块本身绝不自动新开 tab。 @@ -15,10 +22,10 @@ use std::process::Command; pub enum FocusFailure { /// pid 无法反查到 tty,通常是会话进程已退出。 TtyNotFound, + /// 会话文件中的 procStart 缺失/非法,或与当前 pid 的启动时间不一致。 + ProcessIdentityMismatch, /// tty/cwd 匹配不到任何 tab,通常是 tab 已被手动关闭。 TabNotFound, - /// 会话记录里没有 cwd,无法按工作目录匹配(仅 Ghostty 路径)。 - EmptyCwd, /// 当前默认终端 slug 不支持外部聚焦。 Unsupported(String), /// osascript 调用本身失败,详情已写入日志。 @@ -45,10 +52,12 @@ impl FocusFailure { (true, Self::TtyNotFound) => { "The session process has exited; cannot locate the terminal tab.".to_string() } + (true, Self::ProcessIdentityMismatch) => { + "The session process identity could not be verified; focus was skipped.".to_string() + } (true, Self::TabNotFound) => { "No matching terminal tab was found. It may have been closed.".to_string() } - (true, Self::EmptyCwd) => "Session has no working directory to focus.".to_string(), (true, Self::Unsupported(slug)) => { format!("Terminal '{slug}' does not support external focus.") } @@ -60,8 +69,10 @@ impl FocusFailure { "No matching herdr pane was found. It may have been closed.".to_string() } (false, Self::TtyNotFound) => "会话进程已退出,无法定位终端 tab。".to_string(), + (false, Self::ProcessIdentityMismatch) => { + "无法验证会话进程身份,已跳过聚焦。".to_string() + } (false, Self::TabNotFound) => "未找到对应的终端 tab,可能已被关闭。".to_string(), - (false, Self::EmptyCwd) => "会话缺少工作目录,无法聚焦。".to_string(), (false, Self::Unsupported(slug)) => format!("终端 {slug} 不支持外部聚焦。"), (false, Self::ScriptError) => "调用终端失败,详情可查看日志。".to_string(), (false, Self::HerdrNotRunning) => "未检测到运行中的 herdr 服务。".to_string(), @@ -115,30 +126,212 @@ pub(crate) fn terminal_app_from_ps_output(output: &str) -> Option<&'static str> /// - 命中:返回 Ok(())。 /// - 未命中或调用失败:返回 Err(FocusFailure),同时在内部记 warn 日志。 /// 调用方仅负责把失败原因转成系统通知 / Toast,不会自动新开 tab。 -pub fn focus_session_in_terminal(pid: u32, cwd: &str, app_slug: &str) -> Result<(), FocusFailure> { +/// - `proc_start` 是会话文件记录的进程启动时间(`procStart`,UTC),用于校验 pid +/// 未被回收;缺失或非法时拒绝聚焦。 +pub fn focus_session_in_terminal( + pid: u32, + cwd: &str, + app_slug: &str, + proc_start: Option<&str>, +) -> Result<(), FocusFailure> { + let Some(proc_start) = proc_start else { + log::warn!( + "event=tray.session_focus status=miss reason=process_identity_unavailable pid={pid}" + ); + return Err(FocusFailure::ProcessIdentityMismatch); + }; + if !proc_start_is_valid(proc_start) { + log::warn!( + "event=tray.session_focus status=miss reason=process_identity_invalid pid={pid}" + ); + return Err(FocusFailure::ProcessIdentityMismatch); + } + + // 一次 ps 同时取环境(TERM_PROGRAM / herdr 标记)、控制终端与运行时长。 + let Some(info) = ps_tty_and_env(pid) else { + return Err(FocusFailure::TtyNotFound); + }; + let tty = verified_pid_tty(pid, &info, proc_start)?; + let env_output = info.env_output.as_str(); // herdr 会话优先走两跳聚焦:socket 定位 pane + 宿主终端激活。 // 检测本身也是从 pid 环境/进程树判断,失败即视为非 herdr 会话。 - if let Some(ctx) = crate::herdr::detect_herdr_session(pid) { + if let Some(ctx) = crate::herdr::detect_herdr_session_from_env(pid, env_output) { return crate::herdr::focus_herdr_session(pid, cwd, &ctx, app_slug); } // 优先使用目标进程的终端,读取失败再回退设置中的默认终端。 - let app_slug = terminal_app_from_pid(pid).unwrap_or(app_slug); + let app_slug = terminal_app_from_ps_output(env_output).unwrap_or(app_slug); match app_slug { - // 普通会话没有可区分的 identity,Ghostty 按唯一 working directory 匹配。 - "ghostty" => focus_ghostty_via_cwd(cwd), + // Ghostty 优先按 tty 精确匹配(上游 #11592 起支持),旧版降级为唯一 working directory。 + "ghostty" => { + let run_script = |script: &str| run_osascript_returning_bool(script); + focus_ghostty_regular_session(tty.as_deref(), cwd, &run_script) + } // tty 类终端(Terminal/iTerm)共用 pid → tty → AppleScript 路径。 slug => match tty_terminal_script(slug) { - Some((label, build_script)) => focus_via_tty(label, pid, build_script), + Some((label, build_script)) => match tty.as_deref() { + Some(tty) => focus_tty(label, tty, build_script), + None => { + log::warn!( + "event=tray.session_focus status=miss reason=tty_not_found app={label} pid={pid}" + ); + Err(FocusFailure::TtyNotFound) + } + }, None => Err(FocusFailure::Unsupported(slug.to_string())), }, } } +/// pid 回收校验的容忍窗口(秒):procStart 记录与 etime 推算同源系统时钟, +/// 正常偏差只有亚秒级;超过窗口即认为 pid 已被新进程回收。 +const PID_START_TOLERANCE_SECS: i64 = 30; + +/// `ps eww -p -o tty=,etime=,command=` 的解析结果。 +pub(crate) struct PsProcessInfo { + /// 控制终端的绝对路径;后台进程为 None。 + pub(crate) tty: Option, + /// 进程已运行的秒数,用于 pid 回收校验;解析失败为 None。 + pub(crate) etime_secs: Option, + /// command + 完整环境输出。包含敏感信息,只做白名单提取,绝不能写入日志。 + pub(crate) env_output: String, +} + +/// 一次 `ps eww -p -o tty=,etime=,command=` 同时取控制终端、运行时长与进程 +/// 环境,替代原先 tty 反查 / 终端识别 / herdr 检测各自的独立 spawn。 +/// 进程不存在(已退出)时返回 None。 +pub(crate) fn ps_tty_and_env(pid: u32) -> Option { + let output = Command::new("ps") + .args(["eww", "-p", &pid.to_string(), "-o", "tty=,etime=,command="]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + ps_process_info_from_output(&String::from_utf8_lossy(&output.stdout)) +} + +/// 解析 `ps -o tty=,etime=,command=` 输出:前两个 token 是 tty 与 etime 列, +/// 其余整体是 command + 环境。抽成纯函数便于单测。 +fn ps_process_info_from_output(raw: &str) -> Option { + let mut tokens = raw.split_ascii_whitespace(); + // 后台进程没有控制终端(`??`)时 tty 为 None,但环境仍要保留给后续白名单提取。 + let tty = parse_ps_tty_output(tokens.next()?); + let etime_secs = parse_ps_etime_secs(tokens.next()?); + let env_output = tokens.collect::>().join(" "); + Some(PsProcessInfo { + tty, + etime_secs, + env_output, + }) +} + +/// 校验 pid 未被回收后才放行 tty:会话记录的 `procStart` 与按 etime 推算的进程 +/// 启动时间一致才返回 tty;不一致说明 pid 已被新进程占用,tty 指向无关 tab, +/// 必须拒绝整个聚焦请求,不能回退到 cwd 或 herdr 的 pid 匹配。 +fn verified_pid_tty( + pid: u32, + info: &PsProcessInfo, + proc_start: &str, +) -> Result, FocusFailure> { + if !pid_start_matches_recorded(info.etime_secs, Some(proc_start)) { + log::warn!("event=tray.session_focus status=miss reason=pid_identity_mismatch pid={pid}"); + return Err(FocusFailure::ProcessIdentityMismatch); + } + Ok(info.tty.clone()) +} + +/// 比较"当前时间 - etime"推算出的进程启动时间与会话记录的 `procStart`(UTC)。 +fn pid_start_matches_recorded(etime_secs: Option, proc_start: Option<&str>) -> bool { + let Some(etime) = etime_secs else { + return false; + }; + let Some(recorded) = proc_start.and_then(parse_proc_start_epoch) else { + return false; + }; + let derived = crate::utils::current_timestamp() as i64 - etime; + (derived - recorded).abs() <= PID_START_TOLERANCE_SECS +} + +/// 解析 `ps -o etime=` 输出(`MM:SS` / `HH:MM:SS` / `D-HH:MM:SS`)为秒数。 +fn parse_ps_etime_secs(raw: &str) -> Option { + let trimmed = raw.trim(); + let (days, time_part) = match trimmed.split_once('-') { + Some((days, rest)) => (days.parse::().ok()?, rest), + None => (0, trimmed), + }; + let mut secs = days * 86_400; + let parts: Vec<&str> = time_part.split(':').collect(); + match parts.len() { + // HH:MM:SS + 3 => { + secs += parts[0].parse::().ok()? * 3_600 + + parts[1].parse::().ok()? * 60 + + parse_ps_etime_seconds(parts[2])?; + } + // MM:SS + 2 => { + secs += parts[0].parse::().ok()? * 60 + parse_ps_etime_seconds(parts[1])?; + } + _ => return None, + } + Some(secs) +} + +/// macOS 的 `etime` 在部分版本中会把秒写成 `SS.hh`;身份校验只需整秒精度, +/// 但必须拒绝非数字的小数部分,不能因宽松解析把异常输出当成有效时长。 +fn parse_ps_etime_seconds(raw: &str) -> Option { + let (whole, fraction) = raw.split_once('.').unwrap_or((raw, "")); + if !fraction.is_empty() && !fraction.chars().all(|ch| ch.is_ascii_digit()) { + return None; + } + whole.parse::().ok() +} + +/// 解析会话文件 `procStart`(`Www Mmm dd HH:MM:SS yyyy`,UTC)为 epoch 秒。 +/// 星期部分不参与解析(只用于展示);字段缺失或非法返回 None。 +fn parse_proc_start_epoch(raw: &str) -> Option { + const MONTHS: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]; + let parts: Vec<&str> = raw.split_ascii_whitespace().collect(); + if parts.len() != 5 { + return None; + } + let month = MONTHS.iter().position(|month| *month == parts[1])? as i64 + 1; + let day = parts[2].parse::().ok()?; + let year = parts[4].parse::().ok()?; + let time_parts: Vec<&str> = parts[3].split(':').collect(); + if time_parts.len() != 3 { + return None; + } + let (hour, minute, second) = ( + time_parts[0].parse::().ok()?, + time_parts[1].parse::().ok()?, + time_parts[2].parse::().ok()?, + ); + Some(days_from_civil(year, month, day) * 86_400 + hour * 3_600 + minute * 60 + second) +} + +/// 判断会话文件中的进程启动时间是否可用于聚焦身份校验。 +pub(crate) fn proc_start_is_valid(raw: &str) -> bool { + parse_proc_start_epoch(raw).is_some() +} + +/// 公历日期 → 自 1970-01-01 起的天数(Howard Hinnant 的 days_from_civil 算法)。 +fn days_from_civil(year: i64, month: i64, day: i64) -> i64 { + let year = if month <= 2 { year - 1 } else { year }; + let era = if year >= 0 { year } else { year - 399 } / 400; + let year_of_era = year - era * 400; + let day_of_year = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + day - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + era * 146_097 + day_of_era - 719_468 +} + /// tty 类终端的 AppleScript 生成器:入参是转义后的 tty,返回完整脚本。 pub(crate) type TtyScriptBuilder = fn(&str) -> String; /// slug → (终端展示名, tty AppleScript 生成器)。tty 类终端(Terminal/iTerm)的唯一映射源, -/// terminal_focus 的 pid 分发与 herdr 宿主跳共用;Ghostty 走 cwd 不在此表内。 +/// terminal_focus 的 pid 分发与 herdr 宿主跳共用;Ghostty 走 tty/cwd 不在此表内。 /// 新增 tty 类终端只改这一处。 pub(crate) fn tty_terminal_script(slug: &str) -> Option<(&'static str, TtyScriptBuilder)> { match slug { @@ -148,21 +341,6 @@ pub(crate) fn tty_terminal_script(slug: &str) -> Option<(&'static str, TtyScript } } -/// 通过 pid 反查 tty,再用对应终端的 AppleScript 选中 tab。 -fn focus_via_tty( - app_label: &'static str, - pid: u32, - build_script: fn(&str) -> String, -) -> Result<(), FocusFailure> { - let Some(tty) = pid_to_tty(pid) else { - log::warn!( - "event=tray.session_focus status=miss reason=tty_not_found app={app_label} pid={pid}" - ); - return Err(FocusFailure::TtyNotFound); - }; - focus_tty(app_label, &tty, build_script) -} - /// 对已知 tty 执行对应终端的 AppleScript 选中 tab。 /// herdr 宿主跳拿到的 client tty 直接复用此函数,不重复 pid 反查。 pub(crate) fn focus_tty( @@ -186,39 +364,73 @@ pub(crate) fn focus_tty( } } -pub(crate) fn focus_ghostty_via_cwd(cwd: &str) -> Result<(), FocusFailure> { - focus_ghostty(cwd, None) -} - -/// 聚焦 herdr 宿主 Ghostty terminal:命名会话优先按 client title 匹配,cwd 只做唯一兜底。 -pub(crate) fn focus_ghostty_via_herdr_session( +/// 普通 Ghostty 会话:身份校验通过后,tty 精确匹配优先(上游 #11592 起支持),没有 +/// tty 或未命中时落到会话记录 cwd 的唯一匹配;cwd 匹配先排除 herdr client,避免误激活 +/// 同目录的 herdr 宿主 tab。身份校验失败时不会进入 cwd 兜底。 +pub(crate) fn focus_ghostty_regular_session( + tty: Option<&str>, cwd: &str, - session_name: Option<&str>, + run_script: &impl Fn(&str) -> Result, ) -> Result<(), FocusFailure> { - focus_ghostty(cwd, session_name) + // tty 与 cwd 都没有时无法定位;tty 拿不到通常是会话进程已退出, + // 与 Terminal/iTerm 的 TtyNotFound 口径一致。 + if tty.is_none() && cwd.is_empty() { + log::warn!("event=tray.session_focus status=miss reason=tty_not_found app=Ghostty"); + return Err(FocusFailure::TtyNotFound); + } + let spec = GhosttyFocusSpec::regular(tty, cwd); + if focus_ghostty_attempt_with(&spec, run_script)? { + return Ok(()); + } + log::warn!( + "event=tray.session_focus status=miss reason=tab_not_found app=Ghostty kind=regular tty={} cwd={}", + tty.unwrap_or("none"), + crate::utils::truncate(cwd, 160) + ); + Err(FocusFailure::TabNotFound) } -fn focus_ghostty(cwd: &str, session_name: Option<&str>) -> Result<(), FocusFailure> { - if cwd.is_empty() { - log::warn!("event=tray.session_focus status=miss reason=empty_cwd app=Ghostty"); - return Err(FocusFailure::EmptyCwd); +/// 聚焦 herdr 宿主 Ghostty terminal:命名会话 title 与 client tty 精确匹配优先, +/// 都未命中才解析 client cwd(lsof)做唯一兜底。tty 来自 herdr client 进程 +/// (宿主 tab 的 tty),发现 client 时必有值(tty 是过滤条件)。 +pub(crate) fn focus_ghostty_herdr_host( + tty: &str, + session_name: Option<&str>, + client_cwd: impl FnOnce() -> Option, + run_script: &impl Fn(&str) -> Result, +) -> Result<(), FocusFailure> { + // 第一段只做精确匹配(title/tty),不解析 cwd——lsof 是宿主跳里最贵的 spawn, + // Ghostty ≥1.4 时 tty 通常直接命中,不应白付。 + let mut spec = GhosttyFocusSpec::herdr_host(tty, "", session_name); + if focus_ghostty_attempt_with(&spec, run_script)? { + return Ok(()); } - let escaped_cwd = escape_applescript_string(cwd); - let escaped_session_name = session_name.map(escape_applescript_string); - let script = match escaped_session_name.as_deref() { - Some(session_name) => ghostty_script_with_session(&escaped_cwd, Some(session_name)), - None => ghostty_script(&escaped_cwd), - }; - match run_osascript_returning_bool(&script) { - Ok(true) => Ok(()), - Ok(false) => { - log::warn!( - "event=tray.session_focus status=miss reason=tab_not_found app=Ghostty session={} cwd={}", - session_name.unwrap_or("default"), - crate::utils::truncate(cwd, 160), - ); - Err(FocusFailure::TabNotFound) + // 第二段:精确匹配未命中,才取 client 进程的 cwd(用户敲 `herdr` 的目录, + // 不是 pane 的 cwd)做唯一兜底。 + let client_cwd = client_cwd().filter(|cwd| !cwd.is_empty()); + if let Some(cwd) = client_cwd.as_deref() { + spec.cwd = cwd; + if focus_ghostty_attempt_with(&spec, run_script)? { + return Ok(()); } + } + log::warn!( + "event=tray.session_focus status=miss reason=tab_not_found app=Ghostty kind=herdr_host session={} tty={} cwd={}", + session_name.unwrap_or("default"), + tty, + crate::utils::truncate(spec.cwd, 160) + ); + Err(FocusFailure::TabNotFound) +} + +/// 执行一次 Ghostty 聚焦脚本;Ok(true) 表示已命中聚焦,Ok(false) 表示未命中。 +/// runner 作为内部测试 seam 注入,生产路径仍只使用 osascript。 +fn focus_ghostty_attempt_with( + spec: &GhosttyFocusSpec, + run_script: &impl Fn(&str) -> Result, +) -> Result { + match run_script(&ghostty_script(spec)) { + Ok(hit) => Ok(hit), Err(e) => { log::warn!("event=tray.session_focus status=err app=Ghostty error={e}"); Err(FocusFailure::ScriptError) @@ -226,18 +438,42 @@ fn focus_ghostty(cwd: &str, session_name: Option<&str>) -> Result<(), FocusFailu } } -/// 调 `ps -p -o tty=` 拿到 tty,trim 后非 `??` 即拼成 `/dev/tty`。 -/// pub(crate):herdr 宿主跳需要反查 client 进程的 tty。 -pub(crate) fn pid_to_tty(pid: u32) -> Option { - let output = Command::new("ps") - .args(["-p", &pid.to_string(), "-o", "tty="]) - .output() - .ok()?; - if !output.status.success() { - return None; +/// Ghostty 聚焦脚本参数:title/tty 精确匹配与 cwd 兜底的组合由调用场景显式决定, +/// `exclude_herdr_clients` 不做隐式推导——普通会话要排除 herdr client; +/// herdr 宿主跳的目标 tab 本身就是 herdr client,必须关闭排除(含默认会话, +/// 否则 cwd 兜底会跳过标题为 "herdr" 的目标 tab)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct GhosttyFocusSpec<'a> { + /// 精确匹配的宿主 tab tty;None 表示没有可用 tty。 + tty: Option<&'a str>, + /// cwd 唯一兜底;空串表示不生成兜底分支(空 cwd 会误命中无工作目录的 tab)。 + cwd: &'a str, + /// herdr 命名会话名(client title 匹配用);仅 herdr 宿主跳有值。 + session_name: Option<&'a str>, + /// cwd 兜底是否排除 herdr client。 + exclude_herdr_clients: bool, +} + +impl<'a> GhosttyFocusSpec<'a> { + /// 普通会话:tty 精确匹配 + 排除 herdr client 的 cwd 兜底。 + fn regular(tty: Option<&'a str>, cwd: &'a str) -> Self { + Self { + tty, + cwd, + session_name: None, + exclude_herdr_clients: true, + } + } + + /// herdr 宿主跳:目标 tab 就是 herdr client,永不排除;tty 必有值。 + fn herdr_host(tty: &'a str, cwd: &'a str, session_name: Option<&'a str>) -> Self { + Self { + tty: Some(tty), + cwd, + session_name, + exclude_herdr_clients: false, + } } - let raw = String::from_utf8(output.stdout).ok()?; - parse_ps_tty_output(&raw) } /// 把 `ps -o tty=` 的输出解析成绝对 tty 路径。 @@ -261,7 +497,7 @@ fn parse_ps_tty_output(raw: &str) -> Option { } /// 执行 osascript 并按 stdout 文本判定 true/false(AppleScript 脚本里 `return true/false`)。 -fn run_osascript_returning_bool(script: &str) -> Result { +pub(crate) fn run_osascript_returning_bool(script: &str) -> Result { let output = Command::new("osascript") .arg("-e") .arg(script) @@ -332,50 +568,89 @@ end tell"# ) } -fn ghostty_script(escaped_cwd: &str) -> String { - ghostty_script_with_options(escaped_cwd, None, true) -} - -fn ghostty_script_with_session(escaped_cwd: &str, escaped_session_name: Option<&str>) -> String { - ghostty_script_with_options(escaped_cwd, escaped_session_name, false) -} - -fn ghostty_script_with_options( - escaped_cwd: &str, - escaped_session_name: Option<&str>, - exclude_herdr_clients: bool, -) -> String { - let session_match = escaped_session_name - .map(|session_name| { - format!( - r#" -set targetSessionName to "{session_name}" -repeat with term in terminals -set termName to name of term +/// 生成 Ghostty 聚焦脚本。结构:title/tty 共享一次 terminals 遍历的精确匹配段, +/// 之后是 cwd 计数 + 唯一命中才聚焦的兜底段;空 cwd 时兜底段整体不生成。 +fn ghostty_script(spec: &GhosttyFocusSpec) -> String { + let escaped_cwd = escape_applescript_string(spec.cwd); + let escaped_session_name = spec.session_name.map(escape_applescript_string); + let escaped_tty = spec.tty.map(escape_applescript_string); + // 变量声明:只声明本形态会用到的目标值,避免引用未定义变量。 + let mut prelude = String::new(); + if !spec.cwd.is_empty() { + prelude.push_str(&format!(r#"set targetCwd to "{escaped_cwd}""#)); + } + if let Some(session_name) = escaped_session_name.as_deref() { + if !prelude.is_empty() { + prelude.push('\n'); + } + prelude.push_str(&format!(r#"set targetSessionName to "{session_name}""#)); + } + if let Some(tty) = escaped_tty.as_deref() { + if !prelude.is_empty() { + prelude.push('\n'); + } + prelude.push_str(&format!( + r#"set targetTty to "{tty}" +set ttyProbeFailed to false +set termTty to """# + )); + } + // 精确匹配段:title 与 tty 共享一次 terminals 遍历,减少重复的 Apple 事件往返。 + // tty 探测只 try 包住 `tty of term` 属性读取(1.3.x 的 sdef 没有该属性,会抛错): + // 失败置 ttyProbeFailed 跳过后续比较,focus/return 留在 try 外,聚焦错误不会被吞掉。 + let mut exact_body = String::new(); + if escaped_session_name.is_some() { + exact_body.push_str( + r#"set termName to name of term if termName is ("herdr --session " & targetSessionName) or termName is ("herdr --session=" & targetSessionName) or termName is ("herdr session attach " & targetSessionName) then focus term return true end if -end repeat"# - ) - }) - .unwrap_or_default(); - let cwd_match_condition = if exclude_herdr_clients { - r#"if (working directory of term is targetCwd) and (not isHerdrClient) then"# +"#, + ); + } + if escaped_tty.is_some() { + exact_body.push_str( + r#"if not ttyProbeFailed then +try +set termTty to tty of term +on error +set ttyProbeFailed to true +end try +if (not ttyProbeFailed) and (termTty is targetTty) then +focus term +return true +end if +end if +"#, + ); + } + let exact_match = if exact_body.is_empty() { + String::new() } else { - r#"if working directory of term is targetCwd then"# + format!("\nrepeat with term in terminals\n{exact_body}end repeat") }; - let herdr_client_check = if exclude_herdr_clients { - r#"set isHerdrClient to (termName is "herdr") or (termName starts with "herdr --session ") or (termName starts with "herdr --session=") or (termName starts with "herdr session attach ")"# + // cwd 兜底段:空 cwd 不生成——AppleScript 的 `"" is ""` 为 true, + // 会唯一命中没有工作目录(无 shell 集成)的 tab。 + let cwd_fallback = if spec.cwd.is_empty() { + String::new() } else { - "" - }; - format!( - r#"tell application "Ghostty" -set targetCwd to "{escaped_cwd}"{session_match} + // herdr client 的 termName 读取与判定只在排除分支需要,避免多余 Apple 事件。 + let herdr_client_check = if spec.exclude_herdr_clients { + r#"set termName to name of term +set isHerdrClient to (termName is "herdr") or (termName starts with "herdr --session ") or (termName starts with "herdr --session=") or (termName starts with "herdr session attach ")"# + } else { + "" + }; + let cwd_match_condition = if spec.exclude_herdr_clients { + r#"if (working directory of term is targetCwd) and (not isHerdrClient) then"# + } else { + r#"if working directory of term is targetCwd then"# + }; + format!( + r#" set cwdMatchCount to 0 repeat with term in terminals -set termName to name of term {herdr_client_check} {cwd_match_condition} set cwdMatchCount to cwdMatchCount + 1 @@ -383,14 +658,18 @@ end if end repeat if cwdMatchCount is 1 then repeat with term in terminals -set termName to name of term {herdr_client_check} {cwd_match_condition} focus term return true end if end repeat -end if +end if"# + ) + }; + format!( + r#"tell application "Ghostty" +{prelude}{exact_match}{cwd_fallback} return false end tell"# ) @@ -485,18 +764,181 @@ mod tests { } #[test] - fn focus_session_in_terminal_rejects_unknown_slug() { - let err = focus_session_in_terminal(GHOST_PID, "/tmp", "warp").expect_err("warp 应被拒绝"); - assert_eq!(err, FocusFailure::Unsupported("warp".to_string())); - let err = focus_session_in_terminal(GHOST_PID, "/tmp", "").expect_err("空 slug 应被拒绝"); - assert_eq!(err, FocusFailure::Unsupported(String::new())); + fn parse_ps_etime_secs_normalizes_formats() { + // D-HH:MM:SS + assert_eq!( + parse_ps_etime_secs("02-21:08:33"), + Some(2 * 86_400 + 21 * 3_600 + 8 * 60 + 33) + ); + // HH:MM:SS + assert_eq!(parse_ps_etime_secs("1:02:03"), Some(3_723)); + // MM:SS + assert_eq!(parse_ps_etime_secs("05:23"), Some(323)); + assert_eq!(parse_ps_etime_secs("0:01"), Some(1)); + // macOS 部分版本会在秒后带百分之一秒 + assert_eq!(parse_ps_etime_secs("00:01.50"), Some(1)); + // 非法输入 + assert_eq!(parse_ps_etime_secs(""), None); + assert_eq!(parse_ps_etime_secs("abc"), None); + assert_eq!(parse_ps_etime_secs("1-02"), None); + assert_eq!(parse_ps_etime_secs("00:01.xx"), None); + } + + #[test] + fn parse_proc_start_epoch_parses_utc_lstart_format() { + // 与会话文件实际记录一致(UTC;星期部分不参与解析)。 + // 期望值由 python datetime(2026,8,12,15,27,23,UTC).timestamp() 核准。 + assert_eq!( + parse_proc_start_epoch("Wed Aug 12 15:27:23 2026"), + Some(1_786_548_443) + ); + // 单数字日期的双空格由空白切分归一 + assert_eq!( + parse_proc_start_epoch("Mon Aug 4 09:00:00 2026"), + Some(1_785_834_000) + ); + assert_eq!(parse_proc_start_epoch("Thu Jan 1 00:00:00 1970"), Some(0)); + // 非法输入 + assert_eq!(parse_proc_start_epoch(""), None); + assert_eq!(parse_proc_start_epoch("Wed Aug 12 15:27 2026"), None); + assert_eq!(parse_proc_start_epoch("Wed Xyz 12 15:27:23 2026"), None); } #[test] - fn ghostty_rejects_empty_cwd_with_focus_failure() { - let err = focus_session_in_terminal(GHOST_PID, "", "ghostty") - .expect_err("空 cwd 应返回 EmptyCwd"); - assert_eq!(err, FocusFailure::EmptyCwd); + fn ps_process_info_from_output_splits_tty_etime_and_env() { + let info = ps_process_info_from_output( + " ttys018 02-21:08:33 zsh TERM_PROGRAM=ghostty TERM=xterm-ghostty", + ) + .expect("应能解析三段"); + assert_eq!(info.tty.as_deref(), Some("/dev/ttys018")); + assert_eq!(info.etime_secs, Some(2 * 86_400 + 21 * 3_600 + 8 * 60 + 33)); + assert_eq!( + terminal_app_from_ps_output(&info.env_output), + Some("ghostty") + ); + + // 后台进程没有控制终端,环境仍要保留 + let info = ps_process_info_from_output("?? 0:05 herdr HERDR_SESSION=work") + .expect("无 tty 也应返回环境"); + assert_eq!(info.tty, None); + assert!(info.env_output.contains("HERDR_SESSION=work")); + + // 空输出 / 缺列 → None + assert!(ps_process_info_from_output("").is_none()); + assert!(ps_process_info_from_output("ttys018").is_none()); + } + + /// 推算启动时间(now - etime)与 procStart 记录只容忍小偏差,大偏差判定 pid 已回收; + /// 缺失、非法或无法解析 etime 的输入全部拒绝。 + #[test] + fn pid_start_matches_recorded_tolerates_small_drift_only() { + let recorded = "Wed Aug 12 15:27:23 2026"; + let recorded_epoch = parse_proc_start_epoch(recorded).unwrap(); + let now = crate::utils::current_timestamp() as i64; + + // etime = now - recorded - 10 → 推算启动时间比记录晚 10 秒:同一进程 + assert!(pid_start_matches_recorded( + Some(now - recorded_epoch - 10), + Some(recorded) + )); + // etime = now - recorded - 3600 → 推算晚 1 小时:pid 已被回收 + assert!(!pid_start_matches_recorded( + Some(now - recorded_epoch - 3_600), + Some(recorded) + )); + // 无法校验的输入全部拒绝,不能退回信任 pid。 + assert!(!pid_start_matches_recorded(None, Some(recorded))); + assert!(!pid_start_matches_recorded(Some(100), None)); + assert!(!pid_start_matches_recorded(Some(100), Some("garbage"))); + } + + #[test] + fn focus_session_in_terminal_rejects_missing_or_invalid_process_identity() { + let err = focus_session_in_terminal(GHOST_PID, "/tmp", "warp", None) + .expect_err("缺失 procStart 应拒绝聚焦"); + assert_eq!(err, FocusFailure::ProcessIdentityMismatch); + let err = focus_session_in_terminal(GHOST_PID, "/tmp", "warp", Some("garbage")) + .expect_err("非法 procStart 应拒绝聚焦"); + assert_eq!(err, FocusFailure::ProcessIdentityMismatch); + } + + /// 进程已退出(无 tty)且会话无 cwd:按进程退出报错,与 Terminal/iTerm 口径一致。 + #[test] + fn ghostty_regular_without_tty_and_cwd_reports_process_exited() { + let err = + focus_session_in_terminal(GHOST_PID, "", "ghostty", Some("Wed Aug 12 15:27:23 2026")) + .expect_err("无 tty 无 cwd 应报 TtyNotFound"); + assert_eq!(err, FocusFailure::TtyNotFound); + } + + /// 通过生产入口注入 runner 验证普通会话、命名 herdr 宿主和默认 herdr 宿主的组合, + /// 防止测试只验证 spec 构造器却漏掉入口中的参数传递或降级顺序。 + #[test] + fn ghostty_focus_entrypoints_pin_production_composition() { + use std::cell::RefCell; + + let scripts = RefCell::new(Vec::::new()); + let run_script = |script: &str| { + scripts.borrow_mut().push(script.to_string()); + Ok(false) + }; + + let err = focus_ghostty_regular_session(Some("/dev/ttys016"), "/Users/demo", &run_script) + .expect_err("runner 返回 false 时普通会话应报告未命中"); + assert_eq!(err, FocusFailure::TabNotFound); + let regular = scripts.borrow().first().cloned().expect("应生成普通脚本"); + assert!(regular.contains(r#"set targetTty to "/dev/ttys016""#)); + assert!( + regular.contains("isHerdrClient"), + "普通会话必须排除 herdr client" + ); + + scripts.borrow_mut().clear(); + let err = focus_ghostty_herdr_host("/dev/ttys016", Some("work"), || None, &run_script) + .expect_err("runner 返回 false 时命名宿主应报告未命中"); + assert_eq!(err, FocusFailure::TabNotFound); + let named_host = scripts + .borrow() + .first() + .cloned() + .expect("应生成命名宿主脚本"); + assert!(named_host.contains("targetSessionName")); + assert!(!named_host.contains("isHerdrClient")); + + scripts.borrow_mut().clear(); + let err = focus_ghostty_herdr_host( + "/dev/ttys016", + None, + || Some("/Users/demo".to_string()), + &run_script, + ) + .expect_err("runner 返回 false 时默认宿主应报告未命中"); + assert_eq!(err, FocusFailure::TabNotFound); + let captured = scripts.borrow(); + assert_eq!(captured.len(), 2, "默认宿主应先精确匹配再 cwd 兜底"); + assert!(!captured[0].contains("targetSessionName")); + assert!(!captured[0].contains("isHerdrClient")); + assert!(captured[1].contains("working directory of term is targetCwd")); + assert!(!captured[1].contains("isHerdrClient")); + } + + /// herdr 默认会话宿主跳(Ghostty <1.4 时 tty 分支探测失败降级到 cwd 兜底): + /// 兜底不得排除 herdr client,否则标题为 "herdr" 的目标 tab 永远匹配不上。 + #[test] + fn ghostty_herdr_default_host_cwd_fallback_keeps_herdr_clients() { + let spec = GhosttyFocusSpec::herdr_host("/dev/ttys016", "/Users/demo", None); + let script = ghostty_script(&spec); + + assert!( + !script.contains("isHerdrClient"), + "herdr 默认会话宿主跳不得排除 herdr client" + ); + assert!(script.contains("if working directory of term is targetCwd then")); + assert!(script.contains(r#"set targetTty to "/dev/ttys016""#)); + assert!( + !script.contains("targetSessionName"), + "默认会话没有 title 匹配分支" + ); } #[test] @@ -505,14 +947,20 @@ mod tests { let script = terminal_app_script(&escaped); assert!(script.contains(r#"set targetTty to "/path/with\"quote""#)); - let escaped_cwd = escape_applescript_string(r"/cwd\with\bs"); - let script = ghostty_script(&escaped_cwd); + let script = ghostty_script(&GhosttyFocusSpec::regular(None, r"/cwd\with\bs")); assert!(script.contains(r#"set targetCwd to "/cwd\\with\\bs""#)); + + // 模板只负责嵌入已转义的字面量,转义在 ghostty_script 里做(与 cwd 同约定)。 + let script = ghostty_script(&GhosttyFocusSpec::regular( + Some(r#"/dev/tty"evil"#), + "/Users/demo", + )); + assert!(script.contains(r#"set targetTty to "/dev/tty\"evil""#)); } #[test] fn ghostty_script_focuses_matching_terminal_directly() { - let script = ghostty_script("/Users/demo/project"); + let script = ghostty_script(&GhosttyFocusSpec::regular(None, "/Users/demo/project")); assert!(script.contains("repeat with term in terminals")); assert!(script.contains("focus term")); @@ -521,7 +969,7 @@ mod tests { #[test] fn ghostty_script_does_not_choose_first_terminal_when_cwd_is_ambiguous() { - let script = ghostty_script("/Users/demo/project"); + let script = ghostty_script(&GhosttyFocusSpec::regular(None, "/Users/demo/project")); assert!(script.contains("cwdMatchCount")); assert!(script.contains("if cwdMatchCount is 1 then")); @@ -529,7 +977,7 @@ mod tests { #[test] fn ghostty_script_for_regular_session_excludes_herdr_clients() { - let script = ghostty_script("/Users/demo/project"); + let script = ghostty_script(&GhosttyFocusSpec::regular(None, "/Users/demo/project")); assert!(script.contains("set isHerdrClient to")); assert!(script.contains("termName starts with \"herdr --session \"")); @@ -537,21 +985,88 @@ mod tests { } #[test] - fn ghostty_script_prefers_herdr_session_title_before_cwd_fallback() { - let script = ghostty_script_with_session("/Users/demo/project", Some("cloudhub")); + fn ghostty_script_matches_tty_before_cwd_fallback() { + let script = ghostty_script(&GhosttyFocusSpec::regular( + Some("/dev/ttys016"), + "/Users/demo/project", + )); - let session_match = script - .find("set targetSessionName") - .expect("应先生成 herdr session title 匹配"); + let tty_match = script + .find("set targetTty") + .expect("应生成 tty 精确匹配分支"); let cwd_fallback = script .find("set cwdMatchCount") - .expect("应生成 cwd 唯一匹配兜底"); - assert!(session_match < cwd_fallback); + .expect("应保留 cwd 唯一匹配兜底"); + assert!(tty_match < cwd_fallback, "tty 匹配必须排在 cwd 兜底之前"); + assert!(script.contains("if (not ttyProbeFailed) and (termTty is targetTty) then")); + } + + /// 旧版 Ghostty(含本机 1.3.1)的 sdef 没有 tty 属性,`tty of term` 会抛错。 + /// try 只包住属性读取:探测失败置标志跳过后续比较(旧版只付一次失败探测), + /// focus/return 留在 try 外,聚焦错误不会被吞掉重路由到 cwd 兜底。 + #[test] + fn ghostty_tty_probe_try_scopes_only_property_read() { + let script = ghostty_script(&GhosttyFocusSpec::regular( + Some("/dev/ttys016"), + "/Users/demo/project", + )); + + let try_open = script.find("try").expect("tty 探测应被 try 包裹"); + let probe = script + .find("set termTty to tty of term") + .expect("应存在 tty 属性读取"); + let try_close = script.find("end try").expect("try 应闭合"); + assert!(try_open < probe && probe < try_close); + // try 块内不得包含聚焦动作 + let block = &script[try_open..try_close]; + assert!(!block.contains("focus term")); + assert!(!block.contains("return true")); + assert!(script.contains("set ttyProbeFailed to true")); + } + + /// pid 反查 tty 失败(会话进程已退出)时不应生成 tty 分支,直接走 cwd。 + #[test] + fn ghostty_script_without_tty_falls_back_to_cwd_only() { + let script = ghostty_script(&GhosttyFocusSpec::regular(None, "/Users/demo/project")); + + assert!(!script.contains("set targetTty")); + assert!(!script.contains("tty of term")); + assert!(script.contains("set cwdMatchCount")); + } + + /// herdr 命名会话宿主跳的精确匹配优先级必须是 title > tty > cwd。 + #[test] + fn ghostty_herdr_host_script_orders_title_then_tty_then_cwd() { + let script = ghostty_script(&GhosttyFocusSpec::herdr_host( + "/dev/ttys016", + "/Users/demo/project", + Some("cloudhub"), + )); + + let title = script + .find("set targetSessionName") + .expect("herdr title 匹配应存在"); + let tty = script.find("set targetTty").expect("tty 匹配应存在"); + let cwd = script.find("set cwdMatchCount").expect("cwd 兜底应存在"); + assert!(title < tty && tty < cwd); assert!(script.contains("herdr --session ")); assert!(script.contains("herdr --session=")); assert!(script.contains("herdr session attach ")); } + /// 空 cwd 不生成 cwd 兜底分支:AppleScript 的 `"" is ""` 为 true, + /// 会唯一命中没有工作目录(无 shell 集成)的 tab。 + #[test] + fn ghostty_script_suppresses_cwd_fallback_when_cwd_empty() { + let script = ghostty_script(&GhosttyFocusSpec::herdr_host("/dev/ttys016", "", None)); + + assert!(!script.contains("cwdMatchCount")); + assert!(!script.contains("set targetCwd")); + assert!(!script.contains("working directory of term")); + // tty 精确匹配仍在 + assert!(script.contains(r#"set targetTty to "/dev/ttys016""#)); + } + #[test] fn focus_failure_user_message_localizes_by_language() { // 中文(默认) @@ -562,9 +1077,6 @@ mod tests { let (_, body_zh_tty) = FocusFailure::TtyNotFound.user_message("zh"); assert!(body_zh_tty.contains("会话进程已退出")); - let (_, body_zh_empty) = FocusFailure::EmptyCwd.user_message("zh"); - assert!(body_zh_empty.contains("缺少工作目录")); - let (_, body_zh_unsupported) = FocusFailure::Unsupported("warp".to_string()).user_message("zh"); assert!(body_zh_unsupported.contains("warp")); @@ -591,16 +1103,13 @@ mod tests { assert_eq!(title_fallback, "会话聚焦失败"); } - /// 补足英文 body 覆盖:TtyNotFound / EmptyCwd / Unsupported / ScriptError + /// 补足英文 body 覆盖:TtyNotFound / Unsupported / ScriptError /// 之前只测了 TabNotFound 的英文,其它分支没有 assert。 #[test] fn focus_failure_user_message_english_branches_cover_all_variants() { let (_, body) = FocusFailure::TtyNotFound.user_message("en"); assert!(body.to_lowercase().contains("session process has exited")); - let (_, body) = FocusFailure::EmptyCwd.user_message("en"); - assert!(body.to_lowercase().contains("working directory")); - let (_, body) = FocusFailure::Unsupported("warp".to_string()).user_message("en"); assert!(body.contains("'warp'")); assert!(body.to_lowercase().contains("does not support")); diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 5036e12..d208f3a 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -109,6 +109,9 @@ struct RawTraySession { updated_at: u64, #[serde(default)] waiting_for: Option, + /// 会话进程启动时间(`ps -o lstart=` 格式,UTC);聚焦时校验 pid 未被回收。 + #[serde(default)] + proc_start: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -119,6 +122,8 @@ struct TraySession { status: String, updated_at: u64, waiting_for: Option, + /// 会话进程启动时间;缺失或非法时不允许异步聚焦。 + proc_start: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -127,6 +132,8 @@ pub(crate) struct PendingSessionFocusTarget { pub(crate) cwd: String, pub(crate) session_id: String, pub(crate) terminal_app: String, + /// 创建通知时捕获的进程启动时间,点击时不得按 pid 重读会话文件。 + pub(crate) proc_start: String, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -218,6 +225,10 @@ impl From for TraySession { .waiting_for .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()), + proc_start: raw + .proc_start + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), } } } @@ -510,7 +521,11 @@ fn pending_session_notification_interaction( session: &TraySession, default_terminal_app: &str, ) -> PendingSessionNotificationInteraction { - if session_focus_available(session.pid, default_terminal_app) { + if session_focus_available( + session.pid, + default_terminal_app, + session.proc_start.as_deref(), + ) { PendingSessionNotificationInteraction::FocusTerminal } else { PendingSessionNotificationInteraction::Plain @@ -520,16 +535,22 @@ fn pending_session_notification_interaction( /// 单个会话是否可聚焦:macOS 且(默认终端支持聚焦,或 pid 自身宿主终端支持, /// 或会话跑在 herdr pane 里)。herdr 检测与 pid 宿主检测各会发起一次 ps, /// 只在菜单构建 / 通知决策时调用;点击时 `focus_session_in_terminal` 会重新检测。 -fn session_focus_available(pid: u32, default_terminal_app: &str) -> bool { - session_focus_available_for_platform(pid, default_terminal_app, cfg!(target_os = "macos")) +fn session_focus_available(pid: u32, default_terminal_app: &str, proc_start: Option<&str>) -> bool { + session_focus_available_for_platform( + pid, + default_terminal_app, + proc_start, + cfg!(target_os = "macos"), + ) } fn session_focus_available_for_platform( pid: u32, default_terminal_app: &str, + proc_start: Option<&str>, is_macos: bool, ) -> bool { - if !is_macos { + if !is_macos || !proc_start.is_some_and(crate::terminal_focus::proc_start_is_valid) { return false; } crate::terminal_focus::terminal_supports_focus(default_terminal_app) @@ -564,14 +585,16 @@ fn build_pending_session_notification( ), (false, None) => format!("{} 需要处理", crate::utils::truncate(&project_name, 48)), }; - let focus_target = - (interaction == PendingSessionNotificationInteraction::FocusTerminal).then(|| { - PendingSessionFocusTarget { - pid: session.pid, - cwd: session.cwd.clone(), - session_id: session.session_id.clone(), - terminal_app: default_terminal_app.to_string(), - } + let focus_target = (interaction == PendingSessionNotificationInteraction::FocusTerminal) + .then(|| session.proc_start.clone()) + .flatten() + .filter(|proc_start| crate::terminal_focus::proc_start_is_valid(proc_start)) + .map(|proc_start| PendingSessionFocusTarget { + pid: session.pid, + cwd: session.cwd.clone(), + session_id: session.session_id.clone(), + terminal_app: default_terminal_app.to_string(), + proc_start, }); PendingSessionNotification { @@ -637,25 +660,42 @@ fn session_menu_item_label(session: &TraySession, language: &str) -> String { ) } -/// 编码会话菜单项 id,格式 `session_::`。 +/// 编码会话菜单项 id,格式 `session_::::`。 /// 用 hex 是为了让 cwd 中的中文 / 空格 / 引号 / `::` 都不会破坏 menu id 解析, -/// 同时省去引入 base64 依赖。 +/// 同时省去引入 base64 依赖。procStart 也放入 id,确保点击时使用创建菜单时的 +/// 进程身份快照,不按 pid 回读可能已经被替换的会话文件。 fn session_menu_item_id(session: &TraySession) -> String { format!( - "session_{}::{}", + "session_{}::{}::{}", session.pid, - hex_encode(session.cwd.as_bytes()) + hex_encode(session.cwd.as_bytes()), + session + .proc_start + .as_deref() + .map_or_else(String::new, |proc_start| hex_encode(proc_start.as_bytes())) ) } /// 反向解析 `session_menu_item_id`。任一段不合法都返回 None,让 handler 静默忽略。 -fn parse_session_menu_item_id(id: &str) -> Option<(u32, String)> { +/// 旧版只有两段时返回 `None` procStart;handler 会拒绝执行聚焦。 +fn parse_session_menu_item_id(id: &str) -> Option<(u32, String, Option)> { let payload = id.strip_prefix("session_")?; - let (pid_str, hex_cwd) = payload.split_once("::")?; + let mut parts = payload.split("::"); + let pid_str = parts.next()?; + let hex_cwd = parts.next()?; + let hex_proc_start = parts.next().unwrap_or_default(); + if parts.next().is_some() { + return None; + } let pid = pid_str.parse::().ok()?; let bytes = hex_decode(hex_cwd)?; let cwd = String::from_utf8(bytes).ok()?; - Some((pid, cwd)) + let proc_start = if hex_proc_start.is_empty() { + None + } else { + String::from_utf8(hex_decode(hex_proc_start)?).ok() + }; + Some((pid, cwd, proc_start)) } fn hex_encode(bytes: &[u8]) -> String { @@ -846,7 +886,13 @@ fn build_sessions_tray_menu( // 各扫描一遍。 let focusable: Vec = sessions .iter() - .map(|session| session_focus_available(session.pid, &state.app.default_terminal_app)) + .map(|session| { + session_focus_available( + session.pid, + &state.app.default_terminal_app, + session.proc_start.as_deref(), + ) + }) .collect(); let any_focusable = focusable.iter().any(|&b| b); for (session, &enabled) in sessions.iter().zip(&focusable) { @@ -1081,7 +1127,9 @@ pub fn focus_most_urgent_session(app: &AppHandle) { // 全部不可聚焦(非 macOS / 默认终端不支持且无 herdr)时静默跳过。 let focusable: Vec = sessions .iter() - .filter(|session| session_focus_available(session.pid, &slug)) + .filter(|session| { + session_focus_available(session.pid, &slug, session.proc_start.as_deref()) + }) .cloned() .collect(); let Some(target) = pick_focus_target_session(&focusable) else { @@ -1090,12 +1138,23 @@ pub fn focus_most_urgent_session(app: &AppHandle) { }; let pid = target.pid; let cwd = target.cwd.clone(); + let Some(proc_start) = target.proc_start.clone() else { + log::warn!( + "event=tray.focus_shortcut status=skip reason=process_identity_unavailable pid={pid}" + ); + return; + }; let notifications_enabled = session_focus_failure_notification_enabled(&prefs); let language = prefs.ui_language; // osascript 可能耗数百毫秒,丢线程避免阻塞快捷键回调线程。 let app_handle = app.clone(); std::thread::spawn(move || { - if let Err(failure) = crate::terminal_focus::focus_session_in_terminal(pid, &cwd, &slug) { + if let Err(failure) = crate::terminal_focus::focus_session_in_terminal( + pid, + &cwd, + &slug, + Some(proc_start.as_str()), + ) { notify_session_focus_failure(&app_handle, &language, notifications_enabled, &failure); } }); @@ -1299,7 +1358,13 @@ pub fn setup_tray(app: &tauri::App) -> tauri::Result<()> { .show_menu_on_left_click(true) .on_menu_event(|app, event| { let id = event.id().as_ref(); - let Some((pid, cwd)) = parse_session_menu_item_id(id) else { + let Some((pid, cwd, proc_start)) = parse_session_menu_item_id(id) else { + return; + }; + let Some(proc_start) = proc_start else { + log::warn!( + "event=tray.session_focus status=skip reason=process_identity_unavailable pid={pid}" + ); return; }; let prefs = load_registry_or_default().app; @@ -1307,15 +1372,18 @@ pub fn setup_tray(app: &tauri::App) -> tauri::Result<()> { let slug = prefs.default_terminal_app; let language = prefs.ui_language; // 菜单项 enabled 已按会话判定过,这里再按同一口径兜底一次。 - if !session_focus_available(pid, &slug) { + if !session_focus_available(pid, &slug, Some(&proc_start)) { return; } // osascript 可能耗数百毫秒,丢线程避免阻塞 UI 事件循环。 let app_handle = app.clone(); std::thread::spawn(move || { - if let Err(failure) = - crate::terminal_focus::focus_session_in_terminal(pid, &cwd, &slug) - { + if let Err(failure) = crate::terminal_focus::focus_session_in_terminal( + pid, + &cwd, + &slug, + Some(&proc_start), + ) { notify_session_focus_failure( &app_handle, &language, @@ -1467,6 +1535,7 @@ mod tests { /// 进程树检测(herdr / TERM_PROGRAM),固定小 pid(如 123)在装有 herdr 的 /// 机器上可能命中真实进程,导致单测依赖宿主环境而偶发失败。 const GHOST_PID: u32 = u32::MAX; + const VALID_PROC_START: &str = "Wed Aug 12 15:27:23 2026"; fn test_session(cwd: &str, status: &str, updated_at: u64) -> TraySession { TraySession { @@ -1476,6 +1545,7 @@ mod tests { status: status.to_string(), updated_at, waiting_for: None, + proc_start: Some(VALID_PROC_START.to_string()), } } @@ -2172,6 +2242,7 @@ mod tests { status: "waiting".to_string(), updated_at: 2000, waiting_for: None, + proc_start: Some(VALID_PROC_START.to_string()), }; let notification = build_pending_session_notification( @@ -2188,6 +2259,7 @@ mod tests { cwd: "/Users/demo/work/code-manager".to_string(), session_id: "session-focus".to_string(), terminal_app: "terminal".to_string(), + proc_start: VALID_PROC_START.to_string(), }) ); } @@ -2230,24 +2302,45 @@ mod tests { fn session_focus_availability_requires_macos_or_herdr_or_focusable_terminal() { // 默认终端支持聚焦:与 pid 无关,短路径直接放行 assert!(session_focus_available_for_platform( - GHOST_PID, "terminal", true + GHOST_PID, + "terminal", + Some(VALID_PROC_START), + true )); assert!(session_focus_available_for_platform( - GHOST_PID, "ghostty", true + GHOST_PID, + "ghostty", + Some(VALID_PROC_START), + true )); // 默认终端不支持:pid 检测与 herdr 检测都失败(测试进程不存在) assert!(!session_focus_available_for_platform( - GHOST_PID, "warp", true + GHOST_PID, + "warp", + Some(VALID_PROC_START), + true )); // 非 macOS 恒不可聚焦 assert!(!session_focus_available_for_platform( - GHOST_PID, "terminal", false + GHOST_PID, + "terminal", + Some(VALID_PROC_START), + false )); assert!(!session_focus_available_for_platform( - GHOST_PID, "ghostty", false + GHOST_PID, + "ghostty", + Some(VALID_PROC_START), + false )); assert!(!session_focus_available_for_platform( - GHOST_PID, "warp", false + GHOST_PID, + "warp", + Some(VALID_PROC_START), + false + )); + assert!(!session_focus_available_for_platform( + GHOST_PID, "terminal", None, true )); } @@ -2268,7 +2361,7 @@ mod tests { ); } - /// 回归测试:菜单项 id 必须能 round-trip 出原始 pid 与 cwd, + /// 回归测试:菜单项 id 必须能 round-trip 出原始 pid、cwd 与 procStart, /// 否则点击 handler 无法恢复 cwd 去聚焦终端。覆盖中文、空格、引号、`::` 等易错字符。 #[test] fn session_menu_item_id_round_trip() { @@ -2288,13 +2381,22 @@ mod tests { status: "idle".into(), updated_at: 0, waiting_for: None, + proc_start: Some(VALID_PROC_START.to_string()), }; let id = session_menu_item_id(&session); assert!(id.starts_with("session_4242::"), "id 缺前缀: {id}"); - let (pid, decoded_cwd) = parse_session_menu_item_id(&id).expect("应能反解"); + let (pid, decoded_cwd, decoded_proc_start) = + parse_session_menu_item_id(&id).expect("应能反解"); assert_eq!(pid, 4242); assert_eq!(decoded_cwd, cwd); + assert_eq!(decoded_proc_start.as_deref(), Some(VALID_PROC_START)); } + + // 缺少身份快照的旧菜单项仍可解析,但点击 handler 会拒绝聚焦。 + assert_eq!( + parse_session_menu_item_id("session_4242::2f::"), + Some((4242, "/".to_string(), None)) + ); } /// 回归测试:解析非会话 id(profile_*、nav_*、未知字符串)必须返回 None, @@ -2418,7 +2520,7 @@ mod tests { } /// `From` 必须 trim 所有字符串字段,且把空白 / 空字符串的 `waiting_for` - /// 规约为 None;否则下游菜单项会显示 ` · ` 这样的空段。 + /// 与 `proc_start` 规约为 None;否则下游菜单项会显示 ` · ` 这样的空段。 #[test] fn from_raw_tray_session_trims_whitespace_and_filters_empty_waiting_for() { // 全 trim @@ -2429,14 +2531,19 @@ mod tests { status: " waiting ".to_string(), updated_at: 100, waiting_for: Some(" approve Bash ".to_string()), + proc_start: Some(" Wed Aug 12 15:27:23 2026 ".to_string()), }; let session = TraySession::from(raw); assert_eq!(session.session_id, "s1"); assert_eq!(session.cwd, "/tmp/demo"); assert_eq!(session.status, "waiting"); assert_eq!(session.waiting_for.as_deref(), Some("approve Bash")); + assert_eq!( + session.proc_start.as_deref(), + Some("Wed Aug 12 15:27:23 2026") + ); - // 纯空白 waiting_for 视为缺省 + // 纯空白 waiting_for / proc_start 视为缺省 let raw = RawTraySession { pid: 42, session_id: "s1".to_string(), @@ -2444,10 +2551,13 @@ mod tests { status: "idle".to_string(), updated_at: 0, waiting_for: Some(" ".to_string()), + proc_start: Some(" ".to_string()), }; - assert_eq!(TraySession::from(raw).waiting_for, None); + let session = TraySession::from(raw); + assert_eq!(session.waiting_for, None); + assert_eq!(session.proc_start, None); - // None waiting_for 直接保留为 None + // None waiting_for / proc_start 直接保留为 None let raw = RawTraySession { pid: 42, session_id: "s1".to_string(), @@ -2455,8 +2565,11 @@ mod tests { status: "idle".to_string(), updated_at: 0, waiting_for: None, + proc_start: None, }; - assert_eq!(TraySession::from(raw).waiting_for, None); + let session = TraySession::from(raw); + assert_eq!(session.waiting_for, None); + assert_eq!(session.proc_start, None); } /// status 比对必须大小写不敏感、忽略前后空白; From d6a70af98af2d79e26a9d2931e33cc51d1f05fd1 Mon Sep 17 00:00:00 2001 From: maguowei Date: Sat, 15 Aug 2026 22:57:58 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(tray):=20=E4=BF=AE=E5=A4=8D=20Ghostty?= =?UTF-8?q?=20=E8=B7=A8=E6=A1=8C=E9=9D=A2=E8=81=9A=E7=84=A6=E4=B8=8E?= =?UTF-8?q?=E5=90=8C=E7=9B=AE=E5=BD=95=E5=A4=9A=20tab=20=E5=AE=9A=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 命中分支 focus 后补 activate:切换 Space 并取得键盘焦点, 终端在其它桌面或非前台时才能看到跳转 - herdr 默认会话按宿主 tab 稳定标题 "herdr" 直接命中 - title/tty/cwd 均未命中且 tty 在手时用 tty 标题标记法精确定位: 向会话 tty 写 OSC 0 标题标记(终端只解析序列不显示字符), AppleScript 按标记标题轮询命中后清除;Ghostty <1.4 无 tty 属性、 同目录多 tab 场景的唯一精确定位手段 - spec 角色改为显式 kind 枚举,rules 同步上述约束 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/rules/projects-tray-diagnostics.md | 2 +- src-tauri/src/terminal_focus.rs | 270 ++++++++++++++++++--- 2 files changed, 244 insertions(+), 28 deletions(-) diff --git a/.claude/rules/projects-tray-diagnostics.md b/.claude/rules/projects-tray-diagnostics.md index b10cd63..3cbcd56 100644 --- a/.claude/rules/projects-tray-diagnostics.md +++ b/.claude/rules/projects-tray-diagnostics.md @@ -77,7 +77,7 @@ paths: - 会话菜单项 id 需要能安全携带 `pid`、`cwd` 和创建时的 `procStart` 快照;`cwd` 可能包含中文、空格、引号和 `::`。 - Terminal.app 与 iTerm2 通过 `pid -> tty -> AppleScript` 精确聚焦已有 tab。 - 聚焦链路用一次 `ps eww -o tty=,etime=,command=` 同时取 TERM_PROGRAM、控制终端与运行时长,不要拆成多次 spawn;pid 按会话文件 `procStart`(UTC)与 etime 推算的启动时间校验身份,`procStart` 缺失、非法或不一致(pid 已被回收)时拒绝整个聚焦请求,不得回退到 Ghostty cwd 或 herdr 的 pid 匹配。菜单项与可点击通知必须携带创建时的 `procStart` 快照,点击时不得按 pid 重读会话文件。 -- Ghostty 的 `tty` 属性由上游 #11592 引入(随 PR #11922 合入 main、尚未发布;1.3.x 的 sdef 没有,属性读取会抛 `-1700`):`try` 只包住 `tty of term` 属性读取,探测失败置标志跳过后续比较,`focus`/`return` 必须留在 try 外——新版按 tty 精确命中,旧版自然落到兜底。空 cwd 不生成 cwd 兜底分支(AppleScript 的 `"" is ""` 为 true,会误命中无 shell 集成的 tab)。普通会话按 client title 排除 herdr terminal 后再按 `working directory` 唯一匹配;herdr 宿主跳(含默认会话)永不排除 herdr client;不要使用 `select tab t of w` 这类循环变量 specifier,容易触发 `-1700` 类型错误。 +- Ghostty 的 `tty` 属性由上游 #11592 引入(随 PR #11922 合入 main、尚未发布;1.3.x 的 sdef 没有,属性读取会抛 `-1700`):`try` 只包住 `tty of term` 属性读取,探测失败置标志跳过后续比较,`focus`/`return` 必须留在 try 外——新版按 tty 精确命中,旧版自然落到兜底。空 cwd 不生成 cwd 兜底分支(AppleScript 的 `"" is ""` 为 true,会误命中无 shell 集成的 tab)。普通会话按 client title 排除 herdr terminal 后再按 `working directory` 唯一匹配;herdr 宿主跳(含默认会话)永不排除 herdr client,默认会话宿主 tab 标题稳定为 "herdr"、按标题直接命中;title/tty/cwd 都未命中且 tty 在手时,最后用 tty 标题标记法定位——向会话 tty 写 OSC 0 标题标记(终端只解析序列不显示字符),AppleScript 按标记标题命中后清除标记(Ghostty <1.4 无 `tty` 属性、同目录多 tab 时的唯一精确定位手段);每个命中分支必须在 `focus term` 后跟 `activate`——focus 只把窗口在其所在桌面内置前,终端在其它 Space 或非前台时必须靠 activate 切换桌面并取得键盘焦点;不要使用 `select tab t of w` 这类循环变量 specifier,容易触发 `-1700` 类型错误。 - herdr 会话(跑在 herdr pane 里的 Claude Code)走"两跳聚焦":先连 herdr unix socket API 按 pid 定位 pane 并 `pane.focus`,再找到附着的 herdr client 进程(其 tty 即宿主 tab 的 tty)复用宿主终端 AppleScript。逻辑集中在 `src-tauri/src/herdr.rs`,`terminal_focus.rs` 只做编排。 - herdr 检测:先读会话进程 env(`HERDR_SESSION` / `HERDR_SOCKET_PATH` 标记,命名会话才有),无标记时沿 ppid 链找名为 `herdr` 的祖先进程(默认会话没有 env 标记,只能靠进程树)。 - herdr socket 路径:`HERDR_SOCKET_PATH` > `/herdr/sessions//herdr.sock`(命名会话)> `/herdr/herdr.sock`;`HERDR_SESSION` 必须按 herdr 命名规则白名单校验,防路径穿越。 diff --git a/src-tauri/src/terminal_focus.rs b/src-tauri/src/terminal_focus.rs index 93858ee..24c870d 100644 --- a/src-tauri/src/terminal_focus.rs +++ b/src-tauri/src/terminal_focus.rs @@ -366,7 +366,8 @@ pub(crate) fn focus_tty( /// 普通 Ghostty 会话:身份校验通过后,tty 精确匹配优先(上游 #11592 起支持),没有 /// tty 或未命中时落到会话记录 cwd 的唯一匹配;cwd 匹配先排除 herdr client,避免误激活 -/// 同目录的 herdr 宿主 tab。身份校验失败时不会进入 cwd 兜底。 +/// 同目录的 herdr 宿主 tab。仍 miss 且 tty 在手时用 tty 标题标记法精确定位 +/// (Ghostty <1.4 无 `tty` 属性、同目录多 tab 时的降级方案)。身份校验失败时不会进入兜底。 pub(crate) fn focus_ghostty_regular_session( tty: Option<&str>, cwd: &str, @@ -382,6 +383,9 @@ pub(crate) fn focus_ghostty_regular_session( if focus_ghostty_attempt_with(&spec, run_script)? { return Ok(()); } + if focus_ghostty_via_title_marker(tty, run_script)? { + return Ok(()); + } log::warn!( "event=tray.session_focus status=miss reason=tab_not_found app=Ghostty kind=regular tty={} cwd={}", tty.unwrap_or("none"), @@ -390,8 +394,9 @@ pub(crate) fn focus_ghostty_regular_session( Err(FocusFailure::TabNotFound) } -/// 聚焦 herdr 宿主 Ghostty terminal:命名会话 title 与 client tty 精确匹配优先, -/// 都未命中才解析 client cwd(lsof)做唯一兜底。tty 来自 herdr client 进程 +/// 聚焦 herdr 宿主 Ghostty terminal:title 与 client tty 精确匹配优先(默认会话的 +/// 宿主 tab 标题稳定为 "herdr",同样按 title 命中),都未命中才解析 client cwd +/// (lsof)做唯一兜底,最后落到 tty 标题标记法。tty 来自 herdr client 进程 /// (宿主 tab 的 tty),发现 client 时必有值(tty 是过滤条件)。 pub(crate) fn focus_ghostty_herdr_host( tty: &str, @@ -400,7 +405,7 @@ pub(crate) fn focus_ghostty_herdr_host( run_script: &impl Fn(&str) -> Result, ) -> Result<(), FocusFailure> { // 第一段只做精确匹配(title/tty),不解析 cwd——lsof 是宿主跳里最贵的 spawn, - // Ghostty ≥1.4 时 tty 通常直接命中,不应白付。 + // Ghostty ≥1.4 时 tty 通常直接命中,1.3.x 默认会话 title 也能命中,不应白付。 let mut spec = GhosttyFocusSpec::herdr_host(tty, "", session_name); if focus_ghostty_attempt_with(&spec, run_script)? { return Ok(()); @@ -414,6 +419,9 @@ pub(crate) fn focus_ghostty_herdr_host( return Ok(()); } } + if focus_ghostty_via_title_marker(Some(tty), run_script)? { + return Ok(()); + } log::warn!( "event=tray.session_focus status=miss reason=tab_not_found app=Ghostty kind=herdr_host session={} tty={} cwd={}", session_name.unwrap_or("default"), @@ -429,7 +437,15 @@ fn focus_ghostty_attempt_with( spec: &GhosttyFocusSpec, run_script: &impl Fn(&str) -> Result, ) -> Result { - match run_script(&ghostty_script(spec)) { + run_ghostty_script(&ghostty_script(spec), run_script) +} + +/// 跑一段 Ghostty AppleScript 并统一错误口径。 +fn run_ghostty_script( + script: &str, + run_script: &impl Fn(&str) -> Result, +) -> Result { + match run_script(script) { Ok(hit) => Ok(hit), Err(e) => { log::warn!("event=tray.session_focus status=err app=Ghostty error={e}"); @@ -438,9 +454,86 @@ fn focus_ghostty_attempt_with( } } +/// 标记标题的全局序号:并发点击多个会话时保证各自标记不同,避免互相误命中。 +static TITLE_MARKER_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// 生成一次性标记标题:只含 ASCII 字母数字、连字符与下划线,可安全嵌入 +/// AppleScript 字面量,且不会与真实 tab 标题冲突。 +fn title_marker() -> String { + format!( + "code-manager-focus-{}-{}", + std::process::id(), + TITLE_MARKER_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + ) +} + +/// Ghostty <1.4 没有 `tty` 属性,同目录多 tab 时 cwd 唯一匹配也无法区分; +/// 此时向会话 tty 写一条 OSC 标题标记(终端只解析该序列、不显示字符、不影响 +/// 前台程序输入),AppleScript 按标记标题命中 tab 后聚焦并 activate, +/// 最后清回空标题(shell / Claude Code 下次更新标题时自然恢复)。 +/// 这是 tty 精确匹配在旧版 Ghostty 上的降级实现;tty 打不开或一直查不到 +/// 标记都视为未命中,交由调用方继续兜底。 +fn focus_ghostty_via_title_marker( + tty: Option<&str>, + run_script: &impl Fn(&str) -> Result, +) -> Result { + let Some(tty) = tty else { + return Ok(false); + }; + let marker = title_marker(); + if write_osc_title_to_tty(tty, &marker).is_err() { + return Ok(false); + } + let script = ghostty_title_marker_script(&escape_applescript_string(&marker)); + let hit = run_ghostty_script(&script, run_script); + // 无论命中与否都清掉标记,避免标题残留。 + if let Err(e) = write_osc_title_to_tty(tty, "") { + log::warn!("event=tray.session_focus status=degraded reason=marker_clear_failed error={e}"); + } + hit +} + +/// 构造 OSC 0 标题序列(`\x1b]0;\x07`);title 为空即清空标题。 +/// 抽成纯函数便于单测。 +fn osc_title_sequence(title: &str) -> Vec<u8> { + let mut seq = b"\x1b]0;".to_vec(); + seq.extend_from_slice(title.as_bytes()); + seq.push(0x07); + seq +} + +/// 向会话 tty 设备写入 OSC 标题序列。写设备输出流等价于终端程序自己输出该序列, +/// 终端只解析不显示;不会向会话前台程序的 stdin 注入任何输入。 +fn write_osc_title_to_tty(tty: &str, title: &str) -> std::io::Result<()> { + use std::io::Write; + let mut device = std::fs::OpenOptions::new().write(true).open(tty)?; + device.write_all(&osc_title_sequence(title)) +} + +/// 生成按标记标题定位的脚本:轮询若干轮消化 Ghostty 处理 OSC 的微小延迟, +/// 命中即 focus + activate(跨桌面/非前台场景必须 activate 才可见)。 +fn ghostty_title_marker_script(escaped_marker: &str) -> String { + format!( + r#"tell application "Ghostty" +repeat 8 times +repeat with term in terminals +set termName to name of term +if termName is "{escaped_marker}" then +focus term +activate +return true +end if +end repeat +delay 0.05 +end repeat +return false +end tell"# + ) +} + /// Ghostty 聚焦脚本参数:title/tty 精确匹配与 cwd 兜底的组合由调用场景显式决定, -/// `exclude_herdr_clients` 不做隐式推导——普通会话要排除 herdr client; -/// herdr 宿主跳的目标 tab 本身就是 herdr client,必须关闭排除(含默认会话, +/// `kind` 不做隐式推导——普通会话无 title 匹配且 cwd 兜底要排除 herdr client; +/// herdr 宿主跳的目标 tab 本身就是 herdr client,永不排除(含默认会话, /// 否则 cwd 兜底会跳过标题为 "herdr" 的目标 tab)。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct GhosttyFocusSpec<'a> { @@ -448,10 +541,18 @@ struct GhosttyFocusSpec<'a> { tty: Option<&'a str>, /// cwd 唯一兜底;空串表示不生成兜底分支(空 cwd 会误命中无工作目录的 tab)。 cwd: &'a str, - /// herdr 命名会话名(client title 匹配用);仅 herdr 宿主跳有值。 - session_name: Option<&'a str>, - /// cwd 兜底是否排除 herdr client。 - exclude_herdr_clients: bool, + /// 会话角色,决定 title 匹配形态与 cwd 兜底是否排除 herdr client。 + kind: GhosttyFocusKind<'a>, +} + +/// Ghostty 聚焦的会话角色。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GhosttyFocusKind<'a> { + /// 普通会话:无 title 匹配;cwd 兜底排除 herdr client。 + Regular, + /// herdr 宿主跳:永不排除 herdr client;命名会话按 client title 三形态匹配, + /// 默认会话宿主 tab 的标题稳定为 "herdr",按该标题匹配。 + HerdrHost { session_name: Option<&'a str> }, } impl<'a> GhosttyFocusSpec<'a> { @@ -460,8 +561,7 @@ impl<'a> GhosttyFocusSpec<'a> { Self { tty, cwd, - session_name: None, - exclude_herdr_clients: true, + kind: GhosttyFocusKind::Regular, } } @@ -470,8 +570,7 @@ impl<'a> GhosttyFocusSpec<'a> { Self { tty: Some(tty), cwd, - session_name, - exclude_herdr_clients: false, + kind: GhosttyFocusKind::HerdrHost { session_name }, } } } @@ -572,14 +671,19 @@ end tell"# /// 之后是 cwd 计数 + 唯一命中才聚焦的兜底段;空 cwd 时兜底段整体不生成。 fn ghostty_script(spec: &GhosttyFocusSpec) -> String { let escaped_cwd = escape_applescript_string(spec.cwd); - let escaped_session_name = spec.session_name.map(escape_applescript_string); + let session_name = match spec.kind { + GhosttyFocusKind::HerdrHost { + session_name: Some(name), + } => Some(escape_applescript_string(name)), + _ => None, + }; let escaped_tty = spec.tty.map(escape_applescript_string); // 变量声明:只声明本形态会用到的目标值,避免引用未定义变量。 let mut prelude = String::new(); if !spec.cwd.is_empty() { prelude.push_str(&format!(r#"set targetCwd to "{escaped_cwd}""#)); } - if let Some(session_name) = escaped_session_name.as_deref() { + if let Some(session_name) = session_name.as_deref() { if !prelude.is_empty() { prelude.push('\n'); } @@ -598,16 +702,37 @@ set termTty to """# // 精确匹配段:title 与 tty 共享一次 terminals 遍历,减少重复的 Apple 事件往返。 // tty 探测只 try 包住 `tty of term` 属性读取(1.3.x 的 sdef 没有该属性,会抛错): // 失败置 ttyProbeFailed 跳过后续比较,focus/return 留在 try 外,聚焦错误不会被吞掉。 + // 命中后必须 activate:`focus term` 只把窗口在其所在桌面内置前,不切换 Space、 + // 也不会激活非前台应用;activate 才让系统切到该应用窗口所在桌面并取得键盘焦点。 let mut exact_body = String::new(); - if escaped_session_name.is_some() { - exact_body.push_str( - r#"set termName to name of term + match spec.kind { + GhosttyFocusKind::HerdrHost { + session_name: Some(_), + } => { + exact_body.push_str( + r#"set termName to name of term if termName is ("herdr --session " & targetSessionName) or termName is ("herdr --session=" & targetSessionName) or termName is ("herdr session attach " & targetSessionName) then focus term +activate return true end if "#, - ); + ); + } + GhosttyFocusKind::HerdrHost { session_name: None } => { + // herdr 默认会话的宿主 tab 标题稳定为 "herdr",直接按标题命中; + // 命名会话的标题形态不同,不会误配。 + exact_body.push_str( + r#"set termName to name of term +if termName is "herdr" then +focus term +activate +return true +end if +"#, + ); + } + GhosttyFocusKind::Regular => {} } if escaped_tty.is_some() { exact_body.push_str( @@ -619,6 +744,7 @@ set ttyProbeFailed to true end try if (not ttyProbeFailed) and (termTty is targetTty) then focus term +activate return true end if end if @@ -636,13 +762,13 @@ end if String::new() } else { // herdr client 的 termName 读取与判定只在排除分支需要,避免多余 Apple 事件。 - let herdr_client_check = if spec.exclude_herdr_clients { + let herdr_client_check = if matches!(spec.kind, GhosttyFocusKind::Regular) { r#"set termName to name of term set isHerdrClient to (termName is "herdr") or (termName starts with "herdr --session ") or (termName starts with "herdr --session=") or (termName starts with "herdr session attach ")"# } else { "" }; - let cwd_match_condition = if spec.exclude_herdr_clients { + let cwd_match_condition = if matches!(spec.kind, GhosttyFocusKind::Regular) { r#"if (working directory of term is targetCwd) and (not isHerdrClient) then"# } else { r#"if working directory of term is targetCwd then"# @@ -661,6 +787,7 @@ repeat with term in terminals {herdr_client_check} {cwd_match_condition} focus term +activate return true end if end repeat @@ -877,24 +1004,27 @@ mod tests { fn ghostty_focus_entrypoints_pin_production_composition() { use std::cell::RefCell; + // 测试 tty 用确定不存在的设备名:标记法兜底在写 tty 失败时直接放弃, + // 既避免单测向真实终端写控制序列,也让脚本捕获数量确定。 + let ghost_tty = "/dev/ttysGHOST"; let scripts = RefCell::new(Vec::<String>::new()); let run_script = |script: &str| { scripts.borrow_mut().push(script.to_string()); Ok(false) }; - let err = focus_ghostty_regular_session(Some("/dev/ttys016"), "/Users/demo", &run_script) + let err = focus_ghostty_regular_session(Some(ghost_tty), "/Users/demo", &run_script) .expect_err("runner 返回 false 时普通会话应报告未命中"); assert_eq!(err, FocusFailure::TabNotFound); let regular = scripts.borrow().first().cloned().expect("应生成普通脚本"); - assert!(regular.contains(r#"set targetTty to "/dev/ttys016""#)); + assert!(regular.contains(r#"set targetTty to "/dev/ttysGHOST""#)); assert!( regular.contains("isHerdrClient"), "普通会话必须排除 herdr client" ); scripts.borrow_mut().clear(); - let err = focus_ghostty_herdr_host("/dev/ttys016", Some("work"), || None, &run_script) + let err = focus_ghostty_herdr_host(ghost_tty, Some("work"), || None, &run_script) .expect_err("runner 返回 false 时命名宿主应报告未命中"); assert_eq!(err, FocusFailure::TabNotFound); let named_host = scripts @@ -907,7 +1037,7 @@ mod tests { scripts.borrow_mut().clear(); let err = focus_ghostty_herdr_host( - "/dev/ttys016", + ghost_tty, None, || Some("/Users/demo".to_string()), &run_script, @@ -916,12 +1046,73 @@ mod tests { assert_eq!(err, FocusFailure::TabNotFound); let captured = scripts.borrow(); assert_eq!(captured.len(), 2, "默认宿主应先精确匹配再 cwd 兜底"); + // 默认会话第一段带 "herdr" 标题匹配:宿主 tab 标题稳定为 "herdr" + assert!(captured[0].contains(r#"termName is "herdr""#)); assert!(!captured[0].contains("targetSessionName")); assert!(!captured[0].contains("isHerdrClient")); assert!(captured[1].contains("working directory of term is targetCwd")); assert!(!captured[1].contains("isHerdrClient")); } + /// tty 标题标记法:tty 打不开(进程已退出 / 设备不存在)时视为未命中, + /// 不生成任何脚本、不报错。 + #[test] + fn title_marker_gives_up_when_tty_unwritable() { + use std::cell::RefCell; + let scripts = RefCell::new(Vec::<String>::new()); + let run_script = |script: &str| { + scripts.borrow_mut().push(script.to_string()); + Ok(false) + }; + assert_eq!( + focus_ghostty_via_title_marker(Some("/dev/ttysGHOST"), &run_script), + Ok(false) + ); + assert!(scripts.borrow().is_empty(), "不应执行任何脚本"); + } + + /// 标记标题序列:设置与清空的 OSC 0 载荷。 + #[test] + fn osc_title_sequence_sets_and_clears_title() { + assert_eq!( + osc_title_sequence("code-manager-focus-1"), + b"\x1b]0;code-manager-focus-1\x07".to_vec() + ); + // 空标题 = 清空 + assert_eq!(osc_title_sequence(""), b"\x1b]0;\x07".to_vec()); + } + + /// 标记标题必须每次唯一(并发点击互不误命中)且只含安全字符。 + #[test] + fn title_marker_is_unique_and_shell_safe() { + let first = title_marker(); + let second = title_marker(); + assert_ne!(first, second); + for marker in [first, second] { + assert!(marker.starts_with("code-manager-focus-")); + assert!( + marker + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')), + "标记不得含 AppleScript 特殊字符: {marker}" + ); + } + } + + /// 标记定位脚本:按标记标题轮询命中,focus 后必须 activate,轮询消化 OSC 处理延迟。 + #[test] + fn title_marker_script_polls_and_activates_on_hit() { + let marker = escape_applescript_string(&title_marker()); + let script = ghostty_title_marker_script(&marker); + + assert!(script.contains(&format!(r#"if termName is "{marker}" then"#))); + assert!(script.contains("focus term")); + assert!(script.contains("activate")); + assert!(script.contains("repeat 8 times"), "应轮询消化 OSC 延迟"); + assert!(script.contains("delay 0.05")); + assert!(script.contains("return false")); + } + /// herdr 默认会话宿主跳(Ghostty <1.4 时 tty 分支探测失败降级到 cwd 兜底): /// 兜底不得排除 herdr client,否则标题为 "herdr" 的目标 tab 永远匹配不上。 #[test] @@ -967,6 +1158,31 @@ mod tests { assert!(!script.contains("select tab t of w")); } + /// 每个命中分支都必须在 `focus term` 后 `activate`:focus 只把窗口在其所在 + /// 桌面内置前,终端在其它 Space 或非前台时用户看不到跳转;activate 才触发 + /// 系统切换桌面并取得键盘焦点(与 Terminal/iTerm 模板同款模式)。 + #[test] + fn ghostty_script_activates_app_on_every_hit_branch() { + // 普通会话:tty 分支 + cwd 聚焦分支 + let script = ghostty_script(&GhosttyFocusSpec::regular( + Some("/dev/ttys016"), + "/Users/demo/project", + )); + assert_eq!( + script.matches("focus term\nactivate").count(), + 2, + "每个命中分支都要紧跟 activate" + ); + + // herdr 命名会话宿主跳:title + tty + cwd 聚焦三个分支 + let script = ghostty_script(&GhosttyFocusSpec::herdr_host( + "/dev/ttys016", + "/Users/demo/project", + Some("cloudhub"), + )); + assert_eq!(script.matches("focus term\nactivate").count(), 3); + } + #[test] fn ghostty_script_does_not_choose_first_terminal_when_cwd_is_ambiguous() { let script = ghostty_script(&GhosttyFocusSpec::regular(None, "/Users/demo/project")); From bec51b89185a4bf59bc718b1f82f3f5c926da9a9 Mon Sep 17 00:00:00 2001 From: maguowei <imaguowei@gmail.com> Date: Sun, 16 Aug 2026 15:06:29 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(tray):=20=E4=BC=9A=E8=AF=9D=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E4=B8=8E=E9=80=9A=E7=9F=A5=E5=B1=95=E7=A4=BA=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E5=90=8D=20#=E7=9F=ADID=20=E5=B9=B6=E5=8C=BA=E5=88=86?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E5=91=BD=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude <noreply@anthropic.com> --- .claude/rules/projects-tray-diagnostics.md | 1 + src-tauri/src/tray.rs | 318 ++++++++++++++++++--- 2 files changed, 287 insertions(+), 32 deletions(-) diff --git a/.claude/rules/projects-tray-diagnostics.md b/.claude/rules/projects-tray-diagnostics.md index 3cbcd56..1777b1b 100644 --- a/.claude/rules/projects-tray-diagnostics.md +++ b/.claude/rules/projects-tray-diagnostics.md @@ -74,6 +74,7 @@ paths: - 主托盘负责配置切换和页面导航;会话托盘负责 `~/.claude/sessions/*.json` 的状态摘要。 - 设置抽屉负责 UI 语言、主题、本机自启动、默认终端、默认编辑器、托盘展示和诊断入口;主题仍由前端 localStorage 偏好控制,不属于后端 `AppPreferences`。 - 会话文件只读取普通 `.json` 文件,缺少 `pid`、`sessionId`、`cwd`、`status` 或字段为空时应跳过。 +- 会话菜单项与待处理通知展示格式统一为 `{emoji} {project_name} #{short_id} [· {custom_name}] · {status} [· {waiting_for}]`;其中 `short_id` 为 UUID 的前 8 位前缀(对齐 statusline 的 `${session_id:0:8}`,如 `#7dd0ffff`),用于同目录多会话消歧;仅当 `nameSource == "user"` 时展示用户自定义名称。 - 会话菜单项 id 需要能安全携带 `pid`、`cwd` 和创建时的 `procStart` 快照;`cwd` 可能包含中文、空格、引号和 `::`。 - Terminal.app 与 iTerm2 通过 `pid -> tty -> AppleScript` 精确聚焦已有 tab。 - 聚焦链路用一次 `ps eww -o tty=,etime=,command=` 同时取 TERM_PROGRAM、控制终端与运行时长,不要拆成多次 spawn;pid 按会话文件 `procStart`(UTC)与 etime 推算的启动时间校验身份,`procStart` 缺失、非法或不一致(pid 已被回收)时拒绝整个聚焦请求,不得回退到 Ghostty cwd 或 herdr 的 pid 匹配。菜单项与可点击通知必须携带创建时的 `procStart` 快照,点击时不得按 pid 重读会话文件。 diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index d208f3a..6c42557 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -16,7 +16,16 @@ use tauri_plugin_notification::NotificationExt; const MAIN_TRAY_ID: &str = "main_tray"; const SESSIONS_TRAY_ID: &str = "sessions_tray"; -const SESSION_MENU_LABEL_MAX_CHARS: usize = 64; + +/// 会话文本截断长度上限常量,保持菜单与通知紧凑整洁; +/// 待处理通知中 waiting_for 理由文本截断上限 +const SESSION_NOTIFICATION_WAITING_FOR_MAX_CHARS: usize = 64; +/// 会话菜单项基础主标识(项目名 #短ID)截断上限 +const SESSION_MENU_TAG_MAX_CHARS: usize = 24; +/// 会话菜单项自定义会话名截断上限 +const SESSION_MENU_CUSTOM_NAME_MAX_CHARS: usize = 16; +/// 会话菜单项 waiting_for 理由文本截断上限 +const SESSION_MENU_WAITING_FOR_MAX_CHARS: usize = 24; // 会话状态分类 emoji:托盘 title 与下拉菜单项共用,保持视觉一致。 // 🔴 待处理(最需关注)、🟢 进行中、⚪ 其它(空闲等);语义可按需调整。 const SESSION_STATUS_WAITING_EMOJI: &str = "🔴"; @@ -112,6 +121,12 @@ struct RawTraySession { /// 会话进程启动时间(`ps -o lstart=` 格式,UTC);聚焦时校验 pid 未被回收。 #[serde(default)] proc_start: Option<String>, + /// Claude Code 原生会话名称(自动生成如 `dotfiles-d4` 或用户自定义名)。 + #[serde(default)] + name: Option<String>, + /// 名称来源:`derived`(自动派生)或 `user`(用户重命名)。 + #[serde(default)] + name_source: Option<String>, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -124,6 +139,10 @@ struct TraySession { waiting_for: Option<String>, /// 会话进程启动时间;缺失或非法时不允许异步聚焦。 proc_start: Option<String>, + /// 会话名称。 + name: Option<String>, + /// 名称来源(`derived` / `user`)。 + name_source: Option<String>, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -229,6 +248,14 @@ impl From<RawTraySession> for TraySession { .proc_start .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()), + name: raw + .name + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + name_source: raw + .name_source + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), } } } @@ -286,6 +313,65 @@ fn session_project_name(cwd: &str) -> String { .unwrap_or_else(|| cwd.to_string()) } +/// 提取用于托盘 UI 消歧展示的 8 位短会话标识(如 `sessionId = "7dd0ffff-4e38..."` -> `"#7dd0ffff"`)。 +/// 过滤连字符后按字符截取前 8 位并拼接 "#" 前缀;对标准 UUID 输入与 statusline 的 `${session_id:0:8}` 展示一致, +/// 且按字符截取可杜绝多字节 UTF-8 切片 panic。若 session_id 为空则返回空串。 +fn session_short_id(session_id: &str) -> String { + let trimmed = session_id.trim(); + if trimmed.is_empty() { + return String::new(); + } + let clean: String = trimmed.chars().filter(|c| *c != '-').take(8).collect(); + if clean.is_empty() { + String::new() + } else { + format!("#{clean}") + } +} + +/// 识别会话是否由用户显式命名(如通过 `/rename feat-auth` 自定义名称)。 +/// 严格仅在 `nameSource == "user"` 时返回自定义名称,避免将 `.dotfiles` +/// 等隐藏目录生成的派生名(`dotfiles-d4`)误判为自定义重命名。 +fn session_custom_name(session: &TraySession) -> Option<&str> { + if session.name_source.as_deref() != Some("user") { + return None; + } + let name = session.name.as_deref()?.trim(); + if name.is_empty() { + None + } else { + Some(name) + } +} + +/// 获取会话的基础主标识:`{project_name} #{short_id}`(如 `.dotfiles #7dd0ffff`)。 +fn session_project_tag(session: &TraySession) -> String { + let project = session_project_name(&session.cwd); + let short_id = session_short_id(&session.session_id); + match (!project.is_empty(), !short_id.is_empty()) { + (true, true) => format!("{project} {short_id}"), + (true, false) => project, + (false, true) => short_id, + (false, false) => String::new(), + } +} + +/// 获取会话展示名(用于系统通知与汇总): +/// - 默认会话:`{project} #{short_id}`(如 `.dotfiles #7dd0ffff`) +/// - 用户自定义命名会话:`{project} #{short_id} · {custom_name}`(如 `code-manager #f0093a43 · feat-auth`) +fn session_display_name(session: &TraySession) -> String { + let tag = session_project_tag(session); + if let Some(custom) = session_custom_name(session) { + if !tag.is_empty() { + format!("{tag} · {custom}") + } else { + custom.to_string() + } + } else { + tag + } +} + fn session_status_label(status: &str, language: &str) -> String { let normalized = status.trim().to_ascii_lowercase(); let label = match (language, normalized.as_str()) { @@ -566,7 +652,7 @@ fn build_pending_session_notification( interaction: PendingSessionNotificationInteraction, ) -> PendingSessionNotification { let is_en = language == "en"; - let project_name = session_project_name(&session.cwd); + let display_name = session_display_name(session); let title = if is_en { "Claude session needs attention" } else { @@ -576,14 +662,14 @@ fn build_pending_session_notification( let body = match (is_en, session.waiting_for.as_deref()) { (_, Some(waiting_for)) => format!( "{} · {}", - crate::utils::truncate(&project_name, 48), - crate::utils::truncate(waiting_for, SESSION_MENU_LABEL_MAX_CHARS) + crate::utils::truncate(&display_name, 48), + crate::utils::truncate(waiting_for, SESSION_NOTIFICATION_WAITING_FOR_MAX_CHARS) ), (true, None) => format!( "{} needs attention", - crate::utils::truncate(&project_name, 48) + crate::utils::truncate(&display_name, 48) ), - (false, None) => format!("{} 需要处理", crate::utils::truncate(&project_name, 48)), + (false, None) => format!("{} 需要处理", crate::utils::truncate(&display_name, 48)), }; let focus_target = (interaction == PendingSessionNotificationInteraction::FocusTerminal) .then(|| session.proc_start.clone()) @@ -616,19 +702,19 @@ fn build_pending_sessions_summary_notification( "多个 Claude 会话待处理" } .to_string(); - let project_names = sessions + let display_names = sessions .iter() - .map(|session| crate::utils::truncate(&session_project_name(&session.cwd), 32)) + .map(|session| crate::utils::truncate(&session_display_name(session), 32)) .collect::<Vec<_>>() .join(", "); let body = if is_en { format!( "{} sessions need attention: {}", sessions.len(), - project_names + display_names ) } else { - format!("{} 个会话需要处理:{}", sessions.len(), project_names) + format!("{} 个会话需要处理:{}", sessions.len(), display_names) }; PendingSessionNotification { @@ -640,15 +726,23 @@ fn build_pending_sessions_summary_notification( } fn session_menu_item_label(session: &TraySession, language: &str) -> String { - let mut parts = vec![ - crate::utils::truncate(&session_project_name(&session.cwd), 32), - session_status_label(&session.status, language), - ]; + let tag = session_project_tag(session); + let mut parts = Vec::new(); + if !tag.is_empty() { + parts.push(crate::utils::truncate(&tag, SESSION_MENU_TAG_MAX_CHARS)); + } + if let Some(custom_name) = session_custom_name(session) { + parts.push(crate::utils::truncate( + custom_name, + SESSION_MENU_CUSTOM_NAME_MAX_CHARS, + )); + } + parts.push(session_status_label(&session.status, language)); if is_waiting_session_status(&session.status) { if let Some(waiting_for) = &session.waiting_for { parts.push(crate::utils::truncate( waiting_for, - SESSION_MENU_LABEL_MAX_CHARS, + SESSION_MENU_WAITING_FOR_MAX_CHARS, )); } } @@ -1512,10 +1606,10 @@ mod tests { get_tray_title, is_running_session_status, is_starting_session_status, is_waiting_session_status, load_tray_sessions_from_dir, main_tray_navigation_items, parse_session_menu_item_id, pending_session_notification_interaction, - pick_focus_target_session, session_focus_available_for_platform, + pick_focus_target_session, session_display_name, session_focus_available_for_platform, session_focus_failure_notification_enabled, session_menu_item_id, session_menu_item_label, - session_project_name, session_status_emoji, session_status_label, sessions_tray_title, - should_pulse, tick_pulse, to_superscript, tray_labels_for_language, + session_project_name, session_short_id, session_status_emoji, session_status_label, + sessions_tray_title, should_pulse, tick_pulse, to_superscript, tray_labels_for_language, PendingSessionFocusTarget, PendingSessionNotificationInteraction, PendingSessionNotifier, PulsePhase, PulseState, RawTraySession, TraySession, SESSION_STATUS_WAITING_DIM_EMOJI, SESSION_STATUS_WAITING_EMOJI, @@ -1538,6 +1632,7 @@ mod tests { const VALID_PROC_START: &str = "Wed Aug 12 15:27:23 2026"; fn test_session(cwd: &str, status: &str, updated_at: u64) -> TraySession { + let name = session_project_name(cwd); TraySession { pid: GHOST_PID, session_id: "session-1".to_string(), @@ -1546,6 +1641,8 @@ mod tests { updated_at, waiting_for: None, proc_start: Some(VALID_PROC_START.to_string()), + name: if name.is_empty() { None } else { Some(name) }, + name_source: None, } } @@ -1994,11 +2091,11 @@ mod tests { assert_eq!( session_menu_item_label(&session, "zh"), - "🔴 code-manager · 待处理 · approve Bash" + "🔴 code-manager #session1 · 待处理 · approve Bash" ); assert_eq!( session_menu_item_label(&session, "en"), - "🔴 code-manager · Waiting · approve Bash" + "🔴 code-manager #session1 · Waiting · approve Bash" ); } @@ -2045,7 +2142,10 @@ mod tests { assert_eq!(first_notifications.len(), 1); assert_eq!(first_notifications[0].title, "Claude 会话待处理"); - assert_eq!(first_notifications[0].body, "code-manager · approve Bash"); + assert_eq!( + first_notifications[0].body, + "code-manager #session1 · approve Bash" + ); assert!(first_notifications[0].focus_target.is_none()); assert!(repeated_notifications.is_empty()); } @@ -2243,6 +2343,8 @@ mod tests { updated_at: 2000, waiting_for: None, proc_start: Some(VALID_PROC_START.to_string()), + name: None, + name_source: None, }; let notification = build_pending_session_notification( @@ -2382,6 +2484,8 @@ mod tests { updated_at: 0, waiting_for: None, proc_start: Some(VALID_PROC_START.to_string()), + name: None, + name_source: None, }; let id = session_menu_item_id(&session); assert!(id.starts_with("session_4242::"), "id 缺前缀: {id}"); @@ -2519,8 +2623,146 @@ mod tests { assert_eq!(session_project_name("/Users/demo/中文项目"), "中文项目"); } - /// `From<RawTraySession>` 必须 trim 所有字符串字段,且把空白 / 空字符串的 `waiting_for` - /// 与 `proc_start` 规约为 None;否则下游菜单项会显示 ` · ` 这样的空段。 + /// `session_short_id`: + /// 过滤连字符后截取前 8 位字符,带 "#" 前缀(如 "#7dd0ffff"),空 session_id 返回空串。 + /// 对含多字节 UTF-8 字符、连字符、特殊符号能安全处理,不发生切片 panic。 + #[test] + fn session_short_id_extracts_clean_eight_char_prefix() { + assert_eq!( + session_short_id("7dd0ffff-4e38-4ab6-8e94-a04156cc407a"), + "#7dd0ffff" + ); + assert_eq!( + session_short_id("98a01864-a50b-4a28-8662-561aadefac2b"), + "#98a01864" + ); + assert_eq!(session_short_id("abc"), "#abc"); + assert_eq!(session_short_id(""), ""); + assert_eq!(session_short_id(" "), ""); + // 多字节 UTF-8 容错测试:按字符安全截取前 8 个字符,不发生切片 panic + assert_eq!(session_short_id("中文测试会话-123456"), "#中文测试会话12"); + assert_eq!(session_short_id("特殊字符-测试"), "#特殊字符测试"); + } + + /// `session_display_name` 与 `session_project_tag`: + /// 1. 默认会话(derived 或无 custom_name):`{project} #{short_id}`(如 `.dotfiles #7dd0ffff`); + /// 2. 自定义重命名会话(user named):`{project} #{short_id} · {custom_name}`(如 `.dotfiles #7dd0ffff · sync-zsh`); + /// 3. 隐藏目录 `.dotfiles` 派生名 `dotfiles-d4` 在 `name_source: None` 时不误判为自定义名; + /// 4. 若 sessionId 为空则仅显示项目名。 + #[test] + fn session_display_name_prefers_explicit_name_and_falls_back_to_short_id() { + // 场景 1:原生派生名(derived),展示项目名 + #短ID + let session_derived = TraySession { + pid: 1001, + session_id: "7dd0ffff-4e38-4ab6-8e94-a04156cc407a".to_string(), + cwd: "/Users/demo/.dotfiles".to_string(), + status: "idle".to_string(), + updated_at: 1000, + waiting_for: None, + proc_start: Some(VALID_PROC_START.to_string()), + name: Some("dotfiles-d4".to_string()), + name_source: Some("derived".to_string()), + }; + assert_eq!( + session_display_name(&session_derived), + ".dotfiles #7dd0ffff" + ); + + // 场景 2:用户通过 /rename 设置的自定义会话名(user),追加自定义名 + let session_renamed = TraySession { + name: Some("sync-zsh".to_string()), + name_source: Some("user".to_string()), + ..session_derived.clone() + }; + assert_eq!( + session_display_name(&session_renamed), + ".dotfiles #7dd0ffff · sync-zsh" + ); + + // 场景 3:name 存在但 name_source 缺失(旧版文件),不盲目当作自定义名 + let session_legacy_derived = TraySession { + name: Some("dotfiles-d4".to_string()), + name_source: None, + ..session_derived.clone() + }; + assert_eq!( + session_display_name(&session_legacy_derived), + ".dotfiles #7dd0ffff" + ); + + // 场景 4:无 name,依然展示项目名 + #短ID + let session_no_name = TraySession { + name: None, + name_source: None, + ..session_derived.clone() + }; + assert_eq!( + session_display_name(&session_no_name), + ".dotfiles #7dd0ffff" + ); + + // 场景 5:name 与 sessionId 均为空,回退到项目名 + let session_empty_id = TraySession { + session_id: "".to_string(), + name: None, + name_source: None, + ..session_derived.clone() + }; + assert_eq!(session_display_name(&session_empty_id), ".dotfiles"); + } + + /// 同一工作目录下多个会话在托盘菜单项中能通过 session_project_tag / 自定义名 互相区分 + #[test] + fn session_menu_item_label_distinguishes_multiple_sessions_in_same_cwd() { + let s1 = TraySession { + pid: 75389, + session_id: "7dd0ffff-4e38-4ab6-8e94-a04156cc407a".to_string(), + cwd: "/Users/demo/.dotfiles".to_string(), + status: "idle".to_string(), + updated_at: 1000, + waiting_for: None, + proc_start: Some(VALID_PROC_START.to_string()), + name: Some("dotfiles-d4".to_string()), + name_source: Some("derived".to_string()), + }; + let s2 = TraySession { + pid: 82357, + session_id: "98a01864-a50b-4a28-8662-561aadefac2b".to_string(), + cwd: "/Users/demo/.dotfiles".to_string(), + status: "idle".to_string(), + updated_at: 2000, + waiting_for: None, + proc_start: Some(VALID_PROC_START.to_string()), + name: Some("dotfiles-0d".to_string()), + name_source: Some("derived".to_string()), + }; + let s3 = TraySession { + pid: 84863, + session_id: "f0093a43-4503-44d6-871c-e5b74a26689a".to_string(), + cwd: "/Users/demo/work/code-manager".to_string(), + status: "waiting".to_string(), + updated_at: 3000, + waiting_for: Some("approve Bash".to_string()), + proc_start: Some(VALID_PROC_START.to_string()), + name: Some("feat-auth".to_string()), + name_source: Some("user".to_string()), + }; + + let label1 = session_menu_item_label(&s1, "zh"); + let label2 = session_menu_item_label(&s2, "zh"); + let label3 = session_menu_item_label(&s3, "zh"); + + assert_eq!(label1, "⚪ .dotfiles #7dd0ffff · 空闲"); + assert_eq!(label2, "⚪ .dotfiles #98a01864 · 空闲"); + assert_eq!( + label3, + "🔴 code-manager #f0093a43 · feat-auth · 待处理 · approve Bash" + ); + assert_ne!(label1, label2, "同目录多会话的菜单标签必须互不相同"); + } + + /// `From<RawTraySession>` 必须 trim 所有字符串字段,且把空白 / 空字符串的 `waiting_for`、 + /// `proc_start`、`name` 与 `name_source` 规约为 None;否则下游菜单项会显示 ` · ` 这样的空段。 #[test] fn from_raw_tray_session_trims_whitespace_and_filters_empty_waiting_for() { // 全 trim @@ -2532,6 +2774,8 @@ mod tests { updated_at: 100, waiting_for: Some(" approve Bash ".to_string()), proc_start: Some(" Wed Aug 12 15:27:23 2026 ".to_string()), + name: Some(" dotfiles-d4 ".to_string()), + name_source: Some(" derived ".to_string()), }; let session = TraySession::from(raw); assert_eq!(session.session_id, "s1"); @@ -2542,8 +2786,10 @@ mod tests { session.proc_start.as_deref(), Some("Wed Aug 12 15:27:23 2026") ); + assert_eq!(session.name.as_deref(), Some("dotfiles-d4")); + assert_eq!(session.name_source.as_deref(), Some("derived")); - // 纯空白 waiting_for / proc_start 视为缺省 + // 纯空白 waiting_for / proc_start / name / name_source 视为缺省 let raw = RawTraySession { pid: 42, session_id: "s1".to_string(), @@ -2552,12 +2798,16 @@ mod tests { updated_at: 0, waiting_for: Some(" ".to_string()), proc_start: Some(" ".to_string()), + name: Some(" ".to_string()), + name_source: Some(" ".to_string()), }; let session = TraySession::from(raw); assert_eq!(session.waiting_for, None); assert_eq!(session.proc_start, None); + assert_eq!(session.name, None); + assert_eq!(session.name_source, None); - // None waiting_for / proc_start 直接保留为 None + // None waiting_for / proc_start / name / name_source 直接保留为 None let raw = RawTraySession { pid: 42, session_id: "s1".to_string(), @@ -2566,10 +2816,14 @@ mod tests { updated_at: 0, waiting_for: None, proc_start: None, + name: None, + name_source: None, }; let session = TraySession::from(raw); assert_eq!(session.waiting_for, None); assert_eq!(session.proc_start, None); + assert_eq!(session.name, None); + assert_eq!(session.name_source, None); } /// status 比对必须大小写不敏感、忽略前后空白; @@ -2599,8 +2853,8 @@ mod tests { /// `session_menu_item_label`: /// - 非 waiting 状态:始终省略 waiting_for(即便后端误设也不渲染); - /// - waiting 状态 + waiting_for=None:仅渲染项目名与状态; - /// - 项目名超过 32 字符必须截断。 + /// - waiting 状态 + waiting_for=None:仅渲染主标识与状态; + /// - 主标识超过 24 字符必须截断。 #[test] fn session_menu_item_label_omits_waiting_for_outside_waiting_and_when_absent() { // 非 waiting 状态 + 有 waiting_for:应忽略 waiting_for @@ -2608,17 +2862,17 @@ mod tests { running.waiting_for = Some("不应渲染".to_string()); assert_eq!( session_menu_item_label(&running, "zh"), - "🟢 code-manager · 运行中" + "🟢 code-manager #session1 · 运行中" ); // waiting 状态但 waiting_for=None let waiting = test_session("/Users/demo/work/code-manager", "waiting", 2000); assert_eq!( session_menu_item_label(&waiting, "zh"), - "🔴 code-manager · 待处理" + "🔴 code-manager #session1 · 待处理" ); - // 长项目名截断到 32 字符(truncate 在末尾追加 "...") + // 长项目名截断到 24 字符(truncate 在末尾追加 "...") let long = test_session( "/Users/demo/work/this-is-a-really-really-long-project-name-that-exceeds-limit", "idle", @@ -2627,10 +2881,10 @@ mod tests { let label = session_menu_item_label(&long, "en"); // 截断后应仍带状态后缀 assert!(label.ends_with(" · Idle")); - // 去掉状态后缀和 "⚪ " emoji 前缀后,项目名不超过 32 + "..." 的截断长度 + // 去掉状态后缀和 "⚪ " emoji 前缀后,项目名不超过 24 + "..." 的截断长度 let project_part = label.trim_end_matches(" · Idle").trim_start_matches("⚪ "); assert!( - project_part.chars().count() <= 35, + project_part.chars().count() <= 27, "label 项目段过长: {project_part}" ); }