From 4a3f332fb30f134ed00a8279d5217ecec041b002 Mon Sep 17 00:00:00 2001 From: zhaoyingzhen Date: Wed, 5 Aug 2026 12:19:47 +0800 Subject: [PATCH] fix: restore grab-resilient X11 shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the legacy X RECORD path for screenshot, screen recorder, and standalone modifier shortcuts while another X11 client owns an active keyboard grab. Keep passive XGrabKey and XI2 fallback paths available, isolate RECORD lifecycle and keymap connections, and deduplicate XCB/RECORD events. Add policy, RECORD lifecycle, fallback, active-grab, and parallel test coverage. 恢复旧版 X RECORD 路径,使截图、录屏和独立修饰键快捷键在其他 X11 客户端持有主动键盘 grab 时仍可使用。 保留被动 XGrabKey 和 XI2 回退路径,隔离 RECORD 生命周期与键盘映射连接,并对 XCB/RECORD 事件去重。 增加策略、RECORD 生命周期、回退、主动 grab 和并行测试覆盖。 Log: restore grab-resilient X11 shortcuts Pms: BUG-372643 Change-Id: I9498c98fe60a953447beedda08e4e916ac493247 --- debian/control | 1 + src/plugin-qt/shortcut/CMakeLists.txt | 6 + .../src/backend/x11/modifierkeymonitor.cpp | 480 +++++++++++++++--- .../src/backend/x11/modifierkeymonitor.h | 55 +- .../src/backend/x11/x11keyhandler.cpp | 126 ++++- .../shortcut/src/backend/x11/x11keyhandler.h | 12 + .../src/backend/x11/x11shortcutpolicy.cpp | 25 + .../src/backend/x11/x11shortcutpolicy.h | 18 + src/plugin-qt/shortcut/tests/CMakeLists.txt | 73 +++ .../tests/tst_x11grabresilientshortcuts.cpp | 140 +++++ .../shortcut/tests/tst_x11recordmonitor.cpp | 200 ++++++++ .../shortcut/tests/tst_x11shortcutpolicy.cpp | 45 ++ 12 files changed, 1112 insertions(+), 69 deletions(-) create mode 100644 src/plugin-qt/shortcut/src/backend/x11/x11shortcutpolicy.cpp create mode 100644 src/plugin-qt/shortcut/src/backend/x11/x11shortcutpolicy.h create mode 100644 src/plugin-qt/shortcut/tests/tst_x11grabresilientshortcuts.cpp create mode 100644 src/plugin-qt/shortcut/tests/tst_x11recordmonitor.cpp create mode 100644 src/plugin-qt/shortcut/tests/tst_x11shortcutpolicy.cpp 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"