docs: add agent handoff docs, verification guide, and repair evidence

Add docs/agent-handoff (backlog, execution state, verification, platform plan, per-task evidence), repo AGENTS.md, and architecture notes updated for the dual-renderer design.
This commit is contained in:
2026-09-17 16:40:06 +08:00
parent 75b2ec4123
commit bc0b92bcdc
25 changed files with 2347 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
# P1-04 证据:Linux 截图热键与截图实现
状态:Windows 侧自动化验证全绿;Linux X11/Wayland 真实宿主验证按 VERIFICATION.md 手动待办(本环境为 Windows 桌面)。
## 交付物(文件级)
| 文件 | 变更 |
|---|---|
| `ui/views/system_tools/desktop_session.py` | **新增**(纯 stdlib):`session_kind()``win32/x11/wayland/unknown`WAYLAND_DISPLAY / QT_QPA_PLATFORM=wayland / DISPLAY 判定,offscreen→unknown);`hotkey_plan(kind)` / `capture_plan(kind)` 能力路由 + 明确能力说明文案 |
| `ui/views/system_tools/x11_hotkey.py` | **新增**(窄适配器,零新依赖,ctypes→libX11):`X11HotkeyThread`(与 Windows `GlobalHotkeyThread` 同一公开面 `triggered/start/stop`);XOpenDisplay→XKeysymToKeycode('s')→XSelectInput(KeyPressMask)→XGrabKey(root, keycode, Mod1Mask, owner_events)→select(X 连接 fd, 0.2s)+XPending/XNextEvent 循环;命中 Alt+S 发射 `triggered`XEvent 结构体按 xproto.h XKeyEvent 布局(64 位 keycode@76);`_open_x11()` 可注入(测试替身);`wait_ready()` |
| `ui/views/system_tools/portal_capture.py` | **新增**(窄适配器,系统 gdbus CLI,零 pip 依赖):`detect_portal()`Linux + XDG_RUNTIME_DIR/DBUS_SESSION_BUS_ADDRESS + gdbus 探测);`portal_screenshot_sync()`gdbus 调 `org.freedesktop.portal.Screenshot.Screenshot(handle, "/", {})` 取 request 对象路径 → `gdbus monitor --session --object-path <request>` 监听 `FilePicked`(成功,file:// URI 剥前缀+unquote 解码)/`Request.Finished`(无 FilePicked → 用户取消/拒绝);显式预算:request 10s + 等待 120s,超预算 → timeout`PortalScreenshotWorker(QThread)` 信号 `done(ok, path)` 回主线程 |
| `ui/views/main_window.py` | 热键注册块:`desktop_session.hotkey_plan(session_kind())` 平台路由(win32→GlobalHotkeyThread 行为字节级保持;x11→X11HotkeyThreadwayland/offscreen→None + 明确"全局热键不可用"日志),非 Windows 保留应用内 `QShortcut(Alt+S)` 兜底;`_start_screenshot()` 路由:win32/x11→现有覆盖层、wayland→`_start_portal_screenshot()`worker 完成→`_on_image_pasted([path])` 进现有图片附件流程)、unknown→明确"截图不可用,聊天与其他功能不受影响"日志 |
| `ui/views/system_tools/screen_capture.py` | `start()` 增加空画面守卫:无主屏幕 / grabWindow 返回空图(X11 个别 compositor 限制)→ 明确日志 + 不显示覆盖层(Wayland 已在路由层改走 portal |
| `tests/test_global_hotkey_platforms.py` | **新增** 23 检查:H1 session_kind 矩阵(win32/x11/wayland/offscreen/无显示 + XDG_SESSION_TYPE 三分支 + 矛盾时 WAYLAND_DISPLAY 优先);H2 hotkey_plan 四路由;H3 X11 成功路径(fake libX11XGrabKey 参数 keycode=39/Mod1Mask=1/root=123/owner_events=1、命中发射 triggered、stop 后 XUngrabKey+XCloseDisplay);H4 失败三分支(键被占用 XGrabKey=0 / 无显示 XOpenDisplay=None / 不支持组合不打开显示)均安静退出+明确日志;H5 Windows 路径保持(本机实测 RegisterHotKey 线程运行 + stop 干净释放) |
| `tests/test_screen_capture_platforms.py` | **新增** 17 检查:C1 capture_plan 四路由;C2 detect_portal 四分支(非 Linux/无 D-Bus/无 gdbus/齐备);C3 成功路径(fake subprocess 校验 gdbus 命令行 `--dest/--object-path/--method=...Screenshot/parent=/`、monitor 监听 request 对象、file:// 含空格文件名 URI 解码 → 真实文件);C4 授权被拒不伪造成功;C5 portal NotSupported 原因透出;C6 超预算 timeout(迟到信号不算成功);C7 worker 信号回主线程;C8 覆盖层 offscreen 构造+空画面守卫不崩 |
## 关键设计决定
1. **Windows 字节级保持**`GlobalHotkeyThread`Win32 RegisterHotKey 线程)与覆盖层路径零改动,仅调用处改为经 `hotkey_plan("win32")` 取回同一工厂;`capture_plan("win32")` 仍返回 overlay。
2. **X11 全局热键 = 原生 XGrabKey,无新 pip 依赖**libX11 是 X11 桌面必然存在的系统库,ctypes 直调;只映射现有 Alt+S`_VK_TO_KEYSYM` 窄表,扩展需显式加表项);`owner_events=1`stop 走 XUngrabKey+XCloseDisplay(关连接本身即释放 grab,双保险)。
3. **Wayland = xdg-desktop-portal,不绕过 compositor**compositor 安全模型禁止应用直接抓屏,故 Wayland 截图走 `org.freedesktop.portal.Screenshot`(交互式授权窗口,用户批准/取消);`gdbus`GLib 系统组件)CLI 完成 D-Bus 调用,不引入 dbus-python;成功返回文件路径 → 直接进现有 `_on_image_pasted([path])` 附件流程(不经过 Qt 覆盖层,因为 Wayland 下无法把画面抓进 Qt widget)。
4. **Wayland 全局热键:明确"不可用"而非硬做**:通用全局快捷键在 Wayland 依赖 compositor 桌面协议(ext-global-shortcut-unstable-v1 等,无统一 portal API),免依赖实现需完整 Wayland 客户端协议栈,超出窄适配器范围 → `hotkey_plan("wayland")` 返回 None + 日志明确说明(保留应用内 Alt+S + 截图按钮),符合硬约束"不支持时界面/日志必须明确说明能力不可用,主程序仍可聊天"。
5. **能力不可用的一等公民**`session_kind()=unknown`(offscreen/无显示)时,热键与截图都有显式日志("全局热键不可用"/"截图功能不可用;聊天与其他功能不受影响"),不再静默。
6. **异常绝不逃逸 QThread.run()**X11 事件循环整体 try/except——PyQt6 中 QThread.run() 未处理异常会 **abort 整个进程**(实测:CArgObject TypeError 逃逸 → 进程静默 127 退出、无 traceback、stdout 缓冲丢失)。这是本任务最贵的一个坑,已把"QThread.run() 必须全捕获"写进教训。
7. **超时语义**portal 等待中,FilePicked 之前/之后的信号都看预算——`Finished` 在预算内 → denied(用户取消);任何结果晚于预算 → timeout(不伪造、不无限挂起)。
## 验证(全部显式超时)
| 套件 | 结果 |
|---|---|
| `python tests/test_global_hotkey_platforms.py` | **23/23 PASS** EXIT=0timeout 120 |
| `python tests/test_screen_capture_platforms.py` | **17/17 PASS** EXIT=0timeout 180 |
| `python tests/test_file_attach.py`(回归) | 9 tests OKtimeout 90 |
| `python tests/smoke_offscreen.py`(回归,offscreen+software+--disable-gpu | **ALL PASS 8/8**timeout 240),日志含 `[GlobalHotkey] Windows:系统级全局热键 Alt+SRegisterHotKey` |
| `python tests/run_tests.py` | 41/41 |
| `tests/test_main_window_event_filter.py` / `test_config_isolation.py` / `test_wv2_guard.py` / `test_renderer_matrix.py` / `test_cross_platform_shell.py` | ALL PASS / ALL PASS / ALL PASS / 19/19 / 20/20 |
| `main.py` 真实启动链(offscreen + 临时 configtimeout 45→124 kill 预期) | 无 traceback`[GlobalHotkey] Windows:系统级全局热键 Alt+S` 打印;到达"JS 引擎已就绪"`data/config.json` mtime 前后一致(未触碰);error 行仅为 offscreen 已知 GPU 回落噪音 |
## 测试点细节(对应目标测试要求)
- **平台路由矩阵**H1session_kind 9 分支,含 XDG_SESSION_TYPE 与矛盾优先级)+ H2hotkey_plan 4 路由)+ C1capture_plan 4 路由)。
- **X11 替身**FakeX11 鸭子类型 libX11socketpair 提供可 select 的 fdXEvent 用 `ctypes.memmove` 填充;`CArgObject._obj``ctypes.byref(ev)` 还原原 struct——生产走真实 CDLL 不受影响)。
- **注册失败→明确消息**H4.1 键占用("Alt+S 可能已被其他程序占用")、H4.2 无显示("XOpenDisplay 失败")、H4.3 不支持组合("暂不支持的快捷键组合")——均无信号、`_registered=False`、线程安静退出。
- **portal 替身 subprocess**FakeRun/FakePopen 记录 argv 并回放 gdbus 输出(成功/拒绝/NotSupported/超时四态),验证真实 CLI 命令行与 JSON 事件解析,不依赖真实 portal。
- **X11/Wayland 真实行为**:本环境为 Windows 桌面,X11/Wayland 真机按 VERIFICATION.md 手动(见下)。
## 待办(需真实 Linux 宿主,按 VERIFICATION.md 手动)
1. Ubuntu 22.04/24.04 x64 X11 会话:`python3.10 main.py` → 日志 `[GlobalHotkey] X11:原生全局热键 Alt+SXGrabKey`;窗口失焦按 Alt+S 弹出覆盖层、框选截图成功;XGrabKey 被占用时日志明确。
2. Wayland 会话(GNOME/KDE):启动日志 `[GlobalHotkey] Wayland:…未启用 → 仅提供应用内 Alt+S…`;点截图按钮(或应用内 Alt+S)→ xdg-desktop-portal 授权窗口出现;批准 → 文件进入附件;取消 → 日志"portal 截图未完成…已取消";无 portal 时日志"portal 不可用"。
3. 其他发行版/DE 标记"未验证"(硬约束:不做发行版泛化)。
## 教训(持久)
- **PyQt6QThread.run() 内任何未处理异常 = abort 整个进程**(退出码 127、无 traceback、stdout 缓冲丢失,极难诊断)。QThread.run() 必须顶层 try/except 全捕获 + 日志。
- `ctypes.byref(x)` 返回 `CArgObject`:真实 CDLL 调用正常,但传给**普通 Python 可调用对象**(测试替身)时对方 `byref()` 会 TypeError——替身端用 `getattr(arg, "_obj", arg)` 还原。
- Windows 上 `os.pipe()` 的 fd 不能可靠用于 `select()`(无 WSAStartup 时 WinError 10093;初始化后是普通 pipe 又 10038)——跨平台可 select 的假 fd 用 `socket.socketpair()`;且 `a.send()` 的数据在 **b** 的接收缓冲(方向别写反)。
- `file://` URI 解析用"剥前缀 + unquote"而非 `urlparse().path``file://D%3A%5Cx`(无第三斜杠)会被 urlparse 当成 netloc → path 为空。
- gdbus 的 Screenshot 结果信号(FilePicked/Finished)都发在 **request 对象**上(方法返回的句柄路径),不是单独 handle 对象。