Compare commits

...

10 commits

Author SHA1 Message Date
Stefan Wasilewski
0b7a72ae0e fix connect timeout deadlock 2026-09-20 02:20:33 +04:00
Nick Bolton
49e6293a4d fix(client): add per-version server proxies for older servers
A client that downgrades to a 1.6 or 1.7 server parsed key repeat with the 1.8 format and swallowed the messages behind it. ServerProxy is now the 1.6 form with 1.7 and 1.8 subclasses picked by the negotiated version, mirroring the ClientProxy classes.
2026-09-18 20:15:14 +01:00
Nick Bolton
6638076b85 fix(server): send key repeat without language to pre-1.8 clients
fixes: #10069
fixes: #9483
fixes: #9101

The 2021 language change put the string into the frozen 1.1 proxy, so every client below 1.8 (Barrier, Input Leap, Synergy 1.14.1 and older) disconnected on the first key repeat. Adds a test pinning the bytes each proxy version sends.
2026-09-18 20:15:14 +01:00
Nick Bolton
1e32335890 refactor(protocol): name key down constants by version
kMsgDKeyDown is now the current DKDL form and kMsgDKeyDown1_1 the DKDN form, matching the 1_0 suffix used for legacy forms. No wire change.
2026-09-18 20:15:14 +01:00
OceanMan20
ac475f0b1b fix(wayland): sync locked modifiers from EI keyboard modifiers event
fixes: #8437
2026-09-18 14:14:18 +01:00
Nick Bolton
23b0d7792d fix(log): rotate the log instead of deleting it 2026-09-17 12:21:37 +00:00
KoljaFrahm
217166c06b feat: globalshortcuts portal support 2026-09-15 19:16:53 +01:00
Luiz Sardinha
ce2f948c29 fix(mac): run event queue events in autoreleasepool 2026-09-11 17:21:22 +00:00
Timothy Redaelli
71929a3418 fix(wayland): track held mouse buttons in EiScreen
isAnyMouseButtonDown() always returned false, so the server never
considered the screen locked and could switch away mid-drag.

Track the pressed buttons in a bitset fed by the button events. There is
no way to poll them from libei, so drop the whole set whenever the state
stops being trustworthy: a paused device is reset to neutral by the EIS
side, a removed device sends nothing more, and releasing the input
capture ends the event stream. That rule now lives in updateButtons(),
which also puts EiScreen on the same resync path the other platforms
already use.
2026-09-11 18:07:25 +01:00
KoljaFrahm
d6874dfe27 fix: Only enable the screen after connecting to the client
To be able to react to rules in screen->activate() that are enabled in setPrimaryClient, we have to reverse the order of the two calls. The same goes for screen->deactivate()
2026-09-11 13:52:08 +01:00
37 changed files with 1697 additions and 214 deletions

View file

