/* * 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