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

9.7 KiB
Raw Permalink Blame History

P1-04 证据:Linux 截图热键与截图实现

状态:Windows 侧自动化验证全绿;Linux X11/Wayland 真实宿主验证按 VERIFICATION.md 手动待办(本环境为 Windows 桌面)。

交付物(文件级)

文件 变更
ui/views/system_tools/desktop_session.py 新增(纯 stdlib):session_kind()win32/x11/wayland/unknownWAYLAND_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 发射 triggeredXEvent 结构体按 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,超预算 → timeoutPortalScreenshotWorker(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 字节级保持GlobalHotkeyThreadWin32 RegisterHotKey 线程)与覆盖层路径零改动,仅调用处改为经 hotkey_plan("win32") 取回同一工厂;capture_plan("win32") 仍返回 overlay。
  2. X11 全局热键 = 原生 XGrabKey,无新 pip 依赖libX11 是 X11 桌面必然存在的系统库,ctypes 直调;只映射现有 Alt+S_VK_TO_KEYSYM 窄表,扩展需显式加表项);owner_events=1stop 走 XUngrabKey+XCloseDisplay(关连接本身即释放 grab,双保险)。
  3. Wayland = xdg-desktop-portal,不绕过 compositorcompositor 安全模型禁止应用直接抓屏,故 Wayland 截图走 org.freedesktop.portal.Screenshot(交互式授权窗口,用户批准/取消);gdbusGLib 系统组件)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/8timeout 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._objctypes.byref(ev) 还原原 struct——生产走真实 CDLL 不受影响)。
  • 注册失败→明确消息H4.1 键占用("Alt+S 可能已被其他程序占用")、H4.2 无显示("XOpenDisplay 失败")、H4.3 不支持组合("暂不支持的快捷键组合")——均无信号、_registered=False、线程安静退出。
  • portal 替身 subprocessFakeRun/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().pathfile://D%3A%5Cx(无第三斜杠)会被 urlparse 当成 netloc → path 为空。
  • gdbus 的 Screenshot 结果信号(FilePicked/Finished)都发在 request 对象上(方法返回的句柄路径),不是单独 handle 对象。