520 lines
15 KiB
C++
520 lines
15 KiB
C++
/*
|
|
* 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 <inttypes.h>
|
|
|
|
#include <algorithm> // std::find_if, std::ranges::all_of
|
|
#include <cctype> // std::isalnum
|
|
#include <cstdio> // 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<PortalGlobalShortcuts>(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<HotKey> hotkeys)
|
|
{
|
|
std::vector<Binding> 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<PortalGlobalShortcuts *>(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::Binding> 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<PortalGlobalShortcuts *>(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<PortalGlobalShortcuts *>(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<XdpGlobalShortcutAssigned *>(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<XdpGlobalShortcutAssigned *>(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<xkb_keysym_t> 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<xkb_keysym_t>(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<std::size_t>(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<std::string> 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
|