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:
@@ -0,0 +1,163 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
xdg-desktop-portal 截图(P1-04,Linux 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 CLI(GLib 组件)→ 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)
|
||||
Reference in New Issue
Block a user