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