Files
Haocode/ui/views/system_tools/x11_hotkey.py
T
sorrow404null 0a62877cde 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.
2026-09-17 16:40:05 +08:00

197 lines
7.8 KiB
Python
Raw 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.
# -*- 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