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.
9.7 KiB
9.7 KiB
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→X11HotkeyThread;wayland/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 libX11:XGrabKey 参数 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 构造+空画面守卫不崩 |
关键设计决定
- Windows 字节级保持:
GlobalHotkeyThread(Win32 RegisterHotKey 线程)与覆盖层路径零改动,仅调用处改为经hotkey_plan("win32")取回同一工厂;capture_plan("win32")仍返回 overlay。 - X11 全局热键 = 原生 XGrabKey,无新 pip 依赖:libX11 是 X11 桌面必然存在的系统库,ctypes 直调;只映射现有 Alt+S(
_VK_TO_KEYSYM窄表,扩展需显式加表项);owner_events=1;stop 走 XUngrabKey+XCloseDisplay(关连接本身即释放 grab,双保险)。 - 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)。 - Wayland 全局热键:明确"不可用"而非硬做:通用全局快捷键在 Wayland 依赖 compositor 桌面协议(ext-global-shortcut-unstable-v1 等,无统一 portal API),免依赖实现需完整 Wayland 客户端协议栈,超出窄适配器范围 →
hotkey_plan("wayland")返回 None + 日志明确说明(保留应用内 Alt+S + 截图按钮),符合硬约束"不支持时界面/日志必须明确说明能力不可用,主程序仍可聊天"。 - 能力不可用的一等公民:
session_kind()=unknown(offscreen/无显示)时,热键与截图都有显式日志("全局热键不可用"/"截图功能不可用;聊天与其他功能不受影响"),不再静默。 - 异常绝不逃逸 QThread.run():X11 事件循环整体 try/except——PyQt6 中 QThread.run() 未处理异常会 abort 整个进程(实测:CArgObject TypeError 逃逸 → 进程静默 127 退出、无 traceback、stdout 缓冲丢失)。这是本任务最贵的一个坑,已把"QThread.run() 必须全捕获"写进教训。
- 超时语义:portal 等待中,FilePicked 之前/之后的信号都看预算——
Finished在预算内 → denied(用户取消);任何结果晚于预算 → timeout(不伪造、不无限挂起)。
验证(全部显式超时)
| 套件 | 结果 |
|---|---|
python tests/test_global_hotkey_platforms.py |
23/23 PASS EXIT=0(timeout 120) |
python tests/test_screen_capture_platforms.py |
17/17 PASS EXIT=0(timeout 180) |
python tests/test_file_attach.py(回归) |
9 tests OK(timeout 90) |
python tests/smoke_offscreen.py(回归,offscreen+software+--disable-gpu) |
ALL PASS 8/8(timeout 240),日志含 [GlobalHotkey] Windows:系统级全局热键 Alt+S(RegisterHotKey) |
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 + 临时 config,timeout 45→124 kill 预期) |
无 traceback;[GlobalHotkey] Windows:系统级全局热键 Alt+S 打印;到达"JS 引擎已就绪";data/config.json mtime 前后一致(未触碰);error 行仅为 offscreen 已知 GPU 回落噪音 |
测试点细节(对应目标测试要求)
- 平台路由矩阵:H1(session_kind 9 分支,含 XDG_SESSION_TYPE 与矛盾优先级)+ H2(hotkey_plan 4 路由)+ C1(capture_plan 4 路由)。
- X11 替身:FakeX11 鸭子类型 libX11(socketpair 提供可 select 的 fd;XEvent 用
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 手动)
- Ubuntu 22.04/24.04 x64 X11 会话:
python3.10 main.py→ 日志[GlobalHotkey] X11:原生全局热键 Alt+S(XGrabKey);窗口失焦按 Alt+S 弹出覆盖层、框选截图成功;XGrabKey 被占用时日志明确。 - Wayland 会话(GNOME/KDE):启动日志
[GlobalHotkey] Wayland:…未启用 → 仅提供应用内 Alt+S…;点截图按钮(或应用内 Alt+S)→ xdg-desktop-portal 授权窗口出现;批准 → 文件进入附件;取消 → 日志"portal 截图未完成…已取消";无 portal 时日志"portal 不可用"。 - 其他发行版/DE 标记"未验证"(硬约束:不做发行版泛化)。
教训(持久)
- PyQt6:QThread.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 对象。