diff --git a/debian/control b/debian/control index a1956756..45181f12 100644 --- a/debian/control +++ b/debian/control @@ -26,6 +26,7 @@ Build-Depends: debhelper (>= 11), wayland-protocols, libxcb-keysyms1-dev, libxcb-xtest0-dev, + libxtst-dev, libwayland-dev, treeland-protocols (>> 0.5.7), python3, diff --git a/src/plugin-qt/shortcut/CMakeLists.txt b/src/plugin-qt/shortcut/CMakeLists.txt index e451e6f1..e0750ff1 100644 --- a/src/plugin-qt/shortcut/CMakeLists.txt +++ b/src/plugin-qt/shortcut/CMakeLists.txt @@ -21,6 +21,7 @@ pkg_check_modules(XCB_XINPUT REQUIRED xcb-xinput) pkg_check_modules(XCB_XTEST REQUIRED xcb-xtest) pkg_check_modules(X11 REQUIRED x11) pkg_check_modules(X11_XCB REQUIRED x11-xcb) +pkg_check_modules(XTST REQUIRED xtst) pkg_check_modules(WAYLAND_CLIENT REQUIRED wayland-client) # Set source file path (using sources from plugin directory) @@ -52,6 +53,7 @@ set(SHORTCUT_COMMON_SOURCES ${SHORTCUT_SRC_DIR}/backend/x11/x11helper.cpp ${SHORTCUT_SRC_DIR}/backend/x11/modifierkeystate.cpp ${SHORTCUT_SRC_DIR}/backend/x11/modifierkeymonitor.cpp + ${SHORTCUT_SRC_DIR}/backend/x11/x11shortcutpolicy.cpp ${SHORTCUT_SRC_DIR}/backend/x11/systemgestureproxy.cpp ${SHORTCUT_SRC_DIR}/backend/x11/x11gestureactionexecutor.cpp ${SHORTCUT_SRC_DIR}/backend/x11/x11gesturehandler.cpp @@ -95,6 +97,7 @@ target_link_libraries(${PLUGIN_NAME} PRIVATE ${XCB_XTEST_LIBRARIES} ${X11_LIBRARIES} ${X11_XCB_LIBRARIES} + ${XTST_LIBRARIES} ${WAYLAND_CLIENT_LIBRARIES} ) @@ -106,6 +109,7 @@ target_include_directories(${PLUGIN_NAME} PRIVATE ${XCB_XTEST_INCLUDE_DIRS} ${X11_INCLUDE_DIRS} ${X11_XCB_INCLUDE_DIRS} + ${XTST_INCLUDE_DIRS} ${WAYLAND_CLIENT_INCLUDE_DIRS} ) @@ -225,6 +229,7 @@ if(BUILD_SHORTCUT_DEBUG_BINARY) ${XCB_XTEST_LIBRARIES} ${X11_LIBRARIES} ${X11_XCB_LIBRARIES} + ${XTST_LIBRARIES} ${WAYLAND_CLIENT_LIBRARIES} ) @@ -237,6 +242,7 @@ if(BUILD_SHORTCUT_DEBUG_BINARY) ${XCB_XTEST_INCLUDE_DIRS} ${X11_INCLUDE_DIRS} ${X11_XCB_INCLUDE_DIRS} + ${XTST_INCLUDE_DIRS} ${WAYLAND_CLIENT_INCLUDE_DIRS} ) endif() diff --git a/src/plugin-qt/shortcut/src/backend/x11/modifierkeymonitor.cpp b/src/plugin-qt/shortcut/src/backend/x11/modifierkeymonitor.cpp index 3c5dc2c5..eeb58d36 100644 --- a/src/plugin-qt/shortcut/src/backend/x11/modifierkeymonitor.cpp +++ b/src/plugin-qt/shortcut/src/backend/x11/modifierkeymonitor.cpp @@ -7,32 +7,73 @@ #include "modifierkeymonitor.h" #include +#include #include #include #include +#include +#include +#include #include -ModifierKeyMonitor::ModifierKeyMonitor(QObject *parent) +ModifierKeyMonitor::ModifierKeyMonitor(QObject *parent, bool allowRecord) : QObject(parent) { - m_available = initializeRawEvents(); + if (allowRecord && initializeRecord()) { + m_transport = Transport::Record; + m_available = true; + } else { + cleanupRecordResources(); + if (initializeRawEvents()) { + m_transport = Transport::XInput2; + m_available = true; + qCInfo(logShortcut) << "ModifierMonitor: using XInput2 fallback"; + } + } } ModifierKeyMonitor::~ModifierKeyMonitor() { stop(); + if (m_notifier) + m_notifier->setEnabled(false); + if (m_recordContext && m_controlDisplay) { + XRecordDisableContext(m_controlDisplay, m_recordContext); + XSync(m_controlDisplay, False); + } if (m_keySymbols) xcb_key_symbols_free(m_keySymbols); + if (m_keyConnection) + xcb_disconnect(m_keyConnection); + closeDataConnection(); if (m_eventConnection) xcb_disconnect(m_eventConnection); + if (m_recordContext && m_controlDisplay) { + freeContext(); + } + if (m_controlDisplay) + XCloseDisplay(m_controlDisplay); } void ModifierKeyMonitor::start() { - if (m_available && m_notifier) { + if (!m_available) + return; + + m_startRequested = true; + m_state.reset(); + if (m_transport == Transport::XInput2) { discardPendingInputEvents(); - m_notifier->setEnabled(true); + m_running = true; + if (m_notifier) + m_notifier->setEnabled(true); + return; + } + if (m_recordState == RecordState::Disabled) { + enableContext(); + } else if (m_recordState == RecordState::Enabled) { + m_running = true; } } @@ -41,12 +82,54 @@ bool ModifierKeyMonitor::isAvailable() const return m_available; } +bool ModifierKeyMonitor::isRunning() const +{ + return m_running; +} + +bool ModifierKeyMonitor::supportsGrabResilientEvents() const +{ + return m_transport == Transport::Record && m_available; +} + +void ModifierKeyMonitor::refreshKeyboardMapping() +{ + if (m_transport == Transport::XInput2) { + if (m_keySymbols) + xcb_key_symbols_free(m_keySymbols); + m_keySymbols = m_eventConnection + ? xcb_key_symbols_alloc(m_eventConnection) : nullptr; + m_state.reset(); + return; + } + if (!m_keyConnection) + return; + + if (m_keySymbols) + xcb_key_symbols_free(m_keySymbols); + m_keySymbols = xcb_key_symbols_alloc(m_keyConnection); + if (!m_keySymbols) + qCWarning(logShortcut) << "ModifierMonitor: failed to refresh RECORD key symbols"; + m_state.reset(); +} + void ModifierKeyMonitor::stop() { - if (m_notifier) - m_notifier->setEnabled(false); - discardPendingInputEvents(); + m_startRequested = false; + m_running = false; m_state.reset(); + + if (m_transport == Transport::XInput2) { + if (m_notifier) + m_notifier->setEnabled(false); + discardPendingInputEvents(); + return; + } + + if (m_recordState == RecordState::Enabled) + disableContext(); + else if (m_recordState == RecordState::Disabled && m_notifier) + m_notifier->setEnabled(false); } void ModifierKeyMonitor::notifyNonModifierKeyPressed() @@ -54,24 +137,101 @@ void ModifierKeyMonitor::notifyNonModifierKeyPressed() m_state.notifyNonModifierActivity(); } +bool ModifierKeyMonitor::initializeRecord() +{ + m_controlDisplay = XOpenDisplay(nullptr); + if (!m_controlDisplay) { + qCWarning(logShortcut) << "ModifierMonitor: failed to connect to X server for RECORD"; + return false; + } + + int major = 0; + int minor = 0; + if (!XRecordQueryVersion(m_controlDisplay, &major, &minor)) { + qCWarning(logShortcut) << "ModifierMonitor: X RECORD extension is unavailable"; + return false; + } + + m_keyConnection = xcb_connect(nullptr, nullptr); + if (!m_keyConnection || xcb_connection_has_error(m_keyConnection)) { + qCWarning(logShortcut) << "ModifierMonitor: failed to open keymap connection"; + return false; + } + m_keySymbols = xcb_key_symbols_alloc(m_keyConnection); + if (!m_keySymbols) { + qCWarning(logShortcut) << "ModifierMonitor: failed to initialize RECORD key symbols"; + return false; + } + + if (!createContext()) + return false; + + qCInfo(logShortcut) << "ModifierMonitor: X RECORD" << major << minor << "available"; + return true; +} + +void ModifierKeyMonitor::cleanupRecordResources() +{ + closeDataConnection(); + if (m_keySymbols) { + xcb_key_symbols_free(m_keySymbols); + m_keySymbols = nullptr; + } + if (m_keyConnection) { + xcb_disconnect(m_keyConnection); + m_keyConnection = nullptr; + } + if (m_recordContext && m_controlDisplay) + freeContext(); + if (m_controlDisplay) { + XCloseDisplay(m_controlDisplay); + m_controlDisplay = nullptr; + } +} + +void ModifierKeyMonitor::fallbackToXInput2() +{ + const bool shouldRun = m_startRequested; + m_running = false; + m_recordState = RecordState::Disabled; + m_available = false; + m_transport = Transport::Unavailable; + cleanupRecordResources(); + + if (!initializeRawEvents()) { + qCWarning(logShortcut) << "ModifierMonitor: XInput2 fallback failed"; + return; + } + + m_transport = Transport::XInput2; + m_available = true; + qCInfo(logShortcut) << "ModifierMonitor: switched to XInput2 fallback"; + if (shouldRun) { + discardPendingInputEvents(); + m_running = true; + m_notifier->setEnabled(true); + } +} + bool ModifierKeyMonitor::initializeRawEvents() { m_eventConnection = xcb_connect(nullptr, nullptr); if (!m_eventConnection || xcb_connection_has_error(m_eventConnection)) { - qCWarning(logShortcut) << "ModifierMonitor: Failed to connect to X server"; + qCWarning(logShortcut) << "ModifierMonitor: failed to connect for XInput2 fallback"; return false; } - xcb_screen_iterator_t screenIterator = xcb_setup_roots_iterator(xcb_get_setup(m_eventConnection)); - m_screen = screenIterator.data; + xcb_screen_iterator_t screenIterator = + xcb_setup_roots_iterator(xcb_get_setup(m_eventConnection)); while (screenIterator.rem) { m_rootWindows.append(screenIterator.data->root); xcb_screen_next(&screenIterator); } m_keySymbols = xcb_key_symbols_alloc(m_eventConnection); - const xcb_query_extension_reply_t *extension = xcb_get_extension_data(m_eventConnection, &xcb_input_id); - if (!m_screen || !m_keySymbols || !extension || !extension->present) { - qCWarning(logShortcut) << "ModifierMonitor: XInput2 is unavailable"; + const xcb_query_extension_reply_t *extension = + xcb_get_extension_data(m_eventConnection, &xcb_input_id); + if (m_rootWindows.isEmpty() || !m_keySymbols || !extension || !extension->present) { + qCWarning(logShortcut) << "ModifierMonitor: XInput2 fallback is unavailable"; return false; } m_inputOpcode = extension->major_opcode; @@ -100,13 +260,13 @@ bool ModifierKeyMonitor::initializeRawEvents() | XCB_INPUT_XI_EVENT_MASK_RAW_BUTTON_RELEASE; for (xcb_window_t rootWindow : std::as_const(m_rootWindows)) { - const xcb_void_cookie_t selectCookie = xcb_input_xi_select_events_checked( + const xcb_void_cookie_t cookie = xcb_input_xi_select_events_checked( m_eventConnection, rootWindow, 1, &eventMask.header); - xcb_generic_error_t *selectError = xcb_request_check(m_eventConnection, selectCookie); - if (selectError) { - qCWarning(logShortcut) << "ModifierMonitor: Failed to select XInput2 raw events:" - << selectError->error_code << "on root" << rootWindow; - free(selectError); + xcb_generic_error_t *error = xcb_request_check(m_eventConnection, cookie); + if (error) { + qCWarning(logShortcut) << "ModifierMonitor: failed to select XInput2 raw events:" + << error->error_code; + free(error); return false; } } @@ -137,7 +297,7 @@ void ModifierKeyMonitor::discardPendingInputEvents() } } -void ModifierKeyMonitor::handleEvents() +void ModifierKeyMonitor::handleRawEvents() { QList events; bool needsStateSnapshot = false; @@ -154,8 +314,6 @@ void ModifierKeyMonitor::handleEvents() if (needsStateSnapshot) { physicallyPressed = queryPressedModifiers(); if (physicallyPressed) { - // Waiting for QueryKeymap queues every event preceding its reply. - // Drain only that queue so the snapshot and events share a boundary. while ((event = xcb_poll_for_queued_event(m_eventConnection))) events.append(event); } @@ -191,22 +349,22 @@ void ModifierKeyMonitor::handleEvents() if (parseModifierKeyEvent(pendingEvent, pressed, keycode) && pressed && pressIndex < statesBeforePresses.size()) { if (m_state.reconcileAtEventBoundary(statesBeforePresses.at(pressIndex))) { - qCInfo(logShortcut) << "ModifierMonitor: recovered stale state before key press" - << int(keycode); + qCInfo(logShortcut) << "ModifierMonitor: recovered stale XI2 state before key press" + << int(keycode); } ++pressIndex; } - handleEvent(pendingEvent); + handleRawEvent(pendingEvent); free(pendingEvent); } if (physicallyPressed && !mappingChanged && m_state.reconcileAtEventBoundary(physicallyPressed.value())) { - qCInfo(logShortcut) << "ModifierMonitor: recovered stale state after event batch"; + qCInfo(logShortcut) << "ModifierMonitor: recovered stale XI2 state after event batch"; } } -void ModifierKeyMonitor::handleEvent(xcb_generic_event_t *event) +void ModifierKeyMonitor::handleRawEvent(xcb_generic_event_t *event) { const uint8_t responseType = event->response_type & ~0x80; if (responseType == XCB_GE_GENERIC) { @@ -238,35 +396,9 @@ void ModifierKeyMonitor::handleEvent(xcb_generic_event_t *event) } } -void ModifierKeyMonitor::handleRawKey(bool pressed, xcb_keycode_t keycode) -{ - const xcb_keysym_t keysym = xcb_key_symbols_get_keysym(m_keySymbols, keycode, 0); - if (!isModifierKey(keysym)) { - if (pressed) - m_state.notifyNonModifierActivity(); - return; - } - - if (pressed) { - m_state.press(keycode); - return; - } - - if (m_state.release(keycode)) - emit modifierKeyReleased(keysym); -} - -bool ModifierKeyMonitor::isModifierKey(xcb_keysym_t keysym) const -{ - return keysym == XK_Super_L || keysym == XK_Super_R - || keysym == XK_Meta_L || keysym == XK_Meta_R - || keysym == XK_Alt_L || keysym == XK_Alt_R - || keysym == XK_Control_L || keysym == XK_Control_R - || keysym == XK_Shift_L || keysym == XK_Shift_R; -} - bool ModifierKeyMonitor::parseModifierKeyEvent(const xcb_generic_event_t *event, - bool &pressed, xcb_keycode_t &keycode) const + bool &pressed, + xcb_keycode_t &keycode) const { const uint8_t responseType = event->response_type & ~0x80; if (responseType != XCB_GE_GENERIC) @@ -281,8 +413,10 @@ bool ModifierKeyMonitor::parseModifierKeyEvent(const xcb_generic_event_t *event, const auto *keyEvent = reinterpret_cast(event); keycode = xcb_keycode_t(keyEvent->detail); - if (!isModifierKey(xcb_key_symbols_get_keysym(m_keySymbols, keycode, 0))) + if (!m_keySymbols + || !isModifierKey(xcb_key_symbols_get_keysym(m_keySymbols, keycode, 0))) { return false; + } pressed = genericEvent->event_type == XCB_INPUT_RAW_KEY_PRESS; return true; @@ -295,7 +429,8 @@ std::optional> ModifierKeyMonitor::queryPressedModifiers() const xcb_generic_error_t *error = nullptr; const xcb_query_keymap_cookie_t cookie = xcb_query_keymap(m_eventConnection); - xcb_query_keymap_reply_t *reply = xcb_query_keymap_reply(m_eventConnection, cookie, &error); + xcb_query_keymap_reply_t *reply = + xcb_query_keymap_reply(m_eventConnection, cookie, &error); if (!reply || error) { free(reply); free(error); @@ -305,9 +440,238 @@ std::optional> ModifierKeyMonitor::queryPressedModifiers() const QSet pressedModifiers; for (int keycode = 8; keycode < 256; ++keycode) { const bool pressed = reply->keys[keycode / 8] & (1U << (keycode % 8)); - if (pressed && isModifierKey(xcb_key_symbols_get_keysym(m_keySymbols, keycode, 0))) + if (pressed && m_keySymbols + && isModifierKey(xcb_key_symbols_get_keysym(m_keySymbols, keycode, 0))) { pressedModifiers.insert(quint8(keycode)); + } } free(reply); return pressedModifiers; } + +void ModifierKeyMonitor::handleRawKey(bool pressed, xcb_keycode_t keycode) +{ + if (!m_keySymbols) + return; + const xcb_keysym_t keysym = xcb_key_symbols_get_keysym(m_keySymbols, keycode, 0); + if (!isModifierKey(keysym)) { + if (pressed) + m_state.notifyNonModifierActivity(); + return; + } + + if (pressed) { + m_state.press(keycode); + } else if (m_state.release(keycode)) { + emit modifierKeyReleased(keysym); + } +} + +bool ModifierKeyMonitor::createContext() +{ + XRecordRange *range = XRecordAllocRange(); + if (!range) { + qCWarning(logShortcut) << "ModifierMonitor: failed to allocate RECORD range"; + return false; + } + range->device_events.first = KeyPress; + range->device_events.last = ButtonRelease; + + XRecordClientSpec clients[] = {XRecordAllClients}; + m_recordContext = XRecordCreateContext(m_controlDisplay, 0, clients, 1, &range, 1); + XFree(range); + if (!m_recordContext) { + qCWarning(logShortcut) << "ModifierMonitor: failed to create RECORD context"; + return false; + } + // The context is created on the control connection and enabled on the + // data connection. Ensure the server has processed the create request + // before the second connection references that XID. + XSync(m_controlDisplay, False); + + return true; +} + +void ModifierKeyMonitor::freeContext() +{ + if (!m_recordContext || !m_controlDisplay) + return; + XRecordFreeContext(m_controlDisplay, m_recordContext); + XSync(m_controlDisplay, False); + m_recordContext = 0; +} + +bool ModifierKeyMonitor::openDataConnection() +{ + closeDataConnection(); + m_dataDisplay = XOpenDisplay(nullptr); + if (!m_dataDisplay) { + qCWarning(logShortcut) << "ModifierMonitor: failed to open RECORD data connection"; + return false; + } + + m_notifier = new QSocketNotifier(ConnectionNumber(m_dataDisplay), + QSocketNotifier::Read, this); + connect(m_notifier, &QSocketNotifier::activated, + this, &ModifierKeyMonitor::handleEvents); + return true; +} + +void ModifierKeyMonitor::closeDataConnection() +{ + if (m_notifier) { + m_notifier->setEnabled(false); + delete m_notifier; + m_notifier = nullptr; + } + if (m_dataDisplay) { + XCloseDisplay(m_dataDisplay); + m_dataDisplay = nullptr; + } +} + +void ModifierKeyMonitor::enableContext() +{ + if (!m_available || !m_startRequested || !m_recordContext + || m_recordState != RecordState::Disabled) { + return; + } + + if (!openDataConnection()) { + fallbackToXInput2(); + return; + } + + // The callback can be invoked synchronously by EnableContextAsync, so the + // state must be visible before entering Xlib. + m_recordState = RecordState::Enabling; + if (!XRecordEnableContextAsync(m_dataDisplay, m_recordContext, + [](XPointer closure, XRecordInterceptData *data) { + auto *monitor = reinterpret_cast(closure); + if (monitor) + monitor->handleRecordedData(data); + else + XRecordFreeData(data); + }, + reinterpret_cast(this))) { + qCWarning(logShortcut) << "ModifierMonitor: failed to enable RECORD context"; + m_recordState = RecordState::Disabled; + fallbackToXInput2(); + return; + } + + m_notifier->setEnabled(true); + XFlush(m_dataDisplay); + // EnableContextAsync may already have pulled StartOfData into Xlib's + // userspace buffer. Process it now because the fd would no longer become + // readable and therefore would not wake the socket notifier. + XRecordProcessReplies(m_dataDisplay); +} + +void ModifierKeyMonitor::disableContext() +{ + if (!m_controlDisplay || !m_recordContext + || m_recordState != RecordState::Enabled) { + return; + } + + m_running = false; + m_recordState = RecordState::Disabling; + if (!XRecordDisableContext(m_controlDisplay, m_recordContext)) { + qCWarning(logShortcut) << "ModifierMonitor: failed to disable RECORD context"; + fallbackToXInput2(); + return; + } + XFlush(m_controlDisplay); +} + +void ModifierKeyMonitor::handleEvents() +{ + if (m_transport == Transport::XInput2) { + handleRawEvents(); + return; + } + if (m_recordState != RecordState::Disabled && m_dataDisplay) + XRecordProcessReplies(m_dataDisplay); +} + +void ModifierKeyMonitor::handleRecordedData(void *data) +{ + auto *recordedData = static_cast(data); + if (!recordedData) + return; + + if (recordedData->category == XRecordStartOfData) { + if (m_recordState == RecordState::Enabling) + m_recordState = RecordState::Enabled; + if (m_startRequested) { + m_running = true; + } else { + QMetaObject::invokeMethod(this, &ModifierKeyMonitor::disableContext, + Qt::QueuedConnection); + } + } else if (recordedData->category == XRecordEndOfData) { + m_running = false; + m_recordState = RecordState::Disabled; + m_state.reset(); + // The data connection belongs to exactly one RECORD epoch. Recreate + // it before the next enable so delayed bytes from a stopped capture + // can never be interpreted as live input in the next epoch. + QMetaObject::invokeMethod(this, [this] { + closeDataConnection(); + freeContext(); + if (!createContext()) { + fallbackToXInput2(); + return; + } + if (m_startRequested) + enableContext(); + }, Qt::QueuedConnection); + } else if (m_running && m_recordState == RecordState::Enabled + && recordedData->category == XRecordFromServer + && !recordedData->client_swapped) { + const unsigned long byteCount = recordedData->data_len * 4; + for (unsigned long offset = 0; offset + 32 <= byteCount; offset += 32) { + const auto *event = reinterpret_cast( + recordedData->data + offset); + const uint8_t type = event->response_type & 0x7f; + if (type == XCB_KEY_PRESS || type == XCB_KEY_RELEASE) { + const auto *keyEvent = reinterpret_cast(event); + handleKey(type == XCB_KEY_PRESS, keyEvent->detail, + keyEvent->state, keyEvent->time); + } else if (type == XCB_BUTTON_PRESS) { + m_state.notifyNonModifierActivity(); + } + } + } + XRecordFreeData(recordedData); +} + +void ModifierKeyMonitor::handleKey(bool pressed, xcb_keycode_t keycode, + uint16_t state, uint32_t time) +{ + if (!m_keySymbols) + return; + + const xcb_keysym_t keysym = xcb_key_symbols_get_keysym(m_keySymbols, keycode, 0); + if (!isModifierKey(keysym)) { + if (pressed) + m_state.notifyNonModifierActivity(); + } else if (pressed) { + m_state.press(keycode); + } else if (m_state.release(keycode)) { + emit modifierKeyReleased(keysym); + } + + emit keyEventRecorded(pressed, quint8(keycode), quint16(state), quint32(time)); +} + +bool ModifierKeyMonitor::isModifierKey(xcb_keysym_t keysym) const +{ + return keysym == XK_Super_L || keysym == XK_Super_R + || keysym == XK_Meta_L || keysym == XK_Meta_R + || keysym == XK_Alt_L || keysym == XK_Alt_R + || keysym == XK_Control_L || keysym == XK_Control_R + || keysym == XK_Shift_L || keysym == XK_Shift_R + || keysym == XK_Caps_Lock || keysym == XK_Num_Lock; +} diff --git a/src/plugin-qt/shortcut/src/backend/x11/modifierkeymonitor.h b/src/plugin-qt/shortcut/src/backend/x11/modifierkeymonitor.h index bdda21bb..5d85501b 100644 --- a/src/plugin-qt/shortcut/src/backend/x11/modifierkeymonitor.h +++ b/src/plugin-qt/shortcut/src/backend/x11/modifierkeymonitor.h @@ -15,43 +15,78 @@ #include #include +typedef struct _XDisplay Display; + class ModifierKeyMonitor : public QObject { Q_OBJECT public: - explicit ModifierKeyMonitor(QObject *parent = nullptr); + explicit ModifierKeyMonitor(QObject *parent = nullptr, bool allowRecord = true); ~ModifierKeyMonitor() override; void start(); bool isAvailable() const; + bool isRunning() const; + bool supportsGrabResilientEvents() const; void stop(); + void refreshKeyboardMapping(); void notifyNonModifierKeyPressed(); signals: - /** - * @brief 单独修饰键被释放 - * @param keysym X11 keysym (如 XK_Super_L, XK_Alt_L等) - */ void modifierKeyReleased(unsigned long keysym); + void keyEventRecorded(bool pressed, quint8 keycode, quint16 state, quint32 time); + private slots: void handleEvents(); private: + enum class RecordState { + Disabled, + Enabling, + Enabled, + Disabling, + }; + + enum class Transport { + Unavailable, + Record, + XInput2, + }; + + bool initializeRecord(); bool initializeRawEvents(); + void fallbackToXInput2(); + void cleanupRecordResources(); void discardPendingInputEvents(); - bool isModifierKey(xcb_keysym_t keysym) const; + bool createContext(); + void freeContext(); + bool openDataConnection(); + void closeDataConnection(); + void enableContext(); + void disableContext(); + void handleRecordedData(void *recordedData); + void handleRawEvents(); + void handleRawEvent(xcb_generic_event_t *event); bool parseModifierKeyEvent(const xcb_generic_event_t *event, bool &pressed, xcb_keycode_t &keycode) const; std::optional> queryPressedModifiers() const; - void handleEvent(xcb_generic_event_t *event); void handleRawKey(bool pressed, xcb_keycode_t keycode); + bool isModifierKey(xcb_keysym_t keysym) const; + void handleKey(bool pressed, xcb_keycode_t keycode, uint16_t state, uint32_t time); - xcb_connection_t *m_eventConnection = nullptr; - xcb_screen_t *m_screen = nullptr; - QList m_rootWindows; + Display *m_controlDisplay = nullptr; + Display *m_dataDisplay = nullptr; + xcb_connection_t *m_keyConnection = nullptr; xcb_key_symbols_t *m_keySymbols = nullptr; QSocketNotifier *m_notifier = nullptr; + unsigned long m_recordContext = 0; + xcb_connection_t *m_eventConnection = nullptr; + QList m_rootWindows; uint8_t m_inputOpcode = 0; ModifierKeyState m_state; + Transport m_transport = Transport::Unavailable; + RecordState m_recordState = RecordState::Disabled; bool m_available = false; + bool m_startRequested = false; + bool m_running = false; }; diff --git a/src/plugin-qt/shortcut/src/backend/x11/x11keyhandler.cpp b/src/plugin-qt/shortcut/src/backend/x11/x11keyhandler.cpp index 45d45e0f..5997638a 100644 --- a/src/plugin-qt/shortcut/src/backend/x11/x11keyhandler.cpp +++ b/src/plugin-qt/shortcut/src/backend/x11/x11keyhandler.cpp @@ -6,6 +6,7 @@ #include "x11keyhandler.h" #include "modifierkeymonitor.h" +#include "x11shortcutpolicy.h" #include "core/triggeractioncatalog.h" #include "core/qkeysequenceconverter.h" #include "core/physicalkeyalias.h" @@ -40,7 +41,9 @@ enum class LogicalModifier { Shift, Control, Alt, - Meta + Meta, + CapsLock, + NumLock }; LogicalModifier logicalModifier(xcb_keysym_t keysym) @@ -55,6 +58,10 @@ LogicalModifier logicalModifier(xcb_keysym_t keysym) || keysym == XK_Meta_L || keysym == XK_Meta_R) { return LogicalModifier::Meta; } + if (keysym == XK_Caps_Lock) + return LogicalModifier::CapsLock; + if (keysym == XK_Num_Lock) + return LogicalModifier::NumLock; return LogicalModifier::Unknown; } @@ -69,6 +76,10 @@ QList modifierKeysyms(LogicalModifier modifier) return {XK_Alt_L, XK_Alt_R}; case LogicalModifier::Meta: return {XK_Super_L, XK_Super_R, XK_Meta_L, XK_Meta_R}; + case LogicalModifier::CapsLock: + return {XK_Caps_Lock}; + case LogicalModifier::NumLock: + return {XK_Num_Lock}; case LogicalModifier::Unknown: return {}; } @@ -161,8 +172,16 @@ X11KeyHandler::X11KeyHandler(QObject *parent) m_modifierMonitor = new ModifierKeyMonitor(this); connect(m_modifierMonitor, &ModifierKeyMonitor::modifierKeyReleased, this, &X11KeyHandler::onModifierKeyReleased); + connect(m_modifierMonitor, &ModifierKeyMonitor::keyEventRecorded, + this, &X11KeyHandler::onRecordedKeyEvent); m_modifierMonitor->start(); + m_recordReleaseTimer = new QTimer(this); + m_recordReleaseTimer->setSingleShot(true); + m_recordReleaseTimer->setInterval(0); + connect(m_recordReleaseTimer, &QTimer::timeout, + this, &X11KeyHandler::flushRecordedPendingReleases); + // Setup XCB event monitoring int fd = xcb_get_file_descriptor(m_connection); m_notifier = new QSocketNotifier(fd, QSocketNotifier::Read, this); @@ -233,6 +252,10 @@ bool X11KeyHandler::beginCapture(uint timeoutMs, const QString &owner) m_pendingReleases.clear(); m_pressedBindings.clear(); + m_xcbObservedPresses.clear(); + m_recordPendingReleases.clear(); + m_recordPressedBindings.clear(); + m_recordObservedPresses.clear(); m_capture.keystroke.clear(); m_capture.owner = owner; m_capture.active = true; @@ -403,6 +426,11 @@ bool X11KeyHandler::registerKey(const KeyConfig &config) if (!grabbed.isEmpty()) { m_shortcutKeys.insert(config.getId(), grabbed); m_shortcutFlags.insert(config.getId(), config.keyEventFlags); + if (X11ShortcutPolicy::isLegacyGrabResilientShortcut(config.getId()) + && m_modifierMonitor + && m_modifierMonitor->supportsGrabResilientEvents()) { + m_recordShortcutIds.insert(config.getId()); + } } return allSuccess; @@ -423,6 +451,7 @@ bool X11KeyHandler::unregisterKey(const QString &shortcutId) QList keys = m_shortcutKeys.take(shortcutId); m_shortcutFlags.remove(shortcutId); + m_recordShortcutIds.remove(shortcutId); for (uint32_t key : keys) { const xcb_keycode_t keycode = key & 0xFFFF; @@ -430,8 +459,11 @@ bool X11KeyHandler::unregisterKey(const QString &shortcutId) if (!m_standaloneModifierKeys.remove(key)) ungrabKey(keycode, mods); m_grabbedKeys.remove(key); + m_xcbObservedPresses.remove(keycode); + m_recordObservedPresses.remove(keycode); } clearPressedState(shortcutId); + clearRecordedPressedState(shortcutId); return true; } @@ -644,6 +676,8 @@ void X11KeyHandler::handleXcbEvents() emit keymapAboutToChange(); if (mappingEvent->request == XCB_MAPPING_KEYBOARD) xcb_refresh_keyboard_mapping(m_keySymbols, mappingEvent); + if (m_modifierMonitor) + m_modifierMonitor->refreshKeyboardMapping(); refreshModifierMasks(); if (!m_capture.active) scheduleKeymapChanged(); @@ -887,6 +921,17 @@ void X11KeyHandler::handleKeyPress(const xcb_key_press_event_t *event) return; const QString shortcutId = bindingIt.value(); + if (m_recordShortcutIds.contains(shortcutId)) { + // Both streams can report the same event when no active grab exists. + // Whichever stream observes the press first owns the whole sequence. + // Do not infer ownership from isRunning(): the RECORD state may have + // changed after this XCB event was generated. + if (m_recordPressedBindings.contains(event->detail) + || m_recordObservedPresses.value(event->detail) == event->time) { + return; + } + m_xcbObservedPresses.insert(event->detail, event->time); + } m_pressedBindings.insert(event->detail, shortcutId); activate(shortcutId, KeyEventFlag::Press); } @@ -943,6 +988,18 @@ void X11KeyHandler::clearPressedState(const QString &shortcutId) } } +void X11KeyHandler::clearRecordedPressedState(const QString &shortcutId) +{ + for (auto it = m_recordPressedBindings.begin(); it != m_recordPressedBindings.end();) { + if (it.value() == shortcutId) { + m_recordPendingReleases.remove(it.key()); + it = m_recordPressedBindings.erase(it); + } else { + ++it; + } + } +} + void X11KeyHandler::notifyKeymapChanged() { m_keymapChangePending = false; @@ -1062,6 +1119,73 @@ void X11KeyHandler::onModifierKeyReleased(unsigned long keysym) activate(shortcutId, KeyEventFlag::Release); } +void X11KeyHandler::onRecordedKeyEvent(bool pressed, quint8 keycode, + quint16 state, quint32 time) +{ + const xcb_keycode_t code = xcb_keycode_t(keycode); + if (pressed) { + // XCB may have claimed this sequence while RECORD was restarting. + // Keep press/repeat/release on that channel to prevent duplicates. + if (m_pressedBindings.contains(code) + || m_xcbObservedPresses.value(code) == time) { + return; + } + + const auto pending = m_recordPendingReleases.constFind(code); + if (pending != m_recordPendingReleases.constEnd() + && pending.value() == time + && m_recordPressedBindings.contains(code)) { + m_recordPendingReleases.erase(pending); + activate(m_recordPressedBindings.value(code), KeyEventFlag::Repeat); + return; + } + + flushRecordedPendingReleases(); + if (m_recordPressedBindings.contains(code)) { + activate(m_recordPressedBindings.value(code), KeyEventFlag::Repeat); + return; + } + + const uint16_t modifiers = getConcernedMods(state); + const uint32_t bindingKey = code | (uint32_t(modifiers) << 16); + const auto bindingIt = m_grabbedKeys.constFind(bindingKey); + if (bindingIt == m_grabbedKeys.constEnd() + || !m_recordShortcutIds.contains(bindingIt.value())) { + return; + } + + const QString shortcutId = bindingIt.value(); + m_recordObservedPresses.insert(code, time); + m_recordPressedBindings.insert(code, shortcutId); + activate(shortcutId, KeyEventFlag::Press); + return; + } + + if (!m_recordPressedBindings.contains(code)) + return; + if (m_recordPendingReleases.contains(code)) + flushRecordedPendingReleases(); + m_recordPendingReleases.insert(code, time); + m_recordReleaseTimer->start(); +} + +void X11KeyHandler::flushRecordedPendingReleases() +{ + if (m_recordPendingReleases.isEmpty()) + return; + + const QList keycodes = m_recordPendingReleases.keys(); + m_recordPendingReleases.clear(); + for (xcb_keycode_t keycode : keycodes) { + const auto bindingIt = m_recordPressedBindings.find(keycode); + if (bindingIt == m_recordPressedBindings.end()) + continue; + const QString shortcutId = bindingIt.value(); + m_recordPressedBindings.erase(bindingIt); + activate(shortcutId, KeyEventFlag::Release); + } +} + // ==================== Helper Functions ==================== bool X11KeyHandler::isStandaloneModifierKey(xcb_keysym_t keysym, uint16_t mods) const { diff --git a/src/plugin-qt/shortcut/src/backend/x11/x11keyhandler.h b/src/plugin-qt/shortcut/src/backend/x11/x11keyhandler.h index 28796b41..b7370a5c 100644 --- a/src/plugin-qt/shortcut/src/backend/x11/x11keyhandler.h +++ b/src/plugin-qt/shortcut/src/backend/x11/x11keyhandler.h @@ -41,7 +41,9 @@ class X11KeyHandler : public AbstractKeyHandler private slots: void handleXcbEvents(); void onModifierKeyReleased(unsigned long keysym); + void onRecordedKeyEvent(bool pressed, quint8 keycode, quint16 state, quint32 time); void flushPendingReleases(); + void flushRecordedPendingReleases(); void notifyKeymapChanged(); private: @@ -92,6 +94,7 @@ private slots: bool hasAnyMask(uint16_t state, const QList &masks) const; void activate(const QString &shortcutId, int eventFlag); void clearPressedState(const QString &shortcutId); + void clearRecordedPressedState(const QString &shortcutId); void finishCapture(bool notify = true); void scheduleKeymapChanged(); @@ -119,9 +122,18 @@ private slots: // Press, release, and autorepeat tracking. QMap m_pressedBindings; QMap m_pendingReleases; + QMap m_xcbObservedPresses; QTimer *m_releaseTimer = nullptr; bool m_detectableAutoRepeat = false; + // X RECORD is the compatibility path for shortcuts that historically + // remained active while another client held an active keyboard grab. + QSet m_recordShortcutIds; + QMap m_recordPressedBindings; + QMap m_recordPendingReleases; + QMap m_recordObservedPresses; + QTimer *m_recordReleaseTimer = nullptr; + // Interactive shortcut capture session. struct CaptureState { QTimer *timer = nullptr; diff --git a/src/plugin-qt/shortcut/src/backend/x11/x11shortcutpolicy.cpp b/src/plugin-qt/shortcut/src/backend/x11/x11shortcutpolicy.cpp new file mode 100644 index 00000000..7826a9ad --- /dev/null +++ b/src/plugin-qt/shortcut/src/backend/x11/x11shortcutpolicy.cpp @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include "x11shortcutpolicy.h" + +#include + +namespace X11ShortcutPolicy { + +bool isLegacyGrabResilientShortcut(const QString &shortcutId) +{ + static const QSet ids{ + QStringLiteral("org.deepin.dde.keybinding.shortcut.app.screenshot"), + QStringLiteral("org.deepin.dde.keybinding.shortcut.app.fullscreen-screenshot"), + QStringLiteral("org.deepin.dde.keybinding.shortcut.app.window-screenshot"), + QStringLiteral("org.deepin.dde.keybinding.shortcut.app.delay-screenshot"), + QStringLiteral("org.deepin.dde.keybinding.shortcut.app.screenshot-ocr"), + QStringLiteral("org.deepin.dde.keybinding.shortcut.app.scroll-screenshot"), + QStringLiteral("org.deepin.dde.keybinding.shortcut.app.screen-recorder"), + }; + return ids.contains(shortcutId); +} + +} diff --git a/src/plugin-qt/shortcut/src/backend/x11/x11shortcutpolicy.h b/src/plugin-qt/shortcut/src/backend/x11/x11shortcutpolicy.h new file mode 100644 index 00000000..2959210f --- /dev/null +++ b/src/plugin-qt/shortcut/src/backend/x11/x11shortcutpolicy.h @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#pragma once + +#include + +namespace X11ShortcutPolicy { + +// Identifies legacy combination shortcuts that use RECORD to remain available +// while another X11 client owns an active keyboard grab. Standalone modifier +// shortcuts (for example Super/Meta, CapsLock, and NumLock) are intentionally +// absent: ModifierKeyMonitor handles them directly. All other combinations use +// the normal XGrabKey path and are not grab-resilient. +bool isLegacyGrabResilientShortcut(const QString &shortcutId); + +} diff --git a/src/plugin-qt/shortcut/tests/CMakeLists.txt b/src/plugin-qt/shortcut/tests/CMakeLists.txt index a870041f..1b3c1380 100644 --- a/src/plugin-qt/shortcut/tests/CMakeLists.txt +++ b/src/plugin-qt/shortcut/tests/CMakeLists.txt @@ -105,3 +105,76 @@ target_link_libraries(tst-modifierkeystate PRIVATE ) add_test(NAME shortcut-modifierkeystate COMMAND tst-modifierkeystate) + +add_executable(tst-x11shortcutpolicy + tst_x11shortcutpolicy.cpp + ../src/backend/x11/x11shortcutpolicy.cpp +) + +target_include_directories(tst-x11shortcutpolicy PRIVATE + ../src +) + +target_link_libraries(tst-x11shortcutpolicy PRIVATE + Qt6::Core + Qt6::Test +) + +add_test(NAME shortcut-x11shortcutpolicy COMMAND tst-x11shortcutpolicy) + +add_executable(tst-x11recordmonitor + tst_x11recordmonitor.cpp + ../shortcutlogging.cpp + ../src/backend/x11/modifierkeystate.cpp + ../src/backend/x11/modifierkeymonitor.cpp +) + +target_include_directories(tst-x11recordmonitor PRIVATE + .. + ../src + ${XCB_INCLUDE_DIRS} + ${XCB_XINPUT_INCLUDE_DIRS} + ${X11_INCLUDE_DIRS} + ${X11_XCB_INCLUDE_DIRS} + ${XTST_INCLUDE_DIRS} +) + +target_link_libraries(tst-x11recordmonitor PRIVATE + Qt6::Core + Qt6::Test + ${XCB_LIBRARIES} + ${XCB_XINPUT_LIBRARIES} + ${X11_LIBRARIES} + ${X11_XCB_LIBRARIES} + ${XTST_LIBRARIES} +) + +add_test(NAME shortcut-x11recordmonitor COMMAND tst-x11recordmonitor) + +add_executable(tst-x11grabresilientshortcuts + tst_x11grabresilientshortcuts.cpp +) + +target_include_directories(tst-x11grabresilientshortcuts PRIVATE + ../src + ${X11_INCLUDE_DIRS} + ${XTST_INCLUDE_DIRS} +) + +target_link_libraries(tst-x11grabresilientshortcuts PRIVATE + Qt6::Core + Qt6::Test + plugin-dde-shortcut + ${X11_LIBRARIES} + ${XTST_LIBRARIES} +) + +add_test(NAME shortcut-x11grabresilientshortcuts COMMAND tst-x11grabresilientshortcuts) + +# Both integration tests take an active X11 keyboard grab. Keep them mutually +# exclusive when dh_auto_test runs CTest in parallel. +set_tests_properties( + shortcut-x11recordmonitor + shortcut-x11grabresilientshortcuts + PROPERTIES RESOURCE_LOCK x11_keyboard_grab +) diff --git a/src/plugin-qt/shortcut/tests/tst_x11grabresilientshortcuts.cpp b/src/plugin-qt/shortcut/tests/tst_x11grabresilientshortcuts.cpp new file mode 100644 index 00000000..8879757f --- /dev/null +++ b/src/plugin-qt/shortcut/tests/tst_x11grabresilientshortcuts.cpp @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include "backend/x11/x11keyhandler.h" + +#include +#include + +#include +#include +#include + +namespace { + +void sendKey(Display *display, KeySym keysym, bool pressed) +{ + const KeyCode keycode = XKeysymToKeycode(display, keysym); + QVERIFY(keycode != 0); + XTestFakeKeyEvent(display, keycode, pressed ? True : False, CurrentTime); +} + +void sendTestChord(Display *display) +{ + sendKey(display, XK_Control_L, true); + sendKey(display, XK_Alt_L, true); + sendKey(display, XK_B, true); + sendKey(display, XK_B, false); + sendKey(display, XK_Alt_L, false); + sendKey(display, XK_Control_L, false); + XFlush(display); +} + +KeyConfig shortcut(const QString &id, const QString &hotkey) +{ + KeyConfig config; + config.subPath = id; + config.appId = QStringLiteral("org.deepin.dde.keybinding"); + config.displayName = id; + config.enabled = true; + config.modifiable = false; + config.triggerType = static_cast(TriggerType::Command); + config.hotkeys = {hotkey}; + config.keyEventFlags = KeyEventFlag::Release; + return config; +} + +} + +class TestX11GrabResilientShortcuts : public QObject +{ + Q_OBJECT + +private slots: + void legacyShortcutsActivateExactlyOnce(); + void xcbFallbackDuringRecordRestart(); +}; + +void TestX11GrabResilientShortcuts::legacyShortcutsActivateExactlyOnce() +{ + Display *display = XOpenDisplay(nullptr); + if (!display) + QSKIP("No X server is available"); + + X11KeyHandler handler; + if (!handler.isAvailable()) { + XCloseDisplay(display); + QSKIP("X11 shortcut backend is unavailable"); + } + + const QString prefix = QStringLiteral("org.deepin.dde.keybinding.shortcut.app."); + const QString screenshotId = prefix + QStringLiteral("screenshot"); + const QString launcherId = prefix + QStringLiteral("launcher"); + QVERIFY(handler.registerKey(shortcut(screenshotId, QStringLiteral("Ctrl+Alt+B")))); + QVERIFY(handler.registerKey(shortcut(launcherId, QStringLiteral("Meta")))); + + QSignalSpy activationSpy(&handler, &X11KeyHandler::keyActivated); + QVERIFY(activationSpy.isValid()); + + const Window root = DefaultRootWindow(display); + QCOMPARE(XGrabKeyboard(display, root, False, GrabModeAsync, GrabModeAsync, CurrentTime), + GrabSuccess); + + sendTestChord(display); + QTRY_COMPARE_WITH_TIMEOUT(activationSpy.size(), 1, 2000); + QCOMPARE(activationSpy.at(0).at(0).toString(), screenshotId); + + sendKey(display, XK_Super_L, true); + sendKey(display, XK_Super_L, false); + XFlush(display); + QTRY_COMPARE_WITH_TIMEOUT(activationSpy.size(), 2, 2000); + QCOMPARE(activationSpy.at(1).at(0).toString(), launcherId); + + XUngrabKeyboard(display, CurrentTime); + XSync(display, False); + + sendTestChord(display); + QTRY_COMPARE_WITH_TIMEOUT(activationSpy.size(), 3, 2000); + QCOMPARE(activationSpy.at(2).at(0).toString(), screenshotId); + + handler.unregisterKey(screenshotId); + handler.unregisterKey(launcherId); + XCloseDisplay(display); +} + +void TestX11GrabResilientShortcuts::xcbFallbackDuringRecordRestart() +{ + Display *display = XOpenDisplay(nullptr); + if (!display) + QSKIP("No X server is available"); + + X11KeyHandler handler; + if (!handler.isAvailable()) { + XCloseDisplay(display); + QSKIP("X11 shortcut backend is unavailable"); + } + + const QString screenshotId = QStringLiteral( + "org.deepin.dde.keybinding.shortcut.app.screenshot"); + QVERIFY(handler.registerKey(shortcut(screenshotId, QStringLiteral("Ctrl+Alt+B")))); + QSignalSpy activationSpy(&handler, &X11KeyHandler::keyActivated); + QVERIFY(activationSpy.isValid()); + + // Ending capture restarts RECORD asynchronously. A shortcut pressed in + // that interval must fall back to XCB and still activate exactly once. + QVERIFY(handler.beginCapture(30000, QStringLiteral("test-owner"))); + QVERIFY(handler.endCapture(QStringLiteral("test-owner"))); + sendTestChord(display); + QTRY_COMPARE_WITH_TIMEOUT(activationSpy.size(), 1, 2000); + QCOMPARE(activationSpy.constFirst().constFirst().toString(), screenshotId); + QTest::qWait(100); + QCOMPARE(activationSpy.size(), 1); + + handler.unregisterKey(screenshotId); + XCloseDisplay(display); +} + +QTEST_MAIN(TestX11GrabResilientShortcuts) + +#include "tst_x11grabresilientshortcuts.moc" diff --git a/src/plugin-qt/shortcut/tests/tst_x11recordmonitor.cpp b/src/plugin-qt/shortcut/tests/tst_x11recordmonitor.cpp new file mode 100644 index 00000000..d0b6f828 --- /dev/null +++ b/src/plugin-qt/shortcut/tests/tst_x11recordmonitor.cpp @@ -0,0 +1,200 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include "backend/x11/modifierkeymonitor.h" + +#include +#include +#include + +#include +#include +#include + +class TestX11RecordMonitor : public QObject +{ + Q_OBJECT + +private slots: + void recordsKeysDuringActiveGrab(); + void standaloneModifierTransportIsAvailable(); + void keymapRefreshDoesNotUseRecordDataConnection(); + void captureBoundaryDiscardsStoppedEvents(); + void xi2FallbackHandlesStandaloneModifier(); +}; + +namespace { + +void sendKey(Display *display, KeyCode keycode, bool pressed) +{ + XTestFakeKeyEvent(display, keycode, pressed ? True : False, CurrentTime); +} + +} + +void TestX11RecordMonitor::recordsKeysDuringActiveGrab() +{ + Display *display = XOpenDisplay(nullptr); + if (!display) + QSKIP("No X server is available"); + + ModifierKeyMonitor monitor; + if (!monitor.isAvailable()) { + XCloseDisplay(display); + QSKIP("X RECORD extension is unavailable"); + } + if (!monitor.supportsGrabResilientEvents()) { + XCloseDisplay(display); + QSKIP("X RECORD extension is unavailable"); + } + monitor.start(); + QTRY_VERIFY_WITH_TIMEOUT(monitor.isRunning(), 2000); + + QSignalSpy recordedSpy(&monitor, &ModifierKeyMonitor::keyEventRecorded); + QSignalSpy modifierSpy(&monitor, &ModifierKeyMonitor::modifierKeyReleased); + QVERIFY(recordedSpy.isValid()); + QVERIFY(modifierSpy.isValid()); + + const Window root = DefaultRootWindow(display); + QCOMPARE(XGrabKeyboard(display, root, False, GrabModeAsync, GrabModeAsync, CurrentTime), + GrabSuccess); + + const KeyCode super = XKeysymToKeycode(display, XK_Super_L); + const KeyCode control = XKeysymToKeycode(display, XK_Control_L); + const KeyCode alt = XKeysymToKeycode(display, XK_Alt_L); + const KeyCode a = XKeysymToKeycode(display, XK_A); + QVERIFY(super != 0); + QVERIFY(control != 0); + QVERIFY(alt != 0); + QVERIFY(a != 0); + + XTestFakeKeyEvent(display, super, True, CurrentTime); + XTestFakeKeyEvent(display, super, False, CurrentTime); + XTestFakeKeyEvent(display, control, True, CurrentTime); + XTestFakeKeyEvent(display, alt, True, CurrentTime); + XTestFakeKeyEvent(display, a, True, CurrentTime); + XTestFakeKeyEvent(display, a, False, CurrentTime); + XTestFakeKeyEvent(display, alt, False, CurrentTime); + XTestFakeKeyEvent(display, control, False, CurrentTime); + XFlush(display); + + QTRY_VERIFY2_WITH_TIMEOUT(recordedSpy.size() >= 8, + qPrintable(QStringLiteral("recorded events: %1").arg(recordedSpy.size())), + 2000); + QTRY_COMPARE_WITH_TIMEOUT(modifierSpy.size(), 1, 2000); + + XUngrabKeyboard(display, CurrentTime); + XCloseDisplay(display); +} + +void TestX11RecordMonitor::standaloneModifierTransportIsAvailable() +{ + Display *display = XOpenDisplay(nullptr); + if (!display) + QSKIP("No X server is available"); + + ModifierKeyMonitor monitor; + QVERIFY2(monitor.isAvailable(), "Neither X RECORD nor XInput2 is available"); + monitor.start(); + QTRY_VERIFY_WITH_TIMEOUT(monitor.isRunning(), 2000); + + QSignalSpy modifierSpy(&monitor, &ModifierKeyMonitor::modifierKeyReleased); + QVERIFY(modifierSpy.isValid()); + const KeyCode super = XKeysymToKeycode(display, XK_Super_L); + QVERIFY(super != 0); + sendKey(display, super, true); + sendKey(display, super, false); + XSync(display, False); + QTRY_COMPARE_WITH_TIMEOUT(modifierSpy.size(), 1, 2000); + + XCloseDisplay(display); +} + +void TestX11RecordMonitor::xi2FallbackHandlesStandaloneModifier() +{ + Display *display = XOpenDisplay(nullptr); + if (!display) + QSKIP("No X server is available"); + + ModifierKeyMonitor monitor(nullptr, false); + QVERIFY2(monitor.isAvailable(), "XInput2 fallback is unavailable"); + QVERIFY(!monitor.supportsGrabResilientEvents()); + monitor.start(); + QVERIFY(monitor.isRunning()); + + QSignalSpy modifierSpy(&monitor, &ModifierKeyMonitor::modifierKeyReleased); + QVERIFY(modifierSpy.isValid()); + const KeyCode super = XKeysymToKeycode(display, XK_Super_L); + QVERIFY(super != 0); + sendKey(display, super, true); + sendKey(display, super, false); + XSync(display, False); + QTRY_COMPARE_WITH_TIMEOUT(modifierSpy.size(), 1, 2000); + + XCloseDisplay(display); +} + +void TestX11RecordMonitor::keymapRefreshDoesNotUseRecordDataConnection() +{ + ModifierKeyMonitor monitor; + if (!monitor.isAvailable()) + QSKIP("X RECORD extension is unavailable"); + if (!monitor.supportsGrabResilientEvents()) + QSKIP("X RECORD extension is unavailable"); + monitor.start(); + QTRY_VERIFY_WITH_TIMEOUT(monitor.isRunning(), 2000); + + QElapsedTimer timer; + timer.start(); + monitor.refreshKeyboardMapping(); + QVERIFY2(timer.elapsed() < 1000, + "Keyboard mapping refresh blocked on the RECORD data connection"); +} + +void TestX11RecordMonitor::captureBoundaryDiscardsStoppedEvents() +{ + Display *display = XOpenDisplay(nullptr); + if (!display) + QSKIP("No X server is available"); + + ModifierKeyMonitor monitor; + if (!monitor.isAvailable()) { + XCloseDisplay(display); + QSKIP("X RECORD extension is unavailable"); + } + if (!monitor.supportsGrabResilientEvents()) { + XCloseDisplay(display); + QSKIP("X RECORD extension is unavailable"); + } + monitor.start(); + QTRY_VERIFY_WITH_TIMEOUT(monitor.isRunning(), 2000); + + QSignalSpy recordedSpy(&monitor, &ModifierKeyMonitor::keyEventRecorded); + QVERIFY(recordedSpy.isValid()); + const KeyCode a = XKeysymToKeycode(display, XK_A); + QVERIFY(a != 0); + + monitor.stop(); + sendKey(display, a, true); + sendKey(display, a, false); + XSync(display, False); + + // start() may be called before EndOfData arrives. It must wait for that + // server-side boundary and start a new RECORD stream. + monitor.start(); + QTRY_VERIFY_WITH_TIMEOUT(monitor.isRunning(), 2000); + QTest::qWait(50); + QCOMPARE(recordedSpy.size(), 0); + + sendKey(display, a, true); + sendKey(display, a, false); + XSync(display, False); + QTRY_COMPARE_WITH_TIMEOUT(recordedSpy.size(), 2, 2000); + + XCloseDisplay(display); +} + +QTEST_MAIN(TestX11RecordMonitor) + +#include "tst_x11recordmonitor.moc" diff --git a/src/plugin-qt/shortcut/tests/tst_x11shortcutpolicy.cpp b/src/plugin-qt/shortcut/tests/tst_x11shortcutpolicy.cpp new file mode 100644 index 00000000..03245721 --- /dev/null +++ b/src/plugin-qt/shortcut/tests/tst_x11shortcutpolicy.cpp @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include "backend/x11/x11shortcutpolicy.h" + +#include + +class TestX11ShortcutPolicy : public QObject +{ + Q_OBJECT + +private slots: + void legacyGrabResilientShortcuts_data(); + void legacyGrabResilientShortcuts(); +}; + +void TestX11ShortcutPolicy::legacyGrabResilientShortcuts_data() +{ + QTest::addColumn("shortcutId"); + QTest::addColumn("expected"); + + const QString prefix = QStringLiteral("org.deepin.dde.keybinding.shortcut.app."); + QTest::newRow("screenshot") << prefix + QStringLiteral("screenshot") << true; + QTest::newRow("fullscreen-screenshot") << prefix + QStringLiteral("fullscreen-screenshot") << true; + QTest::newRow("window-screenshot") << prefix + QStringLiteral("window-screenshot") << true; + QTest::newRow("delay-screenshot") << prefix + QStringLiteral("delay-screenshot") << true; + QTest::newRow("screenshot-ocr") << prefix + QStringLiteral("screenshot-ocr") << true; + QTest::newRow("scroll-screenshot") << prefix + QStringLiteral("scroll-screenshot") << true; + QTest::newRow("screen-recorder") << prefix + QStringLiteral("screen-recorder") << true; + QTest::newRow("terminal") << prefix + QStringLiteral("terminal") << false; + QTest::newRow("custom-similar-name") + << QStringLiteral("org.deepin.dde.keybinding.shortcut.custom.screenshot") << false; +} + +void TestX11ShortcutPolicy::legacyGrabResilientShortcuts() +{ + QFETCH(QString, shortcutId); + QFETCH(bool, expected); + QCOMPARE(X11ShortcutPolicy::isLegacyGrabResilientShortcut(shortcutId), expected); +} + +QTEST_MAIN(TestX11ShortcutPolicy) + +#include "tst_x11shortcutpolicy.moc"