diff --git a/src/lib/platform/CMakeLists.txt b/src/lib/platform/CMakeLists.txt index d1bf54789..1a2597fd4 100644 --- a/src/lib/platform/CMakeLists.txt +++ b/src/lib/platform/CMakeLists.txt @@ -18,6 +18,7 @@ if(UNIX AND NOT APPLE) set(CMAKE_REQUIRED_LIBRARIES "${CMAKE_REQUIRED_LIBRARIES};${LIBPORTAL_LINK_LIBRARIES};") check_symbol_exists(xdp_input_capture_session_get_restore_token "libportal/inputcapture.h" HAVE_IC_RESTORE) check_symbol_exists(xdp_session_request_clipboard "libportal/portal-helpers.h;libportal/clipboard.h" HAVE_LP_CLIPBOARD) + check_symbol_exists(xdp_global_shortcut_new "libportal/globalshortcuts.h" HAVE_LP_SHORTCUTS) cmake_pop_check_state() endif() @@ -166,6 +167,12 @@ elseif(UNIX) PortalClipboard.h ) endif() + if(HAVE_LP_SHORTCUTS) + list(APPEND PLATFORM_SOURCES + PortalGlobalShortcuts.cpp + PortalGlobalShortcuts.h + ) + endif() endif() endif() endif() @@ -192,6 +199,9 @@ if(UNIX) if (HAVE_LP_CLIPBOARD) target_compile_definitions(platform PUBLIC HAVE_LIBPORTAL_CLIPBOARD) endif() + if (HAVE_LP_SHORTCUTS) + target_compile_definitions(platform PUBLIC HAVE_LIBPORTAL_SHORTCUTS) + endif() target_include_directories(platform PUBLIC ${LIBEI_INCLUDE_DIRS} ${LIBPORTAL_INCLUDE_DIRS}) target_link_libraries(platform PUBLIC diff --git a/src/lib/platform/EiScreen.cpp b/src/lib/platform/EiScreen.cpp index cac1f5493..7e66daa13 100644 --- a/src/lib/platform/EiScreen.cpp +++ b/src/lib/platform/EiScreen.cpp @@ -59,8 +59,11 @@ EiScreen::EiScreen(bool isPrimary, IEventQueue *events, bool usePortal) handleConnectedToEisEvent(e); }); if (isPrimary) { - m_portalInputCapture = new PortalInputCapture(this, m_events); // Portal input capture manages its own clipboard + m_portalInputCapture = new PortalInputCapture(this, m_events); +#ifdef HAVE_LIBPORTAL_SHORTCUTS + m_portalGlobalShortcuts = new PortalGlobalShortcuts(this, m_events); +#endif } else { m_events->addHandler(EventTypes::EISessionClosed, getEventTarget(), [this](const auto &) { handlePortalSessionClosed(); @@ -97,8 +100,11 @@ EiScreen::~EiScreen() delete m_keyState; delete m_clipboard; - delete m_portalRemoteDesktop; +#ifdef HAVE_LIBPORTAL_SHORTCUTS + delete m_portalGlobalShortcuts; +#endif delete m_portalInputCapture; + delete m_portalRemoteDesktop; } void EiScreen::eiLogEvent(ei_log_priority priority, const char *message) const @@ -234,19 +240,47 @@ std::uint32_t EiScreen::registerHotKey(KeyID key, KeyModifierMask mask) } set->second.addItem(HotKeyItem(mask, id)); + updatePortalGlobalShortcuts(); + return id; } void EiScreen::unregisterHotKey(uint32_t id) { - for (auto &[key, set] : m_hotkeys) { - (void)key; - if (set.removeById(id)) { + for (auto it = m_hotkeys.begin(); it != m_hotkeys.end(); ++it) { + if (it->second.removeById(id)) { + if (it->second.empty()) + m_hotkeys.erase(it); + updatePortalGlobalShortcuts(); break; } } } +void EiScreen::updatePortalGlobalShortcuts() +{ +#ifdef HAVE_LIBPORTAL_SHORTCUTS + if (!m_portalGlobalShortcuts) { + return; + } + + if (!m_activated) { + return; + } + + std::vector hotkeys; + + for (const auto &[key, set] : m_hotkeys) { + (void)key; + + const auto setHotKeys = set.getPortalHotKeys(); + hotkeys.insert(hotkeys.end(), setHotKeys.begin(), setHotKeys.end()); + } + + m_portalGlobalShortcuts->setHotKeys(std::move(hotkeys)); +#endif +} + void EiScreen::fakeInputBegin() { // FIXME -- not implemented @@ -359,16 +393,13 @@ void EiScreen::fakeKey(uint32_t keycode, bool isDown) const void EiScreen::enable() { - // Nothing really to be done here - // Portal-based clipboard gets notifications via events - // Socket-based clipboard is passive (no monitoring needed) + m_activated = true; + updatePortalGlobalShortcuts(); } void EiScreen::disable() { - // Nothing to do here - // Portal-based clipboard gets notifications via events - // Socket-based clipboard is passive (no monitoring needed) + m_activated = false; } void EiScreen::cancelIdleEmulationTimer() const @@ -703,6 +734,10 @@ ButtonID EiScreen::mapButtonFromEvdev(ei_event *event) const bool EiScreen::onHotkey(KeyID keyid, bool isPressed, KeyModifierMask mask) { + // Check if the keyid is registered as a hotkey + // This is the implementation if InputCapture is active. If InputCapture is not active, + // the hotkey is handled by the portal and we don't get here. + auto it = m_hotkeys.find(keyid); if (it == m_hotkeys.end()) { @@ -717,7 +752,6 @@ bool EiScreen::onHotkey(KeyID keyid, bool isPressed, KeyModifierMask mask) sendEvent(type, HotKeyInfo::alloc(id)); return true; } - return false; } @@ -1091,4 +1125,21 @@ std::uint32_t EiScreen::HotKeySet::findByMask(std::uint32_t mask) const return 0; } +#ifdef HAVE_LIBPORTAL_SHORTCUTS +const std::vector EiScreen::HotKeySet::getPortalHotKeys() const +{ + std::vector portalHotKeys; + portalHotKeys.reserve(m_set.size()); + + for (const auto &item : m_set) { + portalHotKeys.push_back({ + .id = item.id, + .key = m_id, + .mask = item.mask, + }); + } + return portalHotKeys; +} +#endif + } // namespace deskflow diff --git a/src/lib/platform/EiScreen.h b/src/lib/platform/EiScreen.h index 4a33cab4c..133b95e10 100644 --- a/src/lib/platform/EiScreen.h +++ b/src/lib/platform/EiScreen.h @@ -9,6 +9,9 @@ #include "deskflow/IScreen.h" #include "deskflow/PlatformScreen.h" +#ifdef HAVE_LIBPORTAL_SHORTCUTS +#include "platform/PortalGlobalShortcuts.h" +#endif #include "platform/XDGPowerManager.h" #include @@ -30,6 +33,7 @@ namespace deskflow { class EiKeyState; class PortalRemoteDesktop; class PortalInputCapture; +class PortalGlobalShortcuts; class EiClipboard; using ClipboardInfo = IScreen::ClipboardInfo; @@ -128,6 +132,7 @@ private: void ensureEmulating() const; void stopEmulating() const; void cancelIdleEmulationTimer() const; + void updatePortalGlobalShortcuts(); static void handleEiLogEvent(ei *ei, const ei_log_priority priority, const char *message, ei_log_context *) { @@ -192,6 +197,9 @@ private: PortalRemoteDesktop *m_portalRemoteDesktop = nullptr; PortalInputCapture *m_portalInputCapture = nullptr; + PortalGlobalShortcuts *m_portalGlobalShortcuts = nullptr; + + bool m_activated = false; struct HotKeyItem { @@ -218,6 +226,13 @@ private: bool removeById(std::uint32_t id); void addItem(HotKeyItem item); std::uint32_t findByMask(std::uint32_t mask) const; + bool empty() const + { + return m_set.empty(); + } +#ifdef HAVE_LIBPORTAL_SHORTCUTS + const std::vector getPortalHotKeys() const; +#endif private: KeyID m_id = 0; diff --git a/src/lib/platform/PortalGlobalShortcuts.cpp b/src/lib/platform/PortalGlobalShortcuts.cpp new file mode 100644 index 000000000..eba0b4fc9 --- /dev/null +++ b/src/lib/platform/PortalGlobalShortcuts.cpp @@ -0,0 +1,520 @@ +/* + * Deskflow -- mouse and keyboard sharing utility + * SPDX-FileCopyrightText: (C) 2026 Deskflow Developers + * SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception + */ + +#include "platform/PortalGlobalShortcuts.h" +#include "base/Log.h" +#include "base/TMethodJob.h" +#include "platform/EiScreen.h" +#include + +#include // std::find_if, std::ranges::all_of +#include // std::isalnum +#include // snprintf + +namespace deskflow { + +PortalGlobalShortcuts::PortalGlobalShortcuts(EiScreen *screen, IEventQueue *events) + : m_screen{screen}, + m_events{events}, + m_portal{xdp_portal_new()} +{ + m_glibMainLoop = g_main_loop_new(nullptr, true); + + auto tMethodJob = new TMethodJob(this, &PortalGlobalShortcuts::glibThread); + m_glibThread = new Thread(tMethodJob); +} + +PortalGlobalShortcuts::~PortalGlobalShortcuts() +{ + { + std::scoped_lock lock{m_bindingsMutex}; + if (m_pendingIdleSource != 0) { + g_source_remove(m_pendingIdleSource); + m_pendingIdleSource = 0; + } + } + + if (m_cancellable) { + g_cancellable_cancel(m_cancellable); + } + + if (m_glibMainLoop && g_main_loop_is_running(m_glibMainLoop)) { + g_main_loop_quit(m_glibMainLoop); + } + + if (m_glibThread) { + m_glibThread->cancel(); + m_glibThread->wait(); + delete m_glibThread; + m_glibThread = nullptr; + } + + if (m_glibMainLoop) { + g_main_loop_unref(m_glibMainLoop); + m_glibMainLoop = nullptr; + } + + closeSession(); + + if (m_cancellable) { + g_object_unref(m_cancellable); + m_cancellable = nullptr; + } + + if (m_portal) { + g_object_unref(m_portal); + m_portal = nullptr; + } +} + +void PortalGlobalShortcuts::setHotKeys(std::vector hotkeys) +{ + std::vector bindings; + bindings.reserve(hotkeys.size()); + for (const auto &hotkey : hotkeys) { + bindings.push_back(makeBinding(hotkey)); + } + + std::scoped_lock lock{m_bindingsMutex}; + m_bindings = std::move(bindings); + + if (m_pendingIdleSource != 0) { + // Already scheduled — it will pick up the bindings we just stored via + // bindingsSnapshot() once it runs, so nothing further to do here. + return; + } + + m_pendingIdleSource = g_idle_add( + [](gpointer data) -> gboolean { + auto *self = static_cast(data); + { + std::scoped_lock lock{self->m_bindingsMutex}; + self->m_pendingIdleSource = 0; + } + if (self->m_session) { + self->closeSession(); + } + return self->createSession(); + }, + this + ); +} + +PortalGlobalShortcuts::Binding PortalGlobalShortcuts::makeBinding(const HotKey &hotkey) +{ + Binding binding; + + binding.id = hotkey.id; + binding.key = hotkey.key; + binding.mask = hotkey.mask; + binding.shortcutId = makeShortcutId(hotkey.key, hotkey.mask); + const std::string trigger = makePreferredTrigger(hotkey.key, hotkey.mask); + // This is not the prettiest way, but we currently have no direct access to the Hotkey object + // of lib/gui/Hotkey.cpp, so we cannot use Hotkey::text() + binding.description = trigger.empty() ? "Deskflow hotkey #" + std::to_string(hotkey.id) : "Hotkey " + trigger; + binding.preferredTrigger = trigger; + + return binding; +} + +std::vector PortalGlobalShortcuts::bindingsSnapshot() const +{ + std::scoped_lock lock{m_bindingsMutex}; + return m_bindings; +} + +std::uint32_t PortalGlobalShortcuts::hotKeyIdForShortcutId(const char *shortcutId) const +{ + std::scoped_lock lock{m_bindingsMutex}; + + const auto it = std::find_if(m_bindings.begin(), m_bindings.end(), [shortcutId](const Binding &binding) { + return binding.shortcutId == shortcutId; + }); + + if (it == m_bindings.end()) { + return 0; + } + + return it->id; +} + +gboolean PortalGlobalShortcuts::createSession() +{ + // Supersede any still-pending CreateSession/BindShortcuts from an earlier attempt. + if (m_cancellable) { + g_cancellable_cancel(m_cancellable); + g_object_unref(m_cancellable); + m_cancellable = nullptr; + } + m_cancellable = g_cancellable_new(); + + LOG_DEBUG("creating global shortcuts session"); + + xdp_portal_create_global_shortcuts_session( + m_portal, m_cancellable, + [](GObject *obj, GAsyncResult *res, gpointer data) { + static_cast(data)->createSessionDone(obj, res); + }, + this + ); + return G_SOURCE_REMOVE; +} + +void PortalGlobalShortcuts::closeSession() +{ + if (!m_session) { + LOG_DEBUG("no session to close"); + return; + } + + if (m_cancellable) { + g_cancellable_cancel(m_cancellable); + g_object_unref(m_cancellable); + m_cancellable = nullptr; + } + + using enum Signal; + + if (XdpSession *parentSession = xdp_global_shortcuts_session_get_session(m_session)) { + if (m_signals.at(SessionClosed)) { + g_signal_handler_disconnect(parentSession, m_signals.at(SessionClosed)); + m_signals.at(SessionClosed) = 0; + } + } + + if (m_signals.at(Activated)) { + g_signal_handler_disconnect(m_session, m_signals.at(Activated)); + m_signals.at(Activated) = 0; + } + + if (m_signals.at(Deactivated)) { + g_signal_handler_disconnect(m_session, m_signals.at(Deactivated)); + m_signals.at(Deactivated) = 0; + } + + if (m_signals.at(ShortcutsChanged)) { + g_signal_handler_disconnect(m_session, m_signals.at(ShortcutsChanged)); + m_signals.at(ShortcutsChanged) = 0; + } + + xdp_global_shortcuts_session_close(m_session); + g_object_unref(m_session); + m_session = nullptr; +} + +void PortalGlobalShortcuts::setupSession(XdpGlobalShortcutsSession *session) +{ + auto parentSession = xdp_global_shortcuts_session_get_session(session); + + using enum Signal; + m_signals.at(SessionClosed) = g_signal_connect(G_OBJECT(parentSession), "closed", G_CALLBACK(sessionClosed), this); + m_signals.at(Activated) = g_signal_connect(G_OBJECT(session), "activated", G_CALLBACK(activated), this); + m_signals.at(Deactivated) = g_signal_connect(G_OBJECT(session), "deactivated", G_CALLBACK(deactivated), this); + m_signals.at(ShortcutsChanged) = + g_signal_connect(G_OBJECT(session), "shortcuts-changed", G_CALLBACK(shortcutsChanged), this); +} + +void PortalGlobalShortcuts::bindShortcuts() +{ + const auto bindings = bindingsSnapshot(); + LOG_DEBUG("binding %zu global shortcuts", bindings.size()); + g_autoptr(GPtrArray) shortcuts = nullptr; + + shortcuts = g_ptr_array_new_with_free_func((GDestroyNotify)xdp_global_shortcut_free); + + for (const auto &binding : bindings) { + g_ptr_array_add( + shortcuts, xdp_global_shortcut_new( + binding.shortcutId.c_str(), binding.description.c_str(), + binding.preferredTrigger.empty() ? nullptr : binding.preferredTrigger.c_str() + ) + ); + } + + xdp_global_shortcuts_session_bind_shortcuts( + m_session, shortcuts, + nullptr, // parent window + m_cancellable, + [](GObject *object, GAsyncResult *res, gpointer data) { + static_cast(data)->bindShortcutsDone(object, res); + }, + this + ); +} + +void PortalGlobalShortcuts::createSessionDone(GObject *object, GAsyncResult *res) +{ + g_autoptr(GError) error = nullptr; + + auto session = xdp_portal_create_global_shortcuts_session_finish(XDP_PORTAL(object), res, &error); + if (!session) { + if (g_error_matches(error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) { + LOG_DEBUG("global shortcuts session creation was superseded"); + return; + } + LOG_WARN("failed to create global shortcuts session, hotkeys will not be forwarded: %s", error->message); + return; + } + + LOG_DEBUG("portal global shortcuts session created"); + m_session = session; + setupSession(session); + + bindShortcuts(); +} + +void PortalGlobalShortcuts::bindShortcutsDone(GObject *object, GAsyncResult *res) +{ + g_autoptr(GError) error = nullptr; + g_autoptr(GPtrArray) shortcuts = nullptr; + + if (XDP_GLOBAL_SHORTCUTS_SESSION(object) != m_session) { + return; + } + + shortcuts = xdp_global_shortcuts_session_bind_shortcuts_finish(m_session, res, &error); + if (!shortcuts) { + if (g_error_matches(error, G_IO_ERROR, G_IO_ERROR_CANCELLED)) { + LOG_DEBUG("global shortcuts binding was superseded"); + return; + } + LOG_ERR("failed to bind global shortcuts: %s", error->message); + return; + } + + LOG_DEBUG("bound %u global shortcuts", shortcuts->len); + + for (guint i = 0; i < shortcuts->len; i++) { + XdpGlobalShortcutAssigned *shortcut; + + shortcut = static_cast(g_ptr_array_index(shortcuts, i)); + LOG_DEBUG( + "global shortcut %s assigned to %s", xdp_global_shortcut_assigned_get_shortcut_id(shortcut), + xdp_global_shortcut_assigned_get_trigger_description(shortcut) + ); + } + // EiScreen can't be notified about the change, because triggerDescription is only a + // human-readable string and not a preferred trigger string +} + +void PortalGlobalShortcuts::handleSessionClosed(XdpSession *) +{ + LOG_WARN("portal global shortcuts session was closed by the system, will attempt to recreate it"); + closeSession(); + createSession(); +} + +void PortalGlobalShortcuts::handleActivated( + XdpGlobalShortcutsSession *session, const char *shortcutId, const guint64 timestamp, const GVariant * +) +{ + if (session != m_session) { + return; + } + + const auto hotKeyId = hotKeyIdForShortcutId(shortcutId); + if (hotKeyId == 0) { + LOG_WARN("unknown global shortcut activated: %s", shortcutId); + return; + } + + LOG_DEBUG("global shortcut activated: %s timestamp=%" PRIu64, shortcutId, timestamp); + m_events->addEvent(Event( + EventTypes::PrimaryScreenHotkeyDown, m_screen->getEventTarget(), IPrimaryScreen::HotKeyInfo::alloc(hotKeyId) + )); +} + +void PortalGlobalShortcuts::handleDeactivated( + XdpGlobalShortcutsSession *session, const char *shortcutId, const guint64 timestamp, const GVariant * +) +{ + if (session != m_session) { + return; + } + + const auto hotKeyId = hotKeyIdForShortcutId(shortcutId); + if (hotKeyId == 0) { + LOG_WARN("unknown global shortcut deactivated: %s", shortcutId); + return; + } + + LOG_DEBUG("global shortcut deactivated: %s timestamp=%" PRIu64, shortcutId, timestamp); + m_events->addEvent( + Event(EventTypes::PrimaryScreenHotkeyUp, m_screen->getEventTarget(), IPrimaryScreen::HotKeyInfo::alloc(hotKeyId)) + ); +} + +void PortalGlobalShortcuts::handleShortcutsChanged(XdpGlobalShortcutsSession *session, GPtrArray *shortcuts) +{ + if (session != m_session) { + return; + } + + if (shortcuts == nullptr) { + LOG_WARN("global shortcuts changed without shortcuts data"); + return; + } + + LOG_DEBUG("global shortcuts changed"); + + for (guint i = 0; i < shortcuts->len; i++) { + XdpGlobalShortcutAssigned *shortcut; + const char *shortcutId; + const char *triggerDescription; + + shortcut = static_cast(g_ptr_array_index(shortcuts, i)); + shortcutId = xdp_global_shortcut_assigned_get_shortcut_id(shortcut); + triggerDescription = xdp_global_shortcut_assigned_get_trigger_description(shortcut); + LOG_DEBUG("global shortcut %s changed to %s", shortcutId, triggerDescription); + + // EiScreen can't be notified about the change, because triggerDescription is only a + // human-readable string and not a preferred trigger string + } +} + +void PortalGlobalShortcuts::glibThread(const void *) +{ + auto context = g_main_loop_get_context(m_glibMainLoop); + + LOG_DEBUG("global shortcuts glib thread running"); + + while (g_main_loop_is_running(m_glibMainLoop)) { + Thread::testCancel(); + g_main_context_iteration(context, true); + } + + LOG_DEBUG("global shortcuts glib thread shutting down"); +} + +// see https://specifications.freedesktop.org/shortcuts/latest/ on how to construct +// a preferred trigger string for a key combination +bool PortalGlobalShortcuts::isValidPreferredTriggerIdentifier(const std::string &name) +{ + if (name.empty()) { + return false; + } + + return std::ranges::all_of(name, [](unsigned char c) { return std::isalnum(c) || c == '_'; }); +} + +std::optional PortalGlobalShortcuts::deskflowKeyToXkbKeysym(KeyID key) +{ + if (key == kKeyNone) { + return std::nullopt; + } + + // KeyTypes.h explicitly excludes + // 0xE000..0xE0FF from the "X11 keysym - 0x1000" rule. + if (key >= 0xE000 && key <= 0xE0FF) { + return std::nullopt; + } + + // Deskflow control/function keys. KeyTypes.h says these + // are equal to the corresponding X11 keysym - 0x1000. + if (key >= 0xE100 && key <= 0xEFFF) { + return static_cast(key + 0x1000); + } + + // Remaining values are normal UTF-32/Unicode codepoints. + if (key < 0xE000) { + const auto keysym = xkb_utf32_to_keysym(key); + if (keysym == XKB_KEY_NoSymbol) { + return std::nullopt; + } + + return keysym; + } + + return std::nullopt; +} + +std::string PortalGlobalShortcuts::keyNameForPreferredTrigger(KeyID key) +{ + const auto keysym = deskflowKeyToXkbKeysym(key); + if (!keysym) { + return {}; + } + + char name[128] = {}; + const int len = xkb_keysym_get_name(*keysym, name, sizeof(name)); + if (len <= 0 || static_cast(len) >= sizeof(name)) { + return {}; + } + + std::string result{name}; + if (!isValidPreferredTriggerIdentifier(result)) { + return {}; + } + + return result; +} + +std::string PortalGlobalShortcuts::makePreferredTrigger(KeyID key, KeyModifierMask mask) +{ + constexpr KeyModifierMask supportedMask = KeyModifierShift | KeyModifierControl | KeyModifierAlt | KeyModifierMeta | + KeyModifierSuper | KeyModifierAltGr | KeyModifierLevel5Lock | + KeyModifierCapsLock | KeyModifierNumLock | KeyModifierScrollLock; + + if ((mask & ~supportedMask) != 0) { + return {}; + } + + const auto keyName = keyNameForPreferredTrigger(key); + if (keyName.empty()) { + return {}; + } + + std::vector parts; + + if (mask & KeyModifierShift) { + parts.emplace_back("SHIFT"); + } + if (mask & KeyModifierControl) { + parts.emplace_back("CTRL"); + } + if (mask & KeyModifierAlt) { + parts.emplace_back("ALT"); + } + if (mask & (KeyModifierMeta | KeyModifierSuper)) { + parts.emplace_back("LOGO"); + } + if (mask & KeyModifierAltGr) { + parts.emplace_back("LEVEL3"); + } + if (mask & KeyModifierLevel5Lock) { + parts.emplace_back("LEVEL5"); + } + if (mask & KeyModifierCapsLock) { + parts.emplace_back("CAPS"); + } + if (mask & KeyModifierNumLock) { + parts.emplace_back("NUM"); + } + if (mask & KeyModifierScrollLock) { + parts.emplace_back("SCROLL"); + } + + parts.emplace_back(keyName); + + std::string trigger; + for (std::size_t i = 0; i < parts.size(); ++i) { + if (i != 0) { + trigger += "+"; + } + trigger += parts[i]; + } + + return trigger; +} + +std::string PortalGlobalShortcuts::makeShortcutId(KeyID key, KeyModifierMask mask) +{ + char buffer[64]; + snprintf(buffer, sizeof(buffer), "deskflow-hotkey-%08x-%08x", mask, key); + return buffer; +} + +} // namespace deskflow diff --git a/src/lib/platform/PortalGlobalShortcuts.h b/src/lib/platform/PortalGlobalShortcuts.h new file mode 100644 index 000000000..12a970a97 --- /dev/null +++ b/src/lib/platform/PortalGlobalShortcuts.h @@ -0,0 +1,140 @@ +/* + * Deskflow -- mouse and keyboard sharing utility + * SPDX-FileCopyrightText: (C) 2026 Deskflow Developers + * SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception + */ + +#pragma once + +#include "base/IEventQueue.h" +#include "mt/Thread.h" + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "deskflow/KeyTypes.h" + +namespace deskflow { + +class EiScreen; + +class PortalGlobalShortcuts +{ +public: + PortalGlobalShortcuts(EiScreen *screen, IEventQueue *events); + ~PortalGlobalShortcuts(); + struct HotKey + { + std::uint32_t id = 0; + KeyID key = kKeyNone; + KeyModifierMask mask = 0; + }; + + void setHotKeys(std::vector hotkeys); + +private: + void glibThread(const void *); + void setupSession(XdpGlobalShortcutsSession *session); + + void createSessionDone(GObject *object, GAsyncResult *res); + void bindShortcutsDone(GObject *object, GAsyncResult *res); + + void handleSessionClosed(XdpSession *session); + void handleActivated( + XdpGlobalShortcutsSession *session, const char *shortcutId, const guint64 timestamp, const GVariant *options + ); + void handleDeactivated( + XdpGlobalShortcutsSession *session, const char *shortcutId, const guint64 timestamp, const GVariant *options + ); + void handleShortcutsChanged(XdpGlobalShortcutsSession *session, GPtrArray *shortcuts); + + /// g_signal_connect callback wrapper + static void sessionClosed(XdpSession *session, const gpointer data) + { + static_cast(data)->handleSessionClosed(session); + } + + static void activated( + XdpGlobalShortcutsSession *session, const char *shortcutId, const guint64 timestamp, const GVariant *options, + const gpointer data + ) + { + static_cast(data)->handleActivated(session, shortcutId, timestamp, options); + } + + static void deactivated( + XdpGlobalShortcutsSession *session, const char *shortcutId, const guint64 timestamp, const GVariant *options, + const gpointer data + ) + { + static_cast(data)->handleDeactivated(session, shortcutId, timestamp, options); + } + + static void shortcutsChanged(XdpGlobalShortcutsSession *session, GPtrArray *shortcuts, const gpointer data) + { + static_cast(data)->handleShortcutsChanged(session, shortcuts); + } + + enum class Signal : uint8_t + { + SessionClosed, + Activated, + Deactivated, + ShortcutsChanged, + }; + + struct Binding + { + std::uint32_t id = 0; + KeyID key = kKeyNone; + KeyModifierMask mask = 0; + std::string shortcutId; + std::string description; + std::string preferredTrigger; + }; + + static std::optional deskflowKeyToXkbKeysym(KeyID key); + static std::string keyNameForPreferredTrigger(KeyID key); + static std::string makePreferredTrigger(KeyID key, KeyModifierMask mask); + static bool isValidPreferredTriggerIdentifier(const std::string &name); + + gboolean createSession(); + void closeSession(); + void bindShortcuts(); + + std::uint32_t hotKeyIdForShortcutId(const char *shortcutId) const; + static Binding makeBinding(const HotKey &hotkey); + std::vector bindingsSnapshot() const; + static std::string makeShortcutId(KeyID key, KeyModifierMask mask); + + EiScreen *m_screen = nullptr; + IEventQueue *m_events = nullptr; + + Thread *m_glibThread = nullptr; + GMainLoop *m_glibMainLoop = nullptr; + + XdpPortal *m_portal = nullptr; + XdpGlobalShortcutsSession *m_session = nullptr; + GCancellable *m_cancellable = nullptr; + + std::map m_signals = { + {Signal::SessionClosed, 0}, + {Signal::Activated, 0}, + {Signal::Deactivated, 0}, + {Signal::ShortcutsChanged, 0}, + }; + + mutable std::mutex m_bindingsMutex; + guint m_pendingIdleSource = 0; // guarded by m_bindingsMutex + // Snapshot derived from EiScreen::m_hotkeys. This is portal-facing state + std::vector m_bindings; +}; + +} // namespace deskflow