Files
sorrow404null bc0b92bcdc 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.
2026-09-17 16:40:06 +08:00

60 lines
9.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 对象。