feat(ui): cross-platform screenshot hotkey and portal-based capture

Windows keeps the legacy path; Linux X11 uses a native XGrabKey hotkey (ctypes libX11) with native capture; Wayland captures through xdg-desktop-portal and degrades gracefully with explicit capability-unavailable logs when the portal/protocol is missing.
This commit is contained in:
2026-09-17 16:40:05 +08:00
parent 95024785ae
commit 0a62877cde
6 changed files with 1014 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
"""P1-04:桌面会话探测 + 截图/全局热键能力路由(纯 stdlib,可在任何平台导入)。
契约(REPAIR_BACKLOG P1-04):
- Windows 保持现有行为(Win32 RegisterHotKey + Qt grabWindow 覆盖层)。
- Linux X11:原生全局快捷键(XGrabKey,见 x11_hotkey.py+ 原生屏幕捕获(Qt grabWindowX11 可用)。
- Linux Wayland:截图走 xdg-desktop-portal(用户授权,不绕过 compositor,见 portal_capture.py);
全局热键依赖 compositor 桌面协议(ext-global-shortcut 等),本版本无免依赖实现 →
明确告知不可用,仅保留应用内 Alt+S 快捷键,主程序其余功能不受影响。
- offscreen/无显示(unknown):能力不可用要有明确日志,主程序仍可聊天。
"""
import os
import sys
def session_kind() -> str:
"""返回 "win32" / "x11" / "wayland" / "unknown"
判定顺序(Linux,综合 Qt 平台名与 XDG_SESSION_TYPE,见 PLATFORM_PLAN):
1. QT_QPA_PLATFORM 以 offscreen 开头 → unknown(自动化离屏,无桌面能力)
2. QT_QPA_PLATFORM 以 wayland 开头,或 WAYLAND_DISPLAY 已设置,
或 XDG_SESSION_TYPE=wayland → wayland
3. DISPLAY 已设置,或 XDG_SESSION_TYPE=x11 → x11
4. 其余(headless/无显示)→ unknown
"""
if sys.platform == "win32":
return "win32"
if sys.platform != "linux":
# 其他 POSIX(macOS 等)不在本任务支持矩阵内
return "unknown"
qt_plat = os.environ.get("QT_QPA_PLATFORM", "").strip()
if qt_plat.startswith("offscreen"):
return "unknown"
xdg_type = os.environ.get("XDG_SESSION_TYPE", "").strip().lower()
if (qt_plat.startswith("wayland")
or os.environ.get("WAYLAND_DISPLAY", "").strip()
or xdg_type == "wayland"):
return "wayland"
if os.environ.get("DISPLAY", "").strip() or xdg_type == "x11":
return "x11"
return "unknown"
def hotkey_plan(kind: str):
"""全局热键能力路由 → (thread_factory | None, message)。
thread_factory() 返回 QThread(带 triggered 信号);None 表示无全局热键能力
(调用方应保留应用内 QShortcut 兜底)。message 需要打印以明确当前能力。
"""
if kind == "win32":
from ui.views.system_tools.global_hotkey import GlobalHotkeyThread
return GlobalHotkeyThread, "[GlobalHotkey] Windows:系统级全局热键 Alt+SRegisterHotKey"
if kind == "x11":
from ui.views.system_tools.x11_hotkey import X11HotkeyThread
return X11HotkeyThread, "[GlobalHotkey] X11:原生全局热键 Alt+SXGrabKey"
if kind == "wayland":
return None, ("[GlobalHotkey] Wayland:全局热键需要 compositor 桌面协议"
"ext-global-shortcut 等),本版本未启用 → 仅提供应用内 Alt+S 快捷键"
"(窗口获焦时生效)与截图按钮;其余功能不受影响")
return None, ("[GlobalHotkey] 当前环境无显示服务(offscreen/无 DISPLAY)→ 全局热键不可用;"
"应用内 Alt+S 快捷键同样不可用,可用截图按钮以外的全部功能")
def capture_plan(kind: str):
"""截图能力路由 → (mode, message)。
mode: "overlay"Qt grabWindow 覆盖层,win32/x11
"portal"xdg-desktop-portal 交互截图,wayland
"unavailable"(unknown:明确告知,主程序其余功能不受影响)
"""
if kind in ("win32", "x11"):
return "overlay", None
if kind == "wayland":
return "portal", None
return "unavailable", ("[Screenshot] 当前环境无法获取屏幕画面(offscreen/无显示)→ "
"截图功能不可用;聊天与其他功能不受影响")
+163
View File
@@ -0,0 +1,163 @@
# -*- coding: utf-8 -*-
"""
xdg-desktop-portal 截图(P1-04Linux Wayland 专用窄适配器)
Wayland compositor 不允许应用直接抓取屏幕画面(安全模型),截图必须走
xdg-desktop-portal 的 org.freedesktop.portal.Screenshot
1. gdbus 调 Screenshot(handle, parent_window=/, {}) → 返回 request 对象路径;
2. gdbus monitor 监听该 request 对象:
FilePicked(file_uri, ...) → 用户已授权并保存成功 → 返回文件路径;
Finished(无 FilePicked → 用户取消/授权被拒 → 返回 denied;
3. 全程有显式时间预算;超时 → timeout。
实现只用系统自带 CLI(gdbus,GLib 的一部分)+ 标准库,不新增 Python 依赖、
不绕过 compositor、不伪造成功。portal/桌面环境不支持时返回明确的不可用原因。
"""
import json
import os
import shutil
import subprocess
import sys
import time
import urllib.parse
from PyQt6 import QtCore
PORTAL_DEST = "org.freedesktop.portal.Desktop"
PORTAL_PATH = "/org/freedesktop/portal/desktop"
SCREENSHOT_IFACE = "org.freedesktop.portal.Screenshot"
REQUEST_IFACE = "org.freedesktop.portal.Request"
def detect_portal():
"""探测 portal 截图能力 → (ok: bool, reason: str | None)。"""
if sys.platform != "linux":
return False, "portal 截图仅用于 Linux"
if not (os.environ.get("XDG_RUNTIME_DIR", "").strip()
or os.environ.get("DBUS_SESSION_BUS_ADDRESS", "").strip()):
return False, ("未检测到 D-Bus 会话(XDG_RUNTIME_DIR/DBUS_SESSION_BUS_ADDRESS 均缺失)"
" → portal 截图不可用")
if not shutil.which("gdbus"):
return False, ("未找到 gdbus CLIGLib 组件)→ portal 截图不可用;"
"聊天与其他功能不受影响")
return True, None
def _gdbus_screenshot_request(handle: str, timeout_s: float):
"""调 Screenshot 方法,返回 (request_path, stderr);失败抛 RuntimeError。"""
cmd = ["gdbus", "call", "--session", "--dest", PORTAL_DEST,
"--object-path", PORTAL_PATH,
f"--method={SCREENSHOT_IFACE}.Screenshot",
handle, "/", "{}"]
try:
p = subprocess.run(cmd, capture_output=True, text=True,
encoding="utf-8", errors="replace",
timeout=timeout_s, check=False)
except subprocess.TimeoutExpired:
raise RuntimeError(f"portal Screenshot 调用超时({timeout_s:.0f}s 预算)")
if p.returncode != 0:
err = (p.stderr or p.stdout or "").strip().splitlines()
raise RuntimeError("portal 不可用: " + (err[-1] if err else "未知错误"))
out = (p.stdout or "").strip()
# gdbus 输出形如:('/org/freedesktop/portal/desktop/request/1000/haocode/42', <>)
start = out.find("('")
end = out.find("',") if start != -1 else -1
if start == -1 or end == -1:
raise RuntimeError(f"无法解析 Screenshot 返回: {out[:120]!r}")
return out[start + 2:end], (p.stderr or "").strip()
def _gdbus_monitor_request(request_path: str, budget_s: float):
"""监听 request 对象直到 FilePicked / Finished / 预算耗尽。
返回 (status, path_or_reason)status ∈ {"ok", "denied", "timeout"}。
"""
cmd = ["gdbus", "monitor", "--session", "--object-path", request_path]
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, encoding="utf-8", errors="replace")
deadline = time.monotonic() + budget_s
file_path = None
finished = False
try:
while time.monotonic() < deadline:
line = p.stdout.readline()
if not line:
if p.poll() is not None:
break
continue
line = line.strip()
if not line.startswith("{"):
continue
try:
ev = json.loads(line)
except Exception:
continue
member = ev.get("member", "")
body = ev.get("body", [])
if member == "FilePicked" and body:
uri = str(body[0])
# 剥前缀 + unquote(比 urlparse 稳健:兼容 file:///tmp/x 与 file://D%3A%5Cx
file_path = urllib.parse.unquote(uri[len("file://"):]) \
if uri.startswith("file://") else uri
elif member == "Finished" and ev.get("interface", "") == REQUEST_IFACE:
finished = True
break
if file_path:
return "ok", file_path
if finished and time.monotonic() <= deadline:
return "denied", "用户在 portal 授权窗口取消/拒绝"
return "timeout", f"等待 portal 授权超时({budget_s:.0f}s 预算)"
finally:
try:
p.kill()
except Exception:
pass
try:
p.wait(timeout=3)
except Exception:
pass
def portal_screenshot_sync(handle: str = "haocode-shot",
request_timeout_s: float = 10.0,
wait_budget_s: float = 120.0):
"""同步执行 portal 截图(调用方须保证不在 GUI 线程阻塞,或用 PortalScreenshotWorker)。
返回 (ok: bool, path_or_reason: str)。
"""
ok, reason = detect_portal()
if not ok:
return False, reason
try:
request_path, _err = _gdbus_screenshot_request(handle, request_timeout_s)
except RuntimeError as e:
return False, str(e)
status, result = _gdbus_monitor_request(request_path, wait_budget_s)
if status == "ok":
return True, result
if status == "denied":
return False, result
return False, result
class PortalScreenshotWorker(QtCore.QThread):
"""GUI 线程安全的 portal 截图:done(ok, path_or_reason) 信号回主线程。"""
done = QtCore.pyqtSignal(bool, str)
def __init__(self, handle: str = "haocode-shot",
request_timeout_s: float = 10.0,
wait_budget_s: float = 120.0, parent=None):
super().__init__(parent)
self._handle = handle
self._request_timeout_s = request_timeout_s
self._wait_budget_s = wait_budget_s
def run(self):
try:
ok, result = portal_screenshot_sync(self._handle,
self._request_timeout_s,
self._wait_budget_s)
except Exception as e:
ok, result = False, f"portal 截图异常({type(e).__name__}: {e}"
self.done.emit(ok, result)
+7
View File
@@ -60,8 +60,15 @@ class ScreenCaptureOverlay(QtWidgets.QWidget):
"""开始截图:抓取屏幕全图并显示覆盖层"""
screen = QtWidgets.QApplication.primaryScreen()
if not screen:
print("[Screenshot] 无主屏幕 → 截图不可用", flush=True)
return
self._full_pixmap = screen.grabWindow(0)
# P1-04X11 某些 compositor/环境下 grabWindow 可能拿到空图(Wayland 已改走 portal
if self._full_pixmap is None or self._full_pixmap.isNull() or self._full_pixmap.width() == 0:
print("[Screenshot] 屏幕抓取返回空画面(当前 compositor/环境限制)→ 本次截图取消;"
"聊天与其他功能不受影响", flush=True)
self._full_pixmap = None
return
self.setGeometry(screen.geometry())
self.show()
self.activateWindow()
+196
View File
@@ -0,0 +1,196 @@
# -*- coding: utf-8 -*-
"""
X11 原生全局热键(P1-04Linux X11 专用窄适配器)
用 ctypes 调 libX11XOpenDisplay → XGrabKey(root, keycode, Mod1Mask) →
select(display_fd) + XNextEvent 消息循环,命中后发射 triggered 信号。
与 GlobalHotkeyThreadWindows)同一公开面:triggered / start() / stop()。
约束:
- 只支持现有截图快捷键(Alt+S),不增加任意键监听/记录/重映射;
- 注册失败/无显示/无 libX11 → 打印明确"不可用"日志后安静退出,绝不影响主程序;
- 不引入任何新的 Python 依赖(libX11 是 X11 桌面必然存在的系统库)。
"""
import ctypes
import ctypes.util
import select
import threading
from PyQt6 import QtCore
# ---- X11 常量 ----
KeyPress = 2
Mod1Mask = 0x0001 # Alt
KeyPressMask = 0x00002 # XSelectInput event mask
AnyModifier = 0xFFFFFF
DEFAULT_MOD = 1 # 与 global_hotkey.MOD_ALT 同值
DEFAULT_VK = 0x53 # 与 global_hotkey.VK_S 同值('S'
#: Win32 VK → X11 keysym 的窄映射(只覆盖现有快捷键;扩展需同步本表)
_VK_TO_KEYSYM = {
0x53: 0x73, # S → 's'
}
_XEVENT_FIELDS = [
("type", ctypes.c_int),
("serial", ctypes.c_ulong),
("send_event", ctypes.c_int),
("display", ctypes.c_void_p),
("window", ctypes.c_uint),
("root", ctypes.c_uint),
("subwindow", ctypes.c_uint),
("time", ctypes.c_ulong),
("x", ctypes.c_int),
("y", ctypes.c_int),
("root_x", ctypes.c_int),
("root_y", ctypes.c_int),
("state", ctypes.c_uint),
("keycode", ctypes.c_uint),
("same_screen", ctypes.c_int),
]
class XEvent(ctypes.Structure):
"""XEvent union 的按键事件视图(字段布局与 xproto.h XKeyEvent 一致,64 位下 keycode 位于偏移 76"""
_fields_ = _XEVENT_FIELDS
def _load_x11():
"""加载 libX11 并声明最小 API 原型。失败抛 OSError(调用方转成"不可用"日志)。"""
name = ctypes.util.find_library("X11") or "libX11.so.6"
x11 = ctypes.CDLL(name)
x11.XOpenDisplay.argtypes = [ctypes.c_char_p]
x11.XOpenDisplay.restype = ctypes.c_void_p
x11.XCloseDisplay.argtypes = [ctypes.c_void_p]
x11.XConnectionNumber.argtypes = [ctypes.c_void_p]
x11.XConnectionNumber.restype = ctypes.c_int
x11.XDefaultRootWindow.argtypes = [ctypes.c_void_p]
x11.XDefaultRootWindow.restype = ctypes.c_uint
x11.XKeysymToKeycode.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
x11.XKeysymToKeycode.restype = ctypes.c_int
x11.XSelectInput.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_long]
x11.XGrabKey.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_uint, ctypes.c_uint, ctypes.c_int]
x11.XGrabKey.restype = ctypes.c_int
x11.XUngrabKey.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_uint, ctypes.c_uint]
x11.XPending.argtypes = [ctypes.c_void_p]
x11.XPending.restype = ctypes.c_int
x11.XNextEvent.argtypes = [ctypes.c_void_p, ctypes.POINTER(XEvent)]
return x11
def _open_x11():
"""可注入入口(测试用替身替换)。返回 (x11, display)display 为 0/None 表示无显示。"""
x11 = _load_x11()
disp = x11.XOpenDisplay(None)
return x11, disp
class X11HotkeyThread(QtCore.QThread):
"""X11 全局热键线程(公开面与 GlobalHotkeyThread 一致:triggered/start/stop)。"""
triggered = QtCore.pyqtSignal()
def __init__(self, hotkey_id=9001, mod=DEFAULT_MOD, vk=DEFAULT_VK, parent=None):
super().__init__(parent)
self._hotkey_id = hotkey_id
self._mod = mod
self._vk = vk
self._ready = threading.Event()
self._stop_flag = False
self._ready_flag = False
self._registered = False
self._keycode = 0
self._x11 = None
self._display = None
self._fd = None
# ------------------------------------------------------------------
def _setup(self):
"""打开显示、注册热键。成功返回 True;失败打印明确日志并返回 False。"""
try:
keysym = _VK_TO_KEYSYM.get(self._vk)
if keysym is None or self._mod != DEFAULT_MOD:
print(f"[X11Hotkey] 暂不支持的快捷键组合 (mod={self._mod:#x}, vk={self._vk:#x})"
f" → 全局热键不可用(本适配器只覆盖现有 Alt+S)", flush=True)
return False
x11, disp = _open_x11()
if not disp:
print("[X11Hotkey] XOpenDisplay 失败(无 X11 显示服务)→ 全局热键不可用;"
"应用内快捷键/截图按钮以外的功能不受影响", flush=True)
return False
self._x11 = x11
self._display = disp
self._fd = x11.XConnectionNumber(disp)
root = x11.XDefaultRootWindow(disp)
keycode = x11.XKeysymToKeycode(disp, keysym)
if not keycode:
print("[X11Hotkey] 键码解析失败 → 全局热键不可用", flush=True)
self._cleanup()
return False
self._keycode = keycode
x11.XSelectInput(disp, root, ctypes.c_long(KeyPressMask))
if x11.XGrabKey(disp, keycode, Mod1Mask, root, 1) == 0:
# BadAccess:键已被其他程序抢占
print(f"[X11Hotkey] XGrabKey 注册失败(Alt+S 可能已被其他程序占用)"
f" → 全局热键不可用;应用内快捷键/截图按钮以外的功能不受影响", flush=True)
self._cleanup()
return False
self._registered = True
return True
except Exception as e:
print(f"[X11Hotkey] 初始化失败({type(e).__name__}: {e})→ 全局热键不可用", flush=True)
self._cleanup()
return False
def _cleanup(self):
if self._display and self._x11:
try:
self._x11.XUngrabKey(self._display, self._keycode, AnyModifier,
self._x11.XDefaultRootWindow(self._display))
except Exception:
pass
try:
self._x11.XCloseDisplay(self._display)
except Exception:
pass
self._display = None
self._x11 = None
self._fd = None
self._registered = False
# ------------------------------------------------------------------
def run(self):
if not self._setup():
self._ready.set()
return
self._ready.set()
try:
while not self._stop_flag:
if self._fd is None:
break
try:
r, _, _ = select.select([self._fd], [], [], 0.2)
except (OSError, ValueError):
break
if not r:
continue
if not self._x11.XPending(self._display):
continue
ev = XEvent()
self._x11.XNextEvent(self._display, ctypes.byref(ev))
if ev.type == KeyPress and ev.keycode == self._keycode:
self.triggered.emit()
except BaseException as e:
# 绝不允许异常逃逸出 QThread.run()PyQt6 会 abort 整个应用)
print(f"[X11Hotkey] 事件循环异常({type(e).__name__}: {e})→ 全局热键不可用;其余功能不受影响",
flush=True)
finally:
self._cleanup()
def wait_ready(self, timeout_s: float = 2.0) -> bool:
"""等待注册流程结束(成功或失败都算就绪;测试/诊断用)"""
return self._ready.wait(timeout_s)
def stop(self):
"""请求退出(select 0.2s 超时轮询 stop 标志 → 线程安全退出,无需向 X 连接发消息)"""
self._stop_flag = True