@ -155,10 +155,11 @@ This table lists all protocol messages in alphabetical order. For a typical sequ
| [**DDRG**](@ref kMsgDDragInfo) | @ref kMsgDDragInfo | Data | Server→Client | Drag file info | [MsgSize](#constraint-protocol-max-message-length), [ListSize](#constraint-max-list) | 1.5+ |
| [**DFTR**](@ref kMsgDFileTransfer) | @ref kMsgDFileTransfer | Data | Both | File transfer data | [MsgSize](#constraint-protocol-max-message-length) | 1.5+ |
| [**DINF**](@ref kMsgDInfo) | @ref kMsgDInfo | Data | Client→Server | Screen information | [MsgSize](#constraint-protocol-max-message-length) | 1.0+ |
| [**DKDL**](@ref kMsgDKeyDownLang) | @ref kMsgDKeyDownLang | Data | Server→Client | Key down with language | [MsgSize](#constraint-protocol-max-message-length), [KeyMap](#constraint-keymap) | 1.8+ |
| [**DKDN**](@ref kMsgDKeyDown) | @ref kMsgDKeyDown | Data | Server→Client | Key down | [MsgSize](#constraint-protocol-max-message-length), [KeyMap](#constraint-keymap) | 1.1+ |
| [**DKDL**](@ref kMsgDKeyDown) | @ref kMsgDKeyDown | Data | Server→Client | Key down with language | [MsgSize](#constraint-protocol-max-message-length), [KeyMap](#constraint-keymap) | 1.8+ |
| [**DKDN**](@ref kMsgDKeyDown1_1) | @ref kMsgDKeyDown1_1 | Data | Server→Client | Key down | [MsgSize](#constraint-protocol-max-message-length), [KeyMap](#constraint-keymap) | 1.1+ |
| [**DKDN**](@ref kMsgDKeyDown1_0) | @ref kMsgDKeyDown1_0 | Data | Server→Client | Key down (legacy) | [MsgSize](#constraint-protocol-max-message-length), [KeyMap](#constraint-keymap) | 1.0 |
| [**DKRP**](@ref kMsgDKeyRepeat) | @ref kMsgDKeyRepeat | Data | Server→Client | Key repeat | [MsgSize](#constraint-protocol-max-message-length), [KeyMap](#constraint-keymap) | 1.1+ |
| [**DKRP**](@ref kMsgDKeyRepeat) | @ref kMsgDKeyRepeat | Data | Server→Client | Key repeat with language | [MsgSize](#constraint-protocol-max-message-length), [KeyMap](#constraint-keymap) | 1.8+ |
| [**DKRP**](@ref kMsgDKeyRepeat1_1) | @ref kMsgDKeyRepeat1_1 | Data | Server→Client | Key repeat | [MsgSize](#constraint-protocol-max-message-length), [KeyMap](#constraint-keymap) | 1.1+ |
| [**DKRP**](@ref kMsgDKeyRepeat1_0) | @ref kMsgDKeyRepeat1_0 | Data | Server→Client | Key repeat (legacy) | [MsgSize](#constraint-protocol-max-message-length), [KeyMap](#constraint-keymap) | 1.0 |
| [**DKUP**](@ref kMsgDKeyUp) | @ref kMsgDKeyUp | Data | Server→Client | Key up | [MsgSize](#constraint-protocol-max-message-length), [KeyMap](#constraint-keymap) | 1.1+ |
| [**DKUP**](@ref kMsgDKeyUp1_0) | @ref kMsgDKeyUp1_0 | Data | Server→Client | Key up (legacy) | [MsgSize](#constraint-protocol-max-message-length), [KeyMap](#constraint-keymap) | 1.0 |
@ -288,8 +289,8 @@ A modifier (modifier mask) represents the state of modifier keys (like Shift, Co
| **1.4** | Nov 2012 | Synergy | Encryption support (@ref SecureSocket) | 1.4+ |
| **1.5** | Sep 2013 | Synergy | File transfer | 1.5+ |
| **1.6** | Jan 2014 | Synergy | Clipboard streaming | 1.6+ |
| **1.7** | Nov 2021 | Synergy | Secure input notifications | 1.7+ |
| **1.8** | Jun 2025 | Synergy | Language synchronization | 1.8+ |
| **1.7** | Sep 2021 | Synergy | Secure input notifications | 1.7+ |
| **1.8** | Nov 2021 | Synergy | Language synchronization | 1.8+ |
### Version Migration Guide

View file

@ -1,9 +1,17 @@
# SPDX-FileCopyrightText: (C) 2026 Deskflow Developers
# SPDX-FileCopyrightText: (C) 2024 Chris Rizzitello <sithlord48@gmail.com>
# SPDX-FileCopyrightText: (C) 2012 - 2024 Symless Ltd
# SPDX-FileCopyrightText: (C) 2009 - 2012 Nick Bolton
# SPDX-License-Identifier: MIT
add_library(base STATIC
if(APPLE)
set(PLATFORM_CODE
OSXAutoReleasePool.h
OSXAutoReleasePool.mm
)
endif()
add_library(base STATIC ${PLATFORM_CODE}
BaseException.cpp
BaseException.h
DirectionTypes.h

View file

@ -18,6 +18,10 @@
#include <stdexcept>
#if defined(__APPLE__)
#include "base/OSXAutoReleasePool.h"
#endif
// interrupt handler. this just adds a quit event to the queue.
static void interrupt(Arch::ThreadSignal, void *data)
{
@ -166,6 +170,9 @@ bool EventQueue::getEvent(Event &event, double timeout)
bool EventQueue::dispatchEvent(const Event &event)
{
#if defined(__APPLE__)
auto dispatch = [&] {
#endif
void *target = event.getTarget();
if (auto typeHandler = getHandler(event.getType(), target); typeHandler.has_value()) {
(*typeHandler)(event);
@ -176,6 +183,10 @@ bool EventQueue::dispatchEvent(const Event &event)
return true;
}
return false;
#if defined(__APPLE__)
};
return deskflow::runInAutoReleasePool(dispatch);
#endif
}
void EventQueue::addEvent(Event &&event)

View file

@ -138,7 +138,7 @@ bool FileLogOutputter::write(LogLevel::Level, const QString &message)
if (file.size() > s_logFileSizeLimit) {
const auto oldFile = QStringLiteral("%1.1").arg(m_fileName);
QFile::remove(m_fileName);
QFile::remove(oldFile);
QFile::rename(m_fileName, oldFile);
}

View file

@ -0,0 +1,16 @@
/*
* 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 <functional>
namespace deskflow {
//! Run \p fn inside an Objective-C \c \@autoreleasepool block.
bool runInAutoReleasePool(const std::function<bool()> &fn);
} // namespace deskflow

View file

@ -0,0 +1,20 @@
/*
* Deskflow -- mouse and keyboard sharing utility
* SPDX-FileCopyrightText: (C) 2026 Deskflow Developers
* SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception
*/
#import "base/OSXAutoReleasePool.h"
#import <Foundation/Foundation.h>
namespace deskflow {
bool runInAutoReleasePool(const std::function<bool()> &fn)
{
@autoreleasepool {
return fn();
}
}
} // namespace deskflow

View file

@ -8,6 +8,10 @@ add_library(client STATIC
Client.h
ServerProxy.cpp
ServerProxy.h
ServerProxy1_7.cpp
ServerProxy1_7.h
ServerProxy1_8.cpp
ServerProxy1_8.h
)
target_link_libraries(client PUBLIC common)

View file

@ -12,6 +12,8 @@
#include "base/IEventQueue.h"
#include "base/Log.h"
#include "client/ServerProxy.h"
#include "client/ServerProxy1_7.h"
#include "client/ServerProxy1_8.h"
#include "common/NetworkProtocol.h"
#include "common/Settings.h"
#include "deskflow/Clipboard.h"
@ -465,18 +467,38 @@ void Client::setupConnection()
});
}
void Client::setupScreen()
bool Client::setupScreen(int16_t protocolMinor)
{
assert(m_server == nullptr);
m_ready = false;
// only 1.6 and later have a proxy: the clipboard, mouse wheel and key message formats
// differ below that, and nothing older (synergy 1.4 and earlier) still needs supporting.
// a version with no case is refused by the hello handler.
switch (protocolMinor) {
case 6:
m_server = new ServerProxy(this, m_stream, m_events);
break;
case 7:
m_server = new ServerProxy1_7(this, m_stream, m_events);
break;
case 8:
m_server = new ServerProxy1_8(this, m_stream, m_events);
break;
default:
break;
}
if (m_server != nullptr) {
m_events->addHandler(EventTypes::ScreenShapeChanged, getEventTarget(), [this](const auto &) {
handleShapeChanged();
});
m_events->addHandler(EventTypes::ClipboardGrabbed, getEventTarget(), [this](const auto &e) {
handleClipboardGrabbed(e);
});
}
return m_server != nullptr;
}
void Client::setupTimer()
@ -683,6 +705,17 @@ void Client::handleHello()
);
}
// no proxy speaks the negotiated version, so hang up as incompatible rather than
// talk a version the client does not implement, the same rule the server applies
// when it picks a client proxy
if (!setupScreen(helloBackMinor)) {
LOG_WARN("server protocol version not supported: %d.%d", serverMajor, serverMinor);
sendConnectionFailedEvent(IncompatibleClientException(serverMajor, serverMinor).what());
cleanupTimer();
cleanupConnection();
return;
}
LOG_DEBUG("saying hello back with version %s %d.%d", protocolName.c_str(), kProtocolMajorVersion, helloBackMinor);
// dynamically build write format for hello back since `ProtocolUtil::writef`
@ -691,7 +724,6 @@ void Client::handleHello()
ProtocolUtil::writef(m_stream, helloBackMessage.c_str(), kProtocolMajorVersion, helloBackMinor, &m_name);
// now connected but waiting to complete handshake
setupScreen();
cleanupTimer();
// make sure we process any remaining messages later. we won't

View file

@ -197,7 +197,7 @@ private:
void sendConnectionFailedEvent(const char *msg);
void setupConnecting();
void setupConnection();
void setupScreen();
bool setupScreen(int16_t protocolMinor);
void setupTimer();
void cleanup();
void cleanupConnecting();

View file

@ -1,7 +1,7 @@
/*
* Deskflow -- mouse and keyboard sharing utility
* SPDX-FileCopyrightText: (C) 2025 Deskflow Developers
* SPDX-FileCopyrightText: (C) 2012 - 2016 Synergy App Ltd
* SPDX-FileCopyrightText: (C) 2012 - 2016, 2026 Synergy App Ltd
* SPDX-FileCopyrightText: (C) 2002 Chris Schoeneman
* SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception
*/
@ -18,7 +18,6 @@
#include "deskflow/ProtocolTypes.h"
#include "deskflow/ProtocolUtil.h"
#include "deskflow/StreamChunker.h"
#include "deskflow/ipc/CoreIpc.h"
#include "io/IStream.h"
#include <cstring>
@ -140,12 +139,7 @@ ServerProxy::ConnectionResult ServerProxy::parseHandshakeMessage(const uint8_t *
setOptions();
// handshake is complete
m_parser = &ServerProxy::parseMessage;
if (const auto missedKeyboardLayouts = m_layoutManager.getMissedLayouts(); !missedKeyboardLayouts.empty()) {
LOG_WARN("server layouts missing on this computer: %s", missedKeyboardLayouts.c_str());
ipcSendToClient("missingKeyboardLayouts", QString::fromStdString(missedKeyboardLayouts));
}
m_parser = &ServerProxy::handleMessage;
m_client->handshakeComplete();
}
@ -196,8 +190,6 @@ ServerProxy::ConnectionResult ServerProxy::parseHandshakeMessage(const uint8_t *
LOG_ERR("server disconnected due to a protocol error");
requestRefuseConnection(ProtocolError, "server reported a protocol error");
return Disconnect;
} else if (memcmp(code, kMsgDLanguageSynchronisation, 4) == 0) {
setServerLanguages();
} else {
return Unknown;
}
@ -205,6 +197,22 @@ ServerProxy::ConnectionResult ServerProxy::parseHandshakeMessage(const uint8_t *
return Okay;
}
ServerProxy::ConnectionResult ServerProxy::handleMessage(const uint8_t *code)
{
const auto result = parseMessage(code);
if (result == ConnectionResult::Okay) {
// send a reply. this is intended to work around a delay when
// running a linux server and an OS X (any BSD?) client. the
// client waits to send an ACK (if the system control flag
// net.inet.tcp.delayed_ack is 1) in hopes of piggybacking it
// on a data packet. we provide that packet here. i don't
// know why a delayed ACK should cause the server to wait since
// TCP_NODELAY is enabled.
ProtocolUtil::writef(m_stream, kMsgCNoop);
}
return result;
}
ServerProxy::ConnectionResult ServerProxy::parseMessage(const uint8_t *code)
{
using enum ConnectionResult;
@ -221,28 +229,16 @@ ServerProxy::ConnectionResult ServerProxy::parseMessage(const uint8_t *code)
mouseWheel();
}
else if (memcmp(code, kMsgDKeyDown, 4) == 0) {
else if (memcmp(code, kMsgDKeyDown1_1, 4) == 0) {
uint16_t id = 0;
uint16_t mask = 0;
uint16_t button = 0;
ProtocolUtil::readf(m_stream, kMsgDKeyDown + 4, &id, &mask, &button);
ProtocolUtil::readf(m_stream, kMsgDKeyDown1_1 + 4, &id, &mask, &button);
LOG_VERBOSE("recv key down id=0x%08x, mask=0x%04x, button=0x%04x", id, mask, button);
keyDown(id, mask, button, "");
}
else if (memcmp(code, kMsgDKeyDownLang, 4) == 0) {
std::string lang;
uint16_t id = 0;
uint16_t mask = 0;
uint16_t button = 0;
ProtocolUtil::readf(m_stream, kMsgDKeyDownLang + 4, &id, &mask, &button, &lang);
LOG_VERBOSE("recv key down id=0x%08x, mask=0x%04x, button=0x%04x, lang=\"%s\"", id, mask, button, lang.c_str());
keyDown(id, mask, button, lang);
}
else if (memcmp(code, kMsgDKeyUp, 4) == 0) {
keyUp();
}
@ -255,8 +251,15 @@ ServerProxy::ConnectionResult ServerProxy::parseMessage(const uint8_t *code)
mouseUp();
}
else if (memcmp(code, kMsgDKeyRepeat, 4) == 0) {
keyRepeat();
else if (memcmp(code, kMsgDKeyRepeat1_1, 4) == 0) {
uint16_t id = 0;
uint16_t mask = 0;
uint16_t count = 0;
uint16_t button = 0;
ProtocolUtil::readf(m_stream, kMsgDKeyRepeat1_1 + 4, &id, &mask, &count, &button);
LOG_VERBOSE("recv key repeat id=0x%08x, mask=0x%04x, count=%d, button=0x%04x", id, mask, count, button);
keyRepeat(id, mask, count, button, "");
}
else if (memcmp(code, kMsgCKeepAlive, 4) == 0) {
@ -305,10 +308,6 @@ ServerProxy::ConnectionResult ServerProxy::parseMessage(const uint8_t *code)
setOptions();
}
else if (memcmp(code, kMsgDSecureInputNotification, 4) == 0) {
secureInputNotification();
}
else if (memcmp(code, kMsgCClose, 4) == 0) {
// server wants us to hangup
LOG_VERBOSE("recv close");
@ -322,15 +321,6 @@ ServerProxy::ConnectionResult ServerProxy::parseMessage(const uint8_t *code)
return Unknown;
}
// send a reply. this is intended to work around a delay when
// running a linux server and an OS X (any BSD?) client. the
// client waits to send an ACK (if the system control flag
// net.inet.tcp.delayed_ack is 1) in hopes of piggybacking it
// on a data packet. we provide that packet here. i don't
// know why a delayed ACK should cause the server to wait since
// TCP_NODELAY is enabled.
ProtocolUtil::writef(m_stream, kMsgCNoop);
return Okay;
}
@ -521,8 +511,6 @@ void ServerProxy::enter()
m_dxMouse = 0;
m_dyMouse = 0;
m_seqNum = seqNum;
m_serverLayout = "";
m_isUserNotifiedAboutLayoutSyncError = false;
// forward
m_client->enter(x, y, seqNum, static_cast<KeyModifierMask>(mask), false);
@ -588,45 +576,25 @@ void ServerProxy::grabClipboard()
void ServerProxy::keyDown(uint16_t id, uint16_t mask, uint16_t button, const std::string &lang)
{
// get mouse up to date
flushCompressedMouse();
setActiveServerLanguage(lang);
// translate
KeyID id2 = translateKey(static_cast<KeyID>(id));
KeyModifierMask mask2 = translateModifierMask(static_cast<KeyModifierMask>(mask));
if (id2 != static_cast<KeyID>(id) || mask2 != static_cast<KeyModifierMask>(mask))
LOG_VERBOSE("key down translated to id=0x%08x, mask=0x%04x", id2, mask2);
// forward
m_client->keyDown(id2, mask2, button, lang);
}
void ServerProxy::keyRepeat()
void ServerProxy::keyRepeat(uint16_t id, uint16_t mask, uint16_t count, uint16_t button, const std::string &lang)
{
// get mouse up to date
flushCompressedMouse();
// parse
uint16_t id;
uint16_t mask;
uint16_t count;
uint16_t button;
std::string lang;
ProtocolUtil::readf(m_stream, kMsgDKeyRepeat + 4, &id, &mask, &count, &button, &lang);
LOG(
(CLOG_VERBOSE "recv key repeat id=0x%08x, mask=0x%04x, count=%d, "
"button=0x%04x, lang=\"%s\"",
id, mask, count, button, lang.c_str())
);
// translate
KeyID id2 = translateKey(static_cast<KeyID>(id));
KeyModifierMask mask2 = translateModifierMask(static_cast<KeyModifierMask>(mask));
if (id2 != static_cast<KeyID>(id) || mask2 != static_cast<KeyModifierMask>(mask))
LOG_VERBOSE("key repeat translated to id=0x%08x, mask=0x%04x", id2, mask2);
// forward
m_client->keyRepeat(id2, mask2, count, button, lang);
}
@ -841,38 +809,3 @@ void ServerProxy::infoAcknowledgment()
LOG_VERBOSE("recv info acknowledgment");
m_ignoreMouse = false;
}
void ServerProxy::secureInputNotification()
{
std::string app;
ProtocolUtil::readf(m_stream, kMsgDSecureInputNotification + 4, &app);
LOG_INFO("application \"%s\" is blocking the keyboard", app.c_str());
}
void ServerProxy::setServerLanguages()
{
std::string serverLayout;
ProtocolUtil::readf(m_stream, kMsgDLanguageSynchronisation + 4, &serverLayout);
m_layoutManager.setRemoteLayouts(serverLayout);
}
void ServerProxy::setActiveServerLanguage(const std::string_view &language)
{
if (!language.empty() && (language.size() > 0)) {
if (m_serverLayout != language) {
m_isUserNotifiedAboutLayoutSyncError = false;
m_serverLayout = language;
}
if (!m_layoutManager.isLayoutInstalled(m_serverLayout)) {
if (!m_isUserNotifiedAboutLayoutSyncError) {
LOG_WARN("current server layout is not installed on client");
m_isUserNotifiedAboutLayoutSyncError = true;
}
} else {
m_isUserNotifiedAboutLayoutSyncError = false;
}
} else {
LOG_VERBOSE("active server layout is empty");
}
}

View file

@ -1,7 +1,7 @@
/*
* Deskflow -- mouse and keyboard sharing utility
* SPDX-FileCopyrightText: (C) 2025 Deskflow Developers
* SPDX-FileCopyrightText: (C) 2012 - 2016 Synergy App Ltd
* SPDX-FileCopyrightText: (C) 2012 - 2016, 2026 Synergy App Ltd
* SPDX-FileCopyrightText: (C) 2002 Chris Schoeneman
* SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception
*/
@ -12,7 +12,6 @@
#include "deskflow/ClipboardChunk.h"
#include "deskflow/ClipboardTypes.h"
#include "deskflow/KeyTypes.h"
#include "deskflow/KeyboardLayoutManager.h"
class Client;
class ClientInfo;
@ -23,10 +22,12 @@ class IStream;
}
class IEventQueue;
//! Proxy for server
//! Proxy for server implementing protocol version 1.6
/*!
This class acts a proxy for the server, converting calls into messages
to the server and messages from the server to calls on the client.
It implements 1.6, the oldest version the client supports; later
versions subclass it and add their messages.
*/
class ServerProxy
{
@ -38,7 +39,7 @@ public:
ServerProxy(Client *client, deskflow::IStream *stream, IEventQueue *events);
ServerProxy(ServerProxy const &) = delete;
ServerProxy(ServerProxy &&) = delete;
~ServerProxy();
virtual ~ServerProxy();
ServerProxy &operator=(ServerProxy const &) = delete;
ServerProxy &operator=(ServerProxy &&) = delete;
@ -59,8 +60,16 @@ protected:
Unknown,
Disconnect
};
ConnectionResult parseHandshakeMessage(const uint8_t *code);
ConnectionResult parseMessage(const uint8_t *code);
virtual ConnectionResult parseHandshakeMessage(const uint8_t *code);
virtual ConnectionResult parseMessage(const uint8_t *code);
void keyDown(uint16_t id, uint16_t mask, uint16_t button, const std::string &lang);
void keyRepeat(uint16_t id, uint16_t mask, uint16_t count, uint16_t button, const std::string &lang);
deskflow::IStream *getStream() const
{
return m_stream;
}
private:
// if compressing mouse motion then send the last motion now
@ -77,6 +86,7 @@ private:
// event handlers
void handleData();
ConnectionResult handleMessage(const uint8_t *code);
void handleKeepAliveAlarm();
void requestDisconnect(const char *message);
void requestRefuseConnection(deskflow::core::ConnectionRefusal reason, const char *message);
@ -86,8 +96,6 @@ private:
void leave();
void setClipboard();
void grabClipboard();
void keyDown(uint16_t id, uint16_t mask, uint16_t button, const std::string &lang);
void keyRepeat();
void keyUp();
void mouseDown();
void mouseUp();
@ -99,9 +107,6 @@ private:
void setOptions();
void queryInfo();
void infoAcknowledgment();
void secureInputNotification();
void setServerLanguages();
void setActiveServerLanguage(const std::string_view &language);
private:
using MessageParser = ConnectionResult (ServerProxy::*)(const uint8_t *);
@ -127,9 +132,6 @@ private:
MessageParser m_parser = &ServerProxy::parseHandshakeMessage;
IEventQueue *m_events = nullptr;
std::string m_serverLayout = "";
std::string m_clipboardDataCached;
ClipboardChunkAssemblyState m_clipboardChunkState;
bool m_isUserNotifiedAboutLayoutSyncError = false;
deskflow::KeyboardLayoutManager m_layoutManager;
};

View file

@ -0,0 +1,37 @@
/*
* Deskflow -- mouse and keyboard sharing utility
* SPDX-FileCopyrightText: (C) 2026 Synergy App Ltd
* SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception
*/
#include "client/ServerProxy1_7.h"
#include "base/Log.h"
#include "deskflow/ProtocolTypes.h"
#include "deskflow/ProtocolUtil.h"
#include <cstring>
#include <string>
ServerProxy1_7::ServerProxy1_7(Client *client, deskflow::IStream *stream, IEventQueue *events)
: ServerProxy(client, stream, events)
{
}
ServerProxy::ConnectionResult ServerProxy1_7::parseMessage(const uint8_t *code)
{
auto result = ConnectionResult::Okay;
if (memcmp(code, kMsgDSecureInputNotification, 4) == 0) {
secureInputNotification();
} else {
result = ServerProxy::parseMessage(code);
}
return result;
}
void ServerProxy1_7::secureInputNotification() const
{
std::string app;
ProtocolUtil::readf(getStream(), kMsgDSecureInputNotification + 4, &app);
LOG_INFO("application \"%s\" is blocking the keyboard", app.c_str());
}

View file

@ -0,0 +1,23 @@
/*
* Deskflow -- mouse and keyboard sharing utility
* SPDX-FileCopyrightText: (C) 2026 Synergy App Ltd
* SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception
*/
#pragma once
#include "client/ServerProxy.h"
//! Proxy for server implementing protocol version 1.7
class ServerProxy1_7 : public ServerProxy
{
public:
ServerProxy1_7(Client *client, deskflow::IStream *stream, IEventQueue *events);
~ServerProxy1_7() override = default;
protected:
ConnectionResult parseMessage(const uint8_t *code) override;
private:
void secureInputNotification() const;
};

View file

@ -0,0 +1,103 @@
/*
* Deskflow -- mouse and keyboard sharing utility
* SPDX-FileCopyrightText: (C) 2026 Synergy App Ltd
* SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception
*/
#include "client/ServerProxy1_8.h"
#include "base/Log.h"
#include "deskflow/ProtocolTypes.h"
#include "deskflow/ProtocolUtil.h"
#include "deskflow/ipc/CoreIpc.h"
#include <cstring>
#include <string>
#include <QString>
ServerProxy1_8::ServerProxy1_8(Client *client, deskflow::IStream *stream, IEventQueue *events)
: ServerProxy1_7(client, stream, events)
{
}
ServerProxy::ConnectionResult ServerProxy1_8::parseHandshakeMessage(const uint8_t *code)
{
auto result = ConnectionResult::Okay;
if (memcmp(code, kMsgDLanguageSynchronisation, 4) == 0) {
setServerLayouts();
} else {
result = ServerProxy1_7::parseHandshakeMessage(code);
}
return result;
}
ServerProxy::ConnectionResult ServerProxy1_8::parseMessage(const uint8_t *code)
{
auto result = ConnectionResult::Okay;
if (memcmp(code, kMsgDKeyDown, 4) == 0) {
uint16_t id = 0;
uint16_t mask = 0;
uint16_t button = 0;
std::string lang;
ProtocolUtil::readf(getStream(), kMsgDKeyDown + 4, &id, &mask, &button, &lang);
LOG_VERBOSE("recv key down id=0x%08x, mask=0x%04x, button=0x%04x, lang=\"%s\"", id, mask, button, lang.c_str());
setActiveServerLayout(lang);
keyDown(id, mask, button, lang);
} else if (memcmp(code, kMsgDKeyRepeat, 4) == 0) {
uint16_t id = 0;
uint16_t mask = 0;
uint16_t count = 0;
uint16_t button = 0;
std::string lang;
ProtocolUtil::readf(getStream(), kMsgDKeyRepeat + 4, &id, &mask, &count, &button, &lang);
LOG_VERBOSE(
"recv key repeat id=0x%08x, mask=0x%04x, count=%d, button=0x%04x, lang=\"%s\"", id, mask, count, button,
lang.c_str()
);
keyRepeat(id, mask, count, button, lang);
} else if (memcmp(code, kMsgCEnter, 4) == 0) {
m_serverLayout.clear();
m_isUserNotifiedAboutLayoutSyncError = false;
result = ServerProxy1_7::parseMessage(code);
} else {
result = ServerProxy1_7::parseMessage(code);
}
return result;
}
void ServerProxy1_8::setServerLayouts()
{
std::string serverLayouts;
ProtocolUtil::readf(getStream(), kMsgDLanguageSynchronisation + 4, &serverLayouts);
m_layoutManager.setRemoteLayouts(serverLayouts);
if (const auto missingLayouts = m_layoutManager.getMissedLayouts(); !missingLayouts.empty()) {
LOG_WARN("server layouts missing on this computer: %s", missingLayouts.c_str());
ipcSendToClient("missingKeyboardLayouts", QString::fromStdString(missingLayouts));
}
}
void ServerProxy1_8::setActiveServerLayout(const std::string_view &layout)
{
if (layout.empty()) {
LOG_VERBOSE("active server layout is empty");
return;
}
if (m_serverLayout != layout) {
m_isUserNotifiedAboutLayoutSyncError = false;
m_serverLayout = layout;
}
if (!m_layoutManager.isLayoutInstalled(m_serverLayout)) {
if (!m_isUserNotifiedAboutLayoutSyncError) {
LOG_WARN("current server layout is not installed on client");
m_isUserNotifiedAboutLayoutSyncError = true;
}
} else {
m_isUserNotifiedAboutLayoutSyncError = false;
}
}

View file

@ -0,0 +1,33 @@
/*
* Deskflow -- mouse and keyboard sharing utility
* SPDX-FileCopyrightText: (C) 2026 Synergy App Ltd
* SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception
*/
#pragma once
#include "client/ServerProxy1_7.h"
#include "deskflow/KeyboardLayoutManager.h"
#include <string>
#include <string_view>
//! Proxy for server implementing protocol version 1.8
class ServerProxy1_8 : public ServerProxy1_7
{
public:
ServerProxy1_8(Client *client, deskflow::IStream *stream, IEventQueue *events);
~ServerProxy1_8() override = default;
protected:
ConnectionResult parseHandshakeMessage(const uint8_t *code) override;
ConnectionResult parseMessage(const uint8_t *code) override;
private:
void setServerLayouts();
void setActiveServerLayout(const std::string_view &layout);
std::string m_serverLayout;
bool m_isUserNotifiedAboutLayoutSyncError = false;
deskflow::KeyboardLayoutManager m_layoutManager;
};

View file

@ -13,6 +13,7 @@ const char *const kMsgHello = "%7s%2i%2i";
const char *const kMsgHelloArgs = "%2i%2i";
const char *const kMsgHelloBack = "%7s%2i%2i%s";
const char *const kMsgHelloBackArgs = "%2i%2i%s";
const char *const kMsgCNoop = "CNOP";
const char *const kMsgCClose = "CBYE";
const char *const kMsgCEnter = "CINN%2i%2i%4i%2i";
@ -22,10 +23,15 @@ const char *const kMsgCScreenSaver = "CSEC%1i";
const char *const kMsgCResetOptions = "CROP";
const char *const kMsgCInfoAck = "CIAK";
const char *const kMsgCKeepAlive = "CALV";
const char *const kMsgDKeyDownLang = "DKDL%2i%2i%2i%s";
const char *const kMsgDKeyDown = "DKDN%2i%2i%2i";
// This is weird, it goes from DKDN to DKDL. Someone misunderstood the pattern (keep the
// code, pick the form by negotiated version). Do not copy this.
const char *const kMsgDKeyDown = "DKDL%2i%2i%2i%s";
const char *const kMsgDKeyDown1_1 = "DKDN%2i%2i%2i";
const char *const kMsgDKeyDown1_0 = "DKDN%2i%2i";
const char *const kMsgDKeyRepeat = "DKRP%2i%2i%2i%2i%s";
const char *const kMsgDKeyRepeat1_1 = "DKRP%2i%2i%2i%2i";
const char *const kMsgDKeyRepeat1_0 = "DKRP%2i%2i%2i";
const char *const kMsgDKeyUp = "DKUP%2i%2i%2i";
const char *const kMsgDKeyUp1_0 = "DKUP%2i%2i";

View file

@ -504,7 +504,7 @@ extern const char *const kMsgCKeepAlive;
*/
/**
* @brief Key press with language code (v1.8+)
* @brief Key press event
*
* **Message Code**: `"DKDL"`
* **Direction**: Primary → Secondary
@ -524,34 +524,6 @@ extern const char *const kMsgCKeepAlive;
* "DKDL\x00\x61\x00\x00\x00\x1E\x00\x00\x00\x02en"
* ```
*
* Enhanced version of kMsgDKeyDown that includes language information
* to help clients handle unknown language characters correctly.
*
* @see kMsgDKeyDown
* @since Protocol version 1.8
*/
extern const char *const kMsgDKeyDownLang;
/**
* @brief Key press event
*
* **Message Code**: `"DKDN"`
* **Direction**: Primary → Secondary
* **Format**: `"DKDN%2i%2i%2i"`
* **Parameters**:
* - `$1`: KeyID (2 bytes) - Virtual key identifier, often called a "keysym" on Linux/X11. This is platform-dependent
* and corresponds to values like `XK_a` on X11/Linux, `'a'` on macOS, and `'A'` on Windows.
* - `$2`: KeyModifierMask (2 bytes) - Active modifier keys
* - `$3`: KeyButton (2 bytes) - Physical key code, often called a "keycode" or "scancode". This is the raw,
* platform-dependent scan code of the key pressed.
*
* **Example**:
*
* 'a' key (KeyID 0x61), no modifiers, physical key (KeyButton 0x1E)
* ```
* "DKDN\x00\x61\x00\x00\x00\x1E"
* ```
*
* **Key Mapping Strategy**:
* The KeyButton parameter is crucial for proper key release handling.
* The secondary screen should:
@ -564,11 +536,42 @@ extern const char *const kMsgDKeyDownLang;
* - Different keyboard layouts may produce different KeyIDs
* - Modifier keys released before the main key can alter KeyID
*
* @see kMsgDKeyUp, kMsgDKeyDownLang
* @since Protocol version 1.1
* The language code helps clients handle characters from a keyboard layout
* they do not have active. Only sent to clients that replied 1.8 in their hello.
*
* @see kMsgDKeyUp, kMsgDKeyDown1_1
* @since Protocol version 1.8
*/
extern const char *const kMsgDKeyDown;
/**
* @brief Key press event (v1.1 to v1.7)
*
* **Message Code**: `"DKDN"`
* **Direction**: Primary → Secondary
* **Format**: `"DKDN%2i%2i%2i"`
* **Parameters**:
* - `$1`: KeyID (2 bytes) - Virtual key identifier
* - `$2`: KeyModifierMask (2 bytes) - Active modifier keys
* - `$3`: KeyButton (2 bytes) - Physical key code
*
* **Example**:
*
* 'a' key (KeyID 0x61), no modifiers, physical key (KeyButton 0x1E)
* ```
* "DKDN\x00\x61\x00\x00\x00\x1E"
* ```
*
* Version without the language code. Used when communicating with
* protocol version 1.1 through 1.7 clients (Synergy 1.4 to 1.14.1,
* Barrier, Input Leap). The key mapping strategy is the same as for
* kMsgDKeyDown.
*
* @see kMsgDKeyDown
* @since Protocol version 1.1
*/
extern const char *const kMsgDKeyDown1_1;
/**
* @brief Key press event (legacy v1.0)
*
@ -582,8 +585,8 @@ extern const char *const kMsgDKeyDown;
* Legacy version without KeyButton parameter. Used only when
* communicating with protocol version 1.0 clients.
*
* @deprecated Use kMsgDKeyDown for protocol version 1.1+
* @see kMsgDKeyDown
* @deprecated Use kMsgDKeyDown1_1 for protocol version 1.1+
* @see kMsgDKeyDown1_1
* @since Protocol version 1.0
*/
extern const char *const kMsgDKeyDown1_0;
@ -613,11 +616,43 @@ extern const char *const kMsgDKeyDown1_0;
* Sent when a key is held down and auto-repeating. The repeat count
* indicates how many repeat events occurred since the last message.
*
* @see kMsgDKeyDown
* @since Protocol version 1.1
* Shares the `DKRP` message code with kMsgDKeyRepeat1_1, so the receiver can
* only tell the two apart by the negotiated protocol version. Only sent to
* clients that replied 1.8 in their hello.
*
* @see kMsgDKeyDown, kMsgDKeyRepeat1_1
* @since Protocol version 1.8
*/
extern const char *const kMsgDKeyRepeat;
/**
* @brief Key auto-repeat event (v1.1 to v1.7)
*
* **Message Code**: `"DKRP"`
* **Direction**: Primary → Secondary
* **Format**: `"DKRP%2i%2i%2i%2i"`
* **Parameters**:
* - `$1`: KeyID (2 bytes) - Virtual key identifier
* - `$2`: KeyModifierMask (2 bytes) - Active modifier keys
* - `$3`: Repeat count (2 bytes) - Number of repeats
* - `$4`: KeyButton (2 bytes) - Physical key code
*
* **Example**:
*
* 'a' key repeating 3 times
* ```
* "DKRP\x00\x61\x00\x00\x00\x03\x00\x1E"
* ```
*
* Version without the language code. Used when communicating with
* protocol version 1.1 through 1.7 clients (Synergy 1.4 to 1.14.1,
* Barrier, Input Leap).
*
* @see kMsgDKeyRepeat
* @since Protocol version 1.1
*/
extern const char *const kMsgDKeyRepeat1_1;
/**
* @brief Key auto-repeat event (legacy v1.0)
*
@ -629,10 +664,10 @@ extern const char *const kMsgDKeyRepeat;
* - `$2`: KeyModifierMask (2 bytes) - Active modifier keys
* - `$3`: Repeat count (2 bytes) - Number of repeats
*
* Legacy version without KeyButton and language parameters.
* Legacy version without the KeyButton parameter.
*
* @deprecated Use kMsgDKeyRepeat for protocol version 1.1+
* @see kMsgDKeyRepeat
* @deprecated Use kMsgDKeyRepeat1_1 for protocol version 1.1+
* @see kMsgDKeyRepeat1_1
* @since Protocol version 1.0
*/
extern const char *const kMsgDKeyRepeat1_0;

View file

@ -380,13 +380,22 @@ void SecureSocket::createSSL()
void SecureSocket::freeSSL()
{
std::scoped_lock ssl_lock{ssl_mutex_};
isFatal(true);
// take socket from multiplexer ASAP otherwise the race condition
// could cause events to get called on a dead object. TCPSocket
// will do this, too, but the double-call is harmless
// will do this, too, but the double-call is harmless.
//
// this must happen *before* ssl_mutex_ is taken. the multiplexer holds its
// job list across job->run(), and secureAccept()/secureConnect() take
// ssl_mutex_ from inside a job, so taking ssl_mutex_ here first and then
// waiting on the job list inverts the lock order against the multiplexer
// thread and hangs both. removeSocket() also returns only once no job is
// running, so the teardown below still cannot race a live job.
setJob(nullptr);
std::scoped_lock ssl_lock{ssl_mutex_};
if (m_ssl) {
if (m_ssl->m_ssl != nullptr) {
SSL_set_quiet_shutdown(m_ssl->m_ssl, 1);

View file

@ -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

View file

@ -345,6 +345,20 @@ void EiKeyState::updateXkbState(uint32_t keyval, bool isPressed)
xkb_state_update_key(m_xkbState, keyval, isPressed ? XKB_KEY_DOWN : XKB_KEY_UP);
}
void EiKeyState::updateLockedModifiers(xkb_mod_mask_t lockedMods)
{
// The compositor's lock state (Caps/Num/Scroll Lock) is authoritative; it
// also covers locks toggled while we weren't seeing the key events.
const auto depressedMods = xkb_state_serialize_mods(m_xkbState, XKB_STATE_MODS_DEPRESSED);
const auto latchedMods = xkb_state_serialize_mods(m_xkbState, XKB_STATE_MODS_LATCHED);
const auto depressedLayout = xkb_state_serialize_layout(m_xkbState, XKB_STATE_LAYOUT_DEPRESSED);
const auto latchedLayout = xkb_state_serialize_layout(m_xkbState, XKB_STATE_LAYOUT_LATCHED);
const auto lockedLayout = xkb_state_serialize_layout(m_xkbState, XKB_STATE_LAYOUT_LOCKED);
xkb_state_update_mask(
m_xkbState, depressedMods, latchedMods, lockedMods, depressedLayout, latchedLayout, lockedLayout
);
}
void EiKeyState::clearStaleModifiers()
{
const auto lockedMods = xkb_state_serialize_mods(m_xkbState, XKB_STATE_MODS_LOCKED);

View file

@ -35,6 +35,7 @@ public:
void pollPressedKeys(KeyButtonSet &pressedKeys) const override;
KeyID mapKeyFromKeyval(std::uint32_t keyval) const;
void updateXkbState(std::uint32_t keyval, bool isPressed);
void updateLockedModifiers(xkb_mod_mask_t lockedMods);
void clearStaleModifiers() override;
protected:

View file

@ -22,6 +22,7 @@
#include "platform/PortalRemoteDesktop.h"
#include <algorithm>
#include <bit>
#include <cmath>
#include <cstdlib>
#include <cstring>
@ -58,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();
@ -96,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
@ -233,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<PortalGlobalShortcuts::HotKey> 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
@ -261,9 +296,13 @@ std::int32_t EiScreen::getJumpZoneSize() const
return 1;
}
bool EiScreen::isAnyMouseButtonDown(uint32_t &) const
bool EiScreen::isAnyMouseButtonDown(uint32_t &buttonID) const
{
if (m_buttons.none())
return false;
buttonID = std::countr_zero(m_buttons.to_ulong());
return true;
}
void EiScreen::getCursorCenter(int32_t &x, int32_t &y) const
@ -354,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
@ -424,6 +460,8 @@ void EiScreen::enter()
} else if (m_isPrimary) {
LOG_DEBUG("releasing input capture at x=%i y=%i", m_cursorX, m_cursorY);
m_portalInputCapture->release(m_cursorX, m_cursorY);
// no more button events once capture is released, so drop any held state
updateButtons();
}
}
@ -631,6 +669,8 @@ void EiScreen::removeDevice(struct ei_device *device)
if (wasTracked) {
m_isEmulating = false;
cancelIdleEmulationTimer();
// same as for a paused device: no release events follow a removal
updateButtons();
}
delete static_cast<ScrollRemainder *>(ei_device_get_user_data(device));
@ -694,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()) {
@ -708,7 +752,6 @@ bool EiScreen::onHotkey(KeyID keyid, bool isPressed, KeyModifierMask mask)
sendEvent(type, HotKeyInfo::alloc(id));
return true;
}
return false;
}
@ -757,6 +800,8 @@ void EiScreen::onButtonEvent(ei_event *event)
return;
}
m_buttons.set(buttonID, pressed);
auto eventType = pressed ? EventTypes::PrimaryScreenButtonDown : EventTypes::PrimaryScreenButtonUp;
sendEvent(eventType, ButtonInfo::alloc(buttonID, mask));
@ -968,6 +1013,9 @@ void EiScreen::handleSystemEvent(const Event &)
case EI_EVENT_DEVICE_PAUSED:
LOG_DEBUG("device %s is paused", ei_device_get_name(device));
m_isEmulating = false;
// a paused device is reset to neutral by the EIS side and sends no
// further events, so the releases for held buttons never arrive
updateButtons();
cancelIdleEmulationTimer();
break;
case EI_EVENT_DEVICE_RESUMED:
@ -977,7 +1025,8 @@ void EiScreen::handleSystemEvent(const Event &)
}
break;
case EI_EVENT_KEYBOARD_MODIFIERS:
// FIXME
LOG_VERBOSE("event: keyboard modifiers locked=0x%x", ei_event_keyboard_get_xkb_mods_locked(event));
m_keyState->updateLockedModifiers(ei_event_keyboard_get_xkb_mods_locked(event));
break;
// events below are for a receiver context (barriers)
@ -1026,7 +1075,9 @@ void EiScreen::handleSystemEvent(const Event &)
void EiScreen::updateButtons()
{
// libei relies on the EIS implementation to keep our button count correct,
// so there's not much we need to/can do here.
// and the held buttons cannot be polled, so resyncing means assuming that
// everything is released.
m_buttons.reset();
}
IKeyState *EiScreen::getKeyState() const
@ -1075,4 +1126,21 @@ std::uint32_t EiScreen::HotKeySet::findByMask(std::uint32_t mask) const
return 0;
}
#ifdef HAVE_LIBPORTAL_SHORTCUTS
const std::vector<PortalGlobalShortcuts::HotKey> EiScreen::HotKeySet::getPortalHotKeys() const
{
std::vector<PortalGlobalShortcuts::HotKey> 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

View file

@ -9,8 +9,12 @@
#include "deskflow/IScreen.h"
#include "deskflow/PlatformScreen.h"
#ifdef HAVE_LIBPORTAL_SHORTCUTS
#include "platform/PortalGlobalShortcuts.h"
#endif
#include "platform/XDGPowerManager.h"
#include <bitset>
#include <climits>
#include <libei.h>
#include <map>
@ -29,6 +33,7 @@ namespace deskflow {
class EiKeyState;
class PortalRemoteDesktop;
class PortalInputCapture;
class PortalGlobalShortcuts;
class EiClipboard;
using ClipboardInfo = IScreen::ClipboardInfo;
@ -127,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 *)
{
@ -144,6 +150,9 @@ private:
KeyID m_lastPressed = kKeyNone;
// mouse buttons currently held, indexed by ButtonID
std::bitset<NumButtonIDs> m_buttons;
// clipboard stuff
EiClipboard *m_clipboard = nullptr;
size_t m_maximumClipboardSize = INT_MAX;
@ -188,6 +197,9 @@ private:
PortalRemoteDesktop *m_portalRemoteDesktop = nullptr;
PortalInputCapture *m_portalInputCapture = nullptr;
PortalGlobalShortcuts *m_portalGlobalShortcuts = nullptr;
bool m_activated = false;
struct HotKeyItem
{
@ -214,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<PortalGlobalShortcuts::HotKey> getPortalHotKeys() const;
#endif
private:
KeyID m_id = 0;

View file

@ -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 <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

View file

@ -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 <glib.h>
#include <libportal/globalshortcuts.h>
#include <optional>
#include <xkbcommon/xkbcommon.h>
#include <map>
#include <mutex>
#include <string>
#include <vector>
#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<HotKey> 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<PortalGlobalShortcuts *>(data)->handleSessionClosed(session);
}
static void activated(
XdpGlobalShortcutsSession *session, const char *shortcutId, const guint64 timestamp, const GVariant *options,
const gpointer data
)
{
static_cast<PortalGlobalShortcuts *>(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<PortalGlobalShortcuts *>(data)->handleDeactivated(session, shortcutId, timestamp, options);
}
static void shortcutsChanged(XdpGlobalShortcutsSession *session, GPtrArray *shortcuts, const gpointer data)
{
static_cast<PortalGlobalShortcuts *>(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<xkb_keysym_t> 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<Binding> 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<Signal, gulong> 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<Binding> m_bindings;
};
} // namespace deskflow

View file

@ -23,19 +23,16 @@ ClientProxy1_1::ClientProxy1_1(const std::string &name, deskflow::IStream *strea
void ClientProxy1_1::keyDown(KeyID key, KeyModifierMask mask, KeyButton button, const std::string &)
{
LOG_VERBOSE("send key down to \"%s\" id=%d, mask=0x%04x, button=0x%04x", getName().c_str(), key, mask, button);
ProtocolUtil::writef(getStream(), kMsgDKeyDown, key, mask, button);
ProtocolUtil::writef(getStream(), kMsgDKeyDown1_1, key, mask, button);
}
void ClientProxy1_1::keyRepeat(
KeyID key, KeyModifierMask mask, int32_t count, KeyButton button, const std::string &lang
)
void ClientProxy1_1::keyRepeat(KeyID key, KeyModifierMask mask, int32_t count, KeyButton button, const std::string &)
{
LOG(
(CLOG_VERBOSE "send key repeat to \"%s\" id=%d, mask=0x%04x, count=%d, "
"button=0x%04x, lang=\"%s\"",
getName().c_str(), key, mask, count, button, lang.c_str())
LOG_VERBOSE(
"send key repeat to \"%s\" id=%d, mask=0x%04x, count=%d, button=0x%04x", getName().c_str(), key, mask, count,
button
);
ProtocolUtil::writef(getStream(), kMsgDKeyRepeat, key, mask, count, button, &lang);
ProtocolUtil::writef(getStream(), kMsgDKeyRepeat1_1, key, mask, count, button);
}
void ClientProxy1_1::keyUp(KeyID key, KeyModifierMask mask, KeyButton button)

View file

@ -32,9 +32,20 @@ void ClientProxy1_8::synchronizeLanguages() const
void ClientProxy1_8::keyDown(KeyID key, KeyModifierMask mask, KeyButton button, const std::string &language)
{
LOG(
(CLOG_VERBOSE "send key down to \"%s\" id=%d, mask=0x%04x, button=0x%04x, layout=%s", getName().c_str(), key,
mask, button, language.c_str())
LOG_VERBOSE(
"send key down to \"%s\" id=%d, mask=0x%04x, button=0x%04x, layout=%s", getName().c_str(), key, mask, button,
language.c_str()
);
ProtocolUtil::writef(getStream(), kMsgDKeyDownLang, key, mask, button, &language);
ProtocolUtil::writef(getStream(), kMsgDKeyDown, key, mask, button, &language);
}
void ClientProxy1_8::keyRepeat(
KeyID key, KeyModifierMask mask, int32_t count, KeyButton button, const std::string &language
)
{
LOG_VERBOSE(
"send key repeat to \"%s\" id=%d, mask=0x%04x, count=%d, button=0x%04x, layout=%s", getName().c_str(), key, mask,
count, button, language.c_str()
);
ProtocolUtil::writef(getStream(), kMsgDKeyRepeat, key, mask, count, button, &language);
}

View file

@ -15,6 +15,7 @@ public:
~ClientProxy1_8() override = default;
void keyDown(KeyID, KeyModifierMask, KeyButton, const std::string &) override;
void keyRepeat(KeyID, KeyModifierMask, int32_t count, KeyButton, const std::string &) override;
private:
void synchronizeLanguages() const;

View file

@ -126,10 +126,19 @@ Server::Server(ServerConfig &config, PrimaryClient *primaryClient, deskflow::Scr
// set initial configuration
setConfig(config);
// If we are using libportal, we need to set the primary client first.
// This enables the rules of the primary client. The hotkeys are then enabled in EiScreen.cpp after the rules
// registered them.
#if WINAPI_LIBPORTAL
// enable primary client
m_inputFilter->setPrimaryClient(m_primaryClient);
m_primaryClient->enable();
// If we are on Mac, X11 or Windows, we need a key map, which is created in screen->enable().
// So we need to enable the screen first, then enable the primary client.
#else
m_primaryClient->enable();
m_inputFilter->setPrimaryClient(m_primaryClient);
#endif
// Determine if scroll lock is already set. If so, lock the cursor to the
// primary screen (unless the user has disabled lock to screen in config)
if (!m_disableLockToScreen && (m_primaryClient->getToggleMask() & KeyModifierScrollLock)) {
@ -175,11 +184,16 @@ Server::~Server()
delete client;
}
// remove input filter
m_inputFilter->setPrimaryClient(nullptr);
// See at Server::Server for why we do this in this order.
#if WINAPI_LIBPORTAL
// disable and disconnect primary client
m_primaryClient->disable();
// remove input filter
m_inputFilter->setPrimaryClient(nullptr);
#else
m_inputFilter->setPrimaryClient(nullptr);
m_primaryClient->disable();
#endif
removeClient(m_primaryClient);
}

View file

@ -29,6 +29,14 @@ create_test(
WORKING_DIRECTORY "${CMAKE_BINARY_DIR}/src/lib/base"
)
create_test(
NAME FileLogOutputterTests
DEPENDS base
LIBS arch ${extra_libs}
SOURCE FileLogOutputterTests.cpp
WORKING_DIRECTORY "${CMAKE_BINARY_DIR}/src/lib/base"
)
create_test(
NAME BaseExceptionTests
DEPENDS base

View file

@ -0,0 +1,79 @@
/*
* Deskflow -- mouse and keyboard sharing utility
* SPDX-FileCopyrightText: (C) 2026 Synergy App Ltd
* SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception
*/
#include "FileLogOutputterTests.h"
#include <QFile>
#include <QFileInfo>
#include <QTest>
void FileLogOutputterTests::init()
{
QVERIFY(m_dir.isValid());
m_fileName = m_dir.filePath(QStringLiteral("deskflow.log"));
QFile::remove(m_fileName);
QFile::remove(rotatedFileName());
}
QString FileLogOutputterTests::rotatedFileName() const
{
return QStringLiteral("%1.1").arg(m_fileName);
}
QString FileLogOutputterTests::readAll(const QString &fileName)
{
QFile file(fileName);
if (!file.open(QFile::ReadOnly))
return {};
return QString::fromUtf8(file.readAll());
}
void FileLogOutputterTests::growLogPastSizeLimit(FileLogOutputter &outputter, const QString &marker)
{
const QString padding(s_paddingSize, QLatin1Char('x'));
QVERIFY(outputter.write(LogLevel::Level::Info, marker));
// rotating renames the live log away, so its absence is what ends the loop
for (int i = 0; i < s_maxPaddingWrites && QFileInfo::exists(m_fileName); ++i)
QVERIFY(outputter.write(LogLevel::Level::Info, padding));
}
void FileLogOutputterTests::write_multipleMessages_appendsEachLine()
{
FileLogOutputter outputter(m_fileName);
QVERIFY(outputter.write(LogLevel::Level::Info, QStringLiteral("first")));
QVERIFY(outputter.write(LogLevel::Level::Info, QStringLiteral("second")));
QCOMPARE(readAll(m_fileName), QStringLiteral("first\nsecond\n"));
}
void FileLogOutputterTests::write_sizeLimitExceeded_preservesPreviousLog()
{
FileLogOutputter outputter(m_fileName);
growLogPastSizeLimit(outputter, QStringLiteral("oldest entry"));
QVERIFY2(QFileInfo::exists(rotatedFileName()), "log was discarded instead of rotated");
QVERIFY(readAll(rotatedFileName()).startsWith(QStringLiteral("oldest entry\n")));
QVERIFY(outputter.write(LogLevel::Level::Info, QStringLiteral("after rotating")));
QCOMPARE(readAll(m_fileName), QStringLiteral("after rotating\n"));
}
void FileLogOutputterTests::write_rotatedLogExists_replacesIt()
{
FileLogOutputter outputter(m_fileName);
growLogPastSizeLimit(outputter, QStringLiteral("first batch"));
QVERIFY(readAll(rotatedFileName()).startsWith(QStringLiteral("first batch\n")));
growLogPastSizeLimit(outputter, QStringLiteral("second batch"));
QVERIFY(readAll(rotatedFileName()).startsWith(QStringLiteral("second batch\n")));
}
QTEST_MAIN(FileLogOutputterTests)

View file

@ -0,0 +1,35 @@
/*
* Deskflow -- mouse and keyboard sharing utility
* SPDX-FileCopyrightText: (C) 2026 Synergy App Ltd
* SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception
*/
#pragma once
#include "base/LogOutputters.h"
#include <QObject>
#include <QString>
#include <QTemporaryDir>
class FileLogOutputterTests : public QObject
{
Q_OBJECT
private Q_SLOTS:
void init();
void write_multipleMessages_appendsEachLine();
void write_sizeLimitExceeded_preservesPreviousLog();
void write_rotatedLogExists_replacesIt();
private:
QString rotatedFileName() const;
void growLogPastSizeLimit(FileLogOutputter &outputter, const QString &marker);
static QString readAll(const QString &fileName);
static constexpr int s_paddingSize = 64 * 1024;
static constexpr int s_maxPaddingWrites = 64;
QTemporaryDir m_dir;
QString m_fileName;
};

View file

@ -102,4 +102,36 @@ void EiKeyStateTests::clearStaleModifiers_shiftDownAndNumLockOn_shiftClearedAndN
QVERIFY((keyState.pollActiveModifiers() & KeyModifierNumLock) != 0);
}
void EiKeyStateTests::updateLockedModifiers_compositorLockState_numLockFollowsCompositor()
{
// Real modifier Mod2 is index 4 (Shift, Lock, Control, Mod1, Mod2, ...).
constexpr std::uint32_t xkbMod2Mask = 1 << 4;
TestAppUtil appUtil;
EventQueue eventQueue;
deskflow::EiKeyState keyState(nullptr, &eventQueue);
QTemporaryFile keymapFile;
QVERIFY(keymapFile.open());
const QByteArray keymapData = QByteArray::fromRawData(TestKeymap, sizeof(TestKeymap) - 1);
QCOMPARE(keymapFile.write(keymapData), keymapData.size());
QVERIFY(keymapFile.flush());
keyState.init(keymapFile.handle(), keymapFile.size());
keyState.updateXkbState(LeftShiftKeycode, true);
QVERIFY((keyState.pollActiveModifiers() & KeyModifierNumLock) == 0);
// NumLock toggled on locally, reported by the compositor
keyState.updateLockedModifiers(xkbMod2Mask);
QVERIFY((keyState.pollActiveModifiers() & KeyModifierNumLock) != 0);
QVERIFY((keyState.pollActiveModifiers() & KeyModifierShift) != 0);
keyState.clearStaleModifiers();
QVERIFY((keyState.pollActiveModifiers() & KeyModifierNumLock) != 0);
// NumLock toggled off locally
keyState.updateLockedModifiers(0);
QVERIFY((keyState.pollActiveModifiers() & KeyModifierNumLock) == 0);
}
QTEST_MAIN(EiKeyStateTests)

View file

@ -18,6 +18,7 @@ class EiKeyStateTests : public QObject
private Q_SLOTS:
void initTestCase();
void clearStaleModifiers_shiftDownAndNumLockOn_shiftClearedAndNumLockPreserved();
void updateLockedModifiers_compositorLockState_numLockFollowsCompositor();
private:
Arch m_arch;

View file

@ -21,3 +21,10 @@ create_test(
WORKING_DIRECTORY "${CMAKE_BINARY_DIR}/src/lib/server"
)
create_test(
NAME ClientProxyTests
DEPENDS server
LIBS base arch io mt net platform app ${extra_libs}
SOURCE ClientProxyTests.cpp
WORKING_DIRECTORY "${CMAKE_BINARY_DIR}/src/lib/server"
)

View file

@ -0,0 +1,225 @@
/*
* Deskflow -- mouse and keyboard sharing utility
* SPDX-FileCopyrightText: (C) 2026 Synergy App Ltd
* SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception
*/
#include "ClientProxyTests.h"
#include "../deskflow/MockEventQueue.h"
#include "deskflow/AppUtil.h"
#include "io/IStream.h"
#include "server/ClientProxy1_0.h"
#include "server/ClientProxy1_1.h"
#include "server/ClientProxy1_6.h"
#include "server/ClientProxy1_7.h"
#include "server/ClientProxy1_8.h"
#include <memory>
#include <string>
#include <vector>
#include <QByteArray>
#include <QTest>
namespace {
class TestAppUtil : public AppUtil
{
public:
int run() override
{
return 0;
}
std::vector<std::string> getKeyboardLayoutList() override
{
return {"en"};
}
std::string getCurrentLanguageCode() override
{
return "en";
}
};
class CapturingStream : public deskflow::IStream
{
public:
QByteArray take()
{
auto bytes = m_buffer;
m_buffer.clear();
return bytes;
}
void write(const void *buffer, uint32_t n) override
{
m_buffer.append(static_cast<const char *>(buffer), n);
}
void close() override
{
}
uint32_t read(void *, uint32_t) override
{
return 0;
}
void flush() override
{
}
void shutdownInput() override
{
}
void shutdownOutput() override
{
}
void *getEventTarget() const override
{
return const_cast<CapturingStream *>(this);
}
bool isReady() const override
{
return false;
}
uint32_t getSize() const override
{
return 0;
}
private:
QByteArray m_buffer;
};
std::unique_ptr<ClientProxy> makeProxy(int minor, deskflow::IStream *stream, IEventQueue *events)
{
// the 1.4 and later constructors assert the server pointer is non-null but only store it
auto *server = reinterpret_cast<Server *>(0x1);
std::unique_ptr<ClientProxy> proxy;
switch (minor) {
case 0:
proxy = std::make_unique<ClientProxy1_0>("client", stream, events);
break;
case 1:
proxy = std::make_unique<ClientProxy1_1>("client", stream, events);
break;
case 6:
proxy = std::make_unique<ClientProxy1_6>("client", stream, server, events);
break;
case 7:
proxy = std::make_unique<ClientProxy1_7>("client", stream, server, events);
break;
case 8:
proxy = std::make_unique<ClientProxy1_8>("client", stream, server, events);
break;
default:
break;
}
return proxy;
}
struct ProxyUnderTest
{
MockEventQueue events;
CapturingStream *stream = new CapturingStream;
std::unique_ptr<ClientProxy> proxy;
explicit ProxyUnderTest(int minor) : proxy(makeProxy(minor, stream, &events))
{
// drop the query-info and layout-sync messages the constructors send
stream->take();
}
};
const KeyID kKey = 0x61;
const KeyModifierMask kMask = 0;
const KeyButton kButton = 0x1e;
const int32_t kCount = 3;
const std::string kLang = "en";
} // namespace
void ClientProxyTests::initTestCase()
{
// the 1.8 constructor reads the keyboard layouts through AppUtil::instance()
static TestAppUtil appUtil;
}
// These formats are frozen because shipped third-party clients parse them byte
// for byte: Synergy 1.4 through 1.14.1 negotiate 1.4 through 1.7, Barrier and
// Input Leap negotiate 1.6, and Synergy 1.14.2 onwards and Deskflow negotiate 1.8.
void ClientProxyTests::keyDown_data()
{
QTest::addColumn<int>("minor");
QTest::addColumn<QByteArray>("expected");
QTest::newRow("1.0") << 0 << "DKDN" + QByteArray::fromHex("0061 0000");
QTest::newRow("1.1") << 1 << "DKDN" + QByteArray::fromHex("0061 0000 001e");
QTest::newRow("1.6") << 6 << "DKDN" + QByteArray::fromHex("0061 0000 001e");
QTest::newRow("1.7") << 7 << "DKDN" + QByteArray::fromHex("0061 0000 001e");
QTest::newRow("1.8") << 8 << "DKDL" + QByteArray::fromHex("0061 0000 001e 00000002") + "en";
}
void ClientProxyTests::keyDown()
{
QFETCH(int, minor);
QFETCH(QByteArray, expected);
ProxyUnderTest test(minor);
test.proxy->keyDown(kKey, kMask, kButton, kLang);
QCOMPARE(test.stream->take(), expected);
}
void ClientProxyTests::keyRepeat_data()
{
QTest::addColumn<int>("minor");
QTest::addColumn<QByteArray>("expected");
QTest::newRow("1.0") << 0 << "DKRP" + QByteArray::fromHex("0061 0000 0003");
QTest::newRow("1.1") << 1 << "DKRP" + QByteArray::fromHex("0061 0000 0003 001e");
QTest::newRow("1.6") << 6 << "DKRP" + QByteArray::fromHex("0061 0000 0003 001e");
QTest::newRow("1.7") << 7 << "DKRP" + QByteArray::fromHex("0061 0000 0003 001e");
QTest::newRow("1.8") << 8 << "DKRP" + QByteArray::fromHex("0061 0000 0003 001e 00000002") + "en";
}
void ClientProxyTests::keyRepeat()
{
QFETCH(int, minor);
QFETCH(QByteArray, expected);
ProxyUnderTest test(minor);
test.proxy->keyRepeat(kKey, kMask, kCount, kButton, kLang);
QCOMPARE(test.stream->take(), expected);
}
void ClientProxyTests::keyUp_data()
{
QTest::addColumn<int>("minor");
QTest::addColumn<QByteArray>("expected");
QTest::newRow("1.0") << 0 << "DKUP" + QByteArray::fromHex("0061 0000");
QTest::newRow("1.1") << 1 << "DKUP" + QByteArray::fromHex("0061 0000 001e");
QTest::newRow("1.6") << 6 << "DKUP" + QByteArray::fromHex("0061 0000 001e");
QTest::newRow("1.7") << 7 << "DKUP" + QByteArray::fromHex("0061 0000 001e");
QTest::newRow("1.8") << 8 << "DKUP" + QByteArray::fromHex("0061 0000 001e");
}
void ClientProxyTests::keyUp()
{
QFETCH(int, minor);
QFETCH(QByteArray, expected);
ProxyUnderTest test(minor);
test.proxy->keyUp(kKey, kMask, kButton);
QCOMPARE(test.stream->take(), expected);
}
QTEST_MAIN(ClientProxyTests)

View file

@ -0,0 +1,28 @@
/*
* Deskflow -- mouse and keyboard sharing utility
* SPDX-FileCopyrightText: (C) 2026 Synergy App Ltd
* SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception
*/
#pragma once
#include "base/Log.h"
#include <QObject>
class ClientProxyTests : public QObject
{
Q_OBJECT
private Q_SLOTS:
void initTestCase();
void keyDown_data();
void keyDown();
void keyRepeat_data();
void keyRepeat();
void keyUp_data();
void keyUp();
private:
Log m_log;
};