# -*- coding: utf-8 -*- """ X11 原生全局热键(P1-04,Linux X11 专用窄适配器) 用 ctypes 调 libX11:XOpenDisplay → XGrabKey(root, keycode, Mod1Mask) → select(display_fd) + XNextEvent 消息循环,命中后发射 triggered 信号。 与 GlobalHotkeyThread(Windows)同一公开面: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