diff --git a/src/lib/platform/CMakeLists.txt b/src/lib/platform/CMakeLists.txt index d8af35a0e..3add0a553 100644 --- a/src/lib/platform/CMakeLists.txt +++ b/src/lib/platform/CMakeLists.txt @@ -17,6 +17,7 @@ if(UNIX AND NOT APPLE) set(CMAKE_REQUIRED_INCLUDES "${CMAKE_REQUIRED_INCLUDES};${LIBPORTAL_INCLUDE_DIRS};${GLIB2_INCLUDE_DIRS}") set(CMAKE_REQUIRED_LIBRARIES "${CMAKE_REQUIRED_LIBRARIES};${LIBPORTAL_LINK_LIBRARIES};${GLIB2_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) cmake_pop_check_state() endif() @@ -142,6 +143,8 @@ elseif(UNIX) if(LIBEI_FOUND) list(APPEND PLATFORM_SOURCES + EiClipboard.cpp + EiClipboard.h EiEventQueueBuffer.cpp EiEventQueueBuffer.h EiKeyState.cpp @@ -190,10 +193,14 @@ if(UNIX) if (HAVE_IC_RESTORE) target_compile_definitions(platform PUBLIC HAVE_LIBPORTAL_INPUTCAPTURE_RESTORE) endif() + if (HAVE_LP_CLIPBOARD) + target_compile_definitions(platform PUBLIC HAVE_LIBPORTAL_CLIPBOARD) + endif() target_include_directories(platform PUBLIC ${LIBEI_INCLUDE_DIRS} ${LIBPORTAL_INCLUDE_DIRS}) target_link_libraries( platform Qt6::DBus + Qt6::Gui ${LIBXKBCOMMON_LINK_LIBRARIES} ${GLIB2_LINK_LIBRARIES} ${LIBM_LIBRARIES} ${LIBEI_LINK_LIBRARIES} ${LIBPORTAL_LINK_LIBRARIES}) diff --git a/src/lib/platform/EiClipboard.cpp b/src/lib/platform/EiClipboard.cpp new file mode 100644 index 000000000..ad46e47b5 --- /dev/null +++ b/src/lib/platform/EiClipboard.cpp @@ -0,0 +1,102 @@ +/* + * Deskflow -- mouse and keyboard sharing utility + * SPDX-FileCopyrightText: (C) 2026 Red Hat, Inc. + * SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception + */ + +#include "platform/EiClipboard.h" +#include "base/Log.h" + +namespace deskflow { + +EiClipboard::EiClipboard(ClipboardID id) : m_id(id) +{ + open(0); + empty(); + close(); +} + +bool EiClipboard::empty() +{ + if (!m_open) { + LOG_WARN("cannot empty clipboard, not open"); + return false; + } + + // Clear all data + for (int32_t index = 0; index < static_cast(Format::TotalFormats); ++index) { + m_data[index] = ""; + m_added[index] = false; + } + + // Save time + m_timeOwned = m_time; + + // We're the owner now + m_owner = true; + + return true; +} + +void EiClipboard::add(Format format, const std::string &data) +{ + if (!m_open) { + LOG_WARN("cannot add to clipboard, not open"); + return; + } + + if (!m_owner) { + LOG_WARN("cannot add to clipboard, no owner"); + return; + } + + const auto formatID = static_cast(format); + m_data[formatID] = data; + m_added[formatID] = true; +} + +bool EiClipboard::open(Time time) const +{ + if (m_open) { + LOG_DEBUG("skipping clipboard open, already open"); + return true; + } + + m_open = true; + m_time = time; + + return true; +} + +void EiClipboard::close() const +{ + if (!m_open) { + LOG_WARN("clipboard is not open"); + } + m_open = false; +} + +EiClipboard::Time EiClipboard::getTime() const +{ + return m_timeOwned; +} + +bool EiClipboard::has(Format format) const +{ + if (!m_open) { + LOG_WARN("cannot check for clipboard format, not open"); + return false; + } + return m_added[static_cast(format)]; +} + +std::string EiClipboard::get(Format format) const +{ + if (!m_open) { + LOG_WARN("cannot get clipboard format, not open"); + return ""; + } + return m_data[static_cast(format)]; +} + +} // namespace deskflow diff --git a/src/lib/platform/EiClipboard.h b/src/lib/platform/EiClipboard.h new file mode 100644 index 000000000..1db372dc2 --- /dev/null +++ b/src/lib/platform/EiClipboard.h @@ -0,0 +1,52 @@ +/* + * Deskflow -- mouse and keyboard sharing utility + * SPDX-FileCopyrightText: (C) 2026 Red Hat, Inc. + * SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception + */ + +#pragma once + +#include "deskflow/ClipboardTypes.h" +#include "deskflow/IClipboard.h" + +namespace deskflow { + +//! EI/Portal clipboard implementation +/*! +This class implements a clipboard for EI (libei) and portal-based input capture. +It stores clipboard data in memory for the specified clipboard ID. +*/ +class EiClipboard : public IClipboard +{ +public: + explicit EiClipboard(ClipboardID id); + ~EiClipboard() override = default; + + //! Get clipboard ID + ClipboardID getID() const + { + return m_id; + } + + //! @name IClipboard overrides + //@{ + bool empty() override; + void add(Format, const std::string &data) override; + bool open(Time) const override; + void close() const override; + Time getTime() const override; + bool has(Format) const override; + std::string get(Format) const override; + //@} + +private: + ClipboardID m_id; + mutable bool m_open = false; + mutable Time m_time = 0; + bool m_owner = false; + Time m_timeOwned = 0; + bool m_added[static_cast(Format::TotalFormats)] = {false, false, false}; + std::string m_data[static_cast(Format::TotalFormats)] = {"", "", ""}; +}; + +} // namespace deskflow diff --git a/src/lib/platform/EiScreen.cpp b/src/lib/platform/EiScreen.cpp index 78faf9380..d56500dc6 100644 --- a/src/lib/platform/EiScreen.cpp +++ b/src/lib/platform/EiScreen.cpp @@ -1,8 +1,8 @@ /* * Deskflow -- mouse and keyboard sharing utility * SPDX-FileCopyrightText: (C) 2025 - 2026 Deskflow Developers - * SPDX-FileCopyrightText: (C) 2024 Synergy App Ltd - * SPDX-FileCopyrightText: (C) 2022 Red Hat, Inc. + * SPDX-FileCopyrightText: (C) 2024, 2026 Synergy App Ltd + * SPDX-FileCopyrightText: (C) 2022, 2026 Red Hat, Inc. * SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception */ @@ -14,11 +14,12 @@ #include "common/Settings.h" #include "deskflow/App.h" #include "deskflow/IScreen.h" +#include "deskflow/OptionTypes.h" +#include "platform/EiClipboard.h" #include "platform/EiEventQueueBuffer.h" #include "platform/EiKeyState.h" #include "platform/PortalInputCapture.h" #include "platform/PortalRemoteDesktop.h" -#include "platform/WlClipboardCollection.h" #include #include @@ -40,7 +41,6 @@ EiScreen::EiScreen(bool isPrimary, IEventQueue *events, bool usePortal) : PlatformScreen{events}, m_isPrimary{isPrimary}, m_events{events}, - m_clipboard{new WlClipboardCollection()}, m_w{1}, m_h{1}, m_isOnScreen{isPrimary} @@ -58,11 +58,14 @@ EiScreen::EiScreen(bool isPrimary, IEventQueue *events, bool usePortal) }); if (isPrimary) { m_portalInputCapture = new PortalInputCapture(this, m_events); + // Portal input capture manages its own clipboard } else { m_events->addHandler(EventTypes::EISessionClosed, getEventTarget(), [this](const auto &) { handlePortalSessionClosed(); }); m_portalRemoteDesktop = new PortalRemoteDesktop(this, m_events); + // Create clipboard for remote desktop (secondary screen) + m_clipboard = new EiClipboard(kClipboardClipboard); } } else { // Note: socket backend does not support reconnections @@ -71,6 +74,8 @@ EiScreen::EiScreen(bool isPrimary, IEventQueue *events, bool usePortal) LOG_ERR("ei init error: %s", strerror(-rc)); throw std::runtime_error("failed to init ei context"); } + // Create clipboard for socket backend + m_clipboard = new EiClipboard(kClipboardClipboard); } // disable sleep if the flag is set @@ -165,16 +170,21 @@ void *EiScreen::getEventTarget() const bool EiScreen::getClipboard(ClipboardID id, IClipboard *clipboard) const { - if (!m_clipboard || !m_clipboard->isAvailable()) { + // If using portal input capture, get clipboard from there + if (m_portalInputCapture) { + const auto sourceClipboard = m_portalInputCapture->getClipboard(id); + if (!sourceClipboard) { + return false; + } + return IClipboard::copy(clipboard, sourceClipboard); + } + + // Otherwise use our own clipboard + if (!m_clipboard) { return false; } - const auto sourceClipboard = m_clipboard->getClipboard(id); - if (!sourceClipboard) { - return false; - } - - return IClipboard::copy(clipboard, sourceClipboard); + return IClipboard::copy(clipboard, m_clipboard); } void EiScreen::getShape(int32_t &x, int32_t &y, int32_t &w, int32_t &h) const @@ -340,16 +350,15 @@ void EiScreen::fakeKey(uint32_t keycode, bool isDown) const void EiScreen::enable() { // Nothing really to be done here - if (m_clipboard && m_clipboard->isAvailable()) { - m_clipboard->startMonitoring(); - } + // Portal-based clipboard gets notifications via events + // Socket-based clipboard is passive (no monitoring needed) } void EiScreen::disable() { - if (m_clipboard && m_clipboard->isAvailable()) { - m_clipboard->stopMonitoring(); - } + // Nothing to do here + // Portal-based clipboard gets notifications via events + // Socket-based clipboard is passive (no monitoring needed) } void EiScreen::enter() @@ -397,32 +406,32 @@ void EiScreen::leave() bool EiScreen::setClipboard(ClipboardID id, const IClipboard *clipboard) { - if (!clipboard || !m_clipboard || !m_clipboard->isAvailable()) { + if (!clipboard) { return false; } - IClipboard *targetClipboard = m_clipboard->getClipboard(id); - if (!targetClipboard) { + // If using portal input capture, set clipboard there + if (m_portalInputCapture) { + IClipboard *targetClipboard = m_portalInputCapture->getClipboard(id); + if (!targetClipboard) { + return false; + } + return IClipboard::copy(targetClipboard, clipboard); + } + + // Otherwise use our own clipboard + if (!m_clipboard) { return false; } - return IClipboard::copy(targetClipboard, clipboard); + return IClipboard::copy(m_clipboard, clipboard); } void EiScreen::checkClipboards() { - // do nothing, we're always up to date - if (!m_clipboard || !m_clipboard->isAvailable()) { - return; - } - - if (m_clipboard->hasChanged()) { - // Send clipboard change events for all clipboard types - for (ClipboardID id = 0; id < kClipboardEnd; ++id) { - sendClipboardEvent(EventTypes::ClipboardChanged, id); - } - m_clipboard->resetChanged(); - } + // For portal-based input capture, clipboard changes come via portal events + // For socket-based, clipboard is passive and changes are sent explicitly + // Nothing to do here } void EiScreen::openScreensaver(bool notify) @@ -448,7 +457,15 @@ void EiScreen::resetOptions() void EiScreen::setOptions(const OptionsList &options) { - // We don't have ei-specific options, nothing to do here + for (auto it = options.begin(); it != options.end(); ++it) { + if (*it == kOptionClipboardSharingSize) { + ++it; + if (it == options.end()) + break; + m_maximumClipboardSize = *it; + LOG_DEBUG("ei screen received clipboard size limit: %zu KB", m_maximumClipboardSize); + } + } } bool EiScreen::isPrimary() const diff --git a/src/lib/platform/EiScreen.h b/src/lib/platform/EiScreen.h index e4dc73c26..4e95dd366 100644 --- a/src/lib/platform/EiScreen.h +++ b/src/lib/platform/EiScreen.h @@ -1,7 +1,7 @@ /* * Deskflow -- mouse and keyboard sharing utility - * SPDX-FileCopyrightText: (C) 2024 Synergy App Ltd - * SPDX-FileCopyrightText: (C) 2022 Red Hat, Inc. + * SPDX-FileCopyrightText: (C) 2024, 2026 Synergy App Ltd + * SPDX-FileCopyrightText: (C) 2022, 2026 Red Hat, Inc. * SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception */ @@ -11,6 +11,7 @@ #include "deskflow/PlatformScreen.h" #include "platform/XDGPowerManager.h" +#include #include #include #include @@ -23,10 +24,10 @@ struct ei_device; namespace deskflow { -class WlClipboardCollection; class EiKeyState; class PortalRemoteDesktop; class PortalInputCapture; +class EiClipboard; using ClipboardInfo = IScreen::ClipboardInfo; @@ -78,6 +79,15 @@ public: void setSequenceNumber(std::uint32_t) override; bool isPrimary() const override; + // Send clipboard event (needed by PortalInputCapture) + void sendClipboardEvent(EventTypes type, ClipboardID id) const; + + // Maximum clipboard size in KB, configured by the server + size_t maximumClipboardSize() const + { + return m_maximumClipboardSize; + } + protected: // IPlatformScreen overrides void handleSystemEvent(const Event &event) override; @@ -93,7 +103,6 @@ private: void initEi(); void cleanupEi(); void sendEvent(EventTypes type, void *data); - void sendClipboardEvent(EventTypes type, ClipboardID id) const; ButtonID mapButtonFromEvdev(ei_event *event) const; void onKeyEvent(ei_event *event); void onButtonEvent(ei_event *event); @@ -125,7 +134,8 @@ private: KeyID m_lastPressed = kKeyNone; // clipboard stuff - WlClipboardCollection *m_clipboard = nullptr; + EiClipboard *m_clipboard = nullptr; + size_t m_maximumClipboardSize = INT_MAX; std::vector m_eiDevices; diff --git a/src/lib/platform/PortalInputCapture.cpp b/src/lib/platform/PortalInputCapture.cpp index 09fa57bfb..71631d5f2 100644 --- a/src/lib/platform/PortalInputCapture.cpp +++ b/src/lib/platform/PortalInputCapture.cpp @@ -1,8 +1,8 @@ /* * Deskflow -- mouse and keyboard sharing utility * SPDX-FileCopyrightText: (C) 2025 - 2026 Deskflow Developers - * SPDX-FileCopyrightText: (C) 2024 Synergy App Ltd - * SPDX-FileCopyrightText: (C) 2022 Red Hat, Inc. + * SPDX-FileCopyrightText: (C) 2024, 2026 Synergy App Ltd + * SPDX-FileCopyrightText: (C) 2022, 2026 Red Hat, Inc. * SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception */ @@ -11,6 +11,8 @@ #include "base/Event.h" #include "base/Log.h" #include "base/TMethodJob.h" +#include "deskflow/ClipboardTypes.h" +#include "platform/EiClipboard.h" #ifdef HAVE_LIBPORTAL_INPUTCAPTURE_RESTORE #include "common/Settings.h" @@ -19,9 +21,19 @@ #include #include #include +#include +#include #include // for EIS fd hack, remove #include // for EIS fd hack, remove +#include +#include +#include +#include +#include +#include +#include + namespace deskflow { const char *PortalInputCapture::barrierSideName(BarrierSide side) @@ -160,6 +172,9 @@ PortalInputCapture::PortalInputCapture(EiScreen *screen, IEventQueue *events) m_portalVersion(0), m_portal{xdp_portal_new()} { + // Create clipboard for primary clipboard ID + m_clipboard = new EiClipboard(kClipboardClipboard); + m_glibMainLoop = g_main_loop_new(nullptr, true); auto tMethodJob = new TMethodJob(this, &PortalInputCapture::glibThread); @@ -192,6 +207,9 @@ PortalInputCapture::~PortalInputCapture() g_signal_handler_disconnect(m_session, m_signals.at(Activated)); g_signal_handler_disconnect(m_session, m_signals.at(Deactivated)); g_signal_handler_disconnect(m_session, m_signals.at(ZonesChanged)); + + g_signal_handler_disconnect(m_session, m_signals.at(SelectionOwnerChanged)); + g_signal_handler_disconnect(m_session, m_signals.at(SelectionTransfer)); g_object_unref(m_session); } @@ -200,6 +218,17 @@ PortalInputCapture::~PortalInputCapture() } m_barriers.clear(); g_object_unref(m_portal); + + delete m_clipboard; +} + +EiClipboard *PortalInputCapture::getClipboard(ClipboardID id) const +{ + // Currently only supporting primary clipboard + if (id == kClipboardClipboard) { + return m_clipboard; + } + return nullptr; } gboolean PortalInputCapture::timeoutHandler() const @@ -217,9 +246,227 @@ void PortalInputCapture::handleSessionClosed(XdpSession *session) m_signals.at(Signal::SessionClosed) = 0; } +QByteArray PortalInputCapture::formatMimeTypes(const char *const *mimeTypes) +{ + if (!mimeTypes || !mimeTypes[0]) + return QByteArrayLiteral("(none)"); + + QByteArrayList parts; + for (int i = 0; mimeTypes[i]; i++) + parts.append(mimeTypes[i]); + + return parts.join(", "); +} + +void PortalInputCapture::claimClipboardOwnership(XdpSession *session) +{ +#ifdef HAVE_LIBPORTAL_CLIPBOARD + constexpr size_t count = std::size(kSupportedMimes); + const char *mimeTypes[count + 1]; + for (size_t i = 0; i < count; ++i) + mimeTypes[i] = kSupportedMimes[i].mime; + mimeTypes[count] = nullptr; + + LOG_DEBUG("claiming clipboard: %s", formatMimeTypes(mimeTypes).constData()); + xdp_session_set_selection(session, mimeTypes); +#endif +} + +void PortalInputCapture::readClipboardSelection(XdpSession *session) +{ +#ifdef HAVE_LIBPORTAL_CLIPBOARD + const qint64 kClipboardReadCap = static_cast(m_screen->maximumClipboardSize()) * 1024; + LOG_DEBUG("clipboard read cap: %lld bytes", static_cast(kClipboardReadCap)); + + const char **mimeTypes = xdp_session_get_selection_mime_types(session); + if (!mimeTypes) { + LOG_DEBUG("clipboard has no mime types available to read"); + return; + } + + if (!pickSupportedMime(mimeTypes)) { + LOG_DEBUG( + "clipboard no supported mime type in selection, available types: %s", formatMimeTypes(mimeTypes).constData() + ); + return; + } + + std::vector> reads; + std::set seen; + for (const auto &entry : kSupportedMimes) { + if (seen.count(entry.format)) + continue; + if (!g_strv_contains(mimeTypes, entry.mime)) + continue; + + LOG_DEBUG("clipboard reading selection for mime type: %s", entry.mime); + QByteArray bytes = readSelectionBytes(session, entry.mime, kClipboardReadCap); + if (bytes.isEmpty()) { + LOG_WARN("clipboard read returned no data for mime type: %s", entry.mime); + continue; + } + + if (entry.format != IClipboard::Format::Bitmap) { + while (bytes.endsWith('\0')) + bytes.chop(1); + bytes.replace("\r\n", "\n"); + } + + std::string data = decodeFormat(entry.format, bytes); + if (data.empty()) + continue; + + reads.emplace_back(entry.format, std::move(data)); + seen.insert(entry.format); + } + + if (reads.empty()) { + LOG_DEBUG("clipboard read produced no data, leaving existing clipboard intact"); + return; + } + + m_clipboard->open(0); + m_clipboard->empty(); + for (const auto &[format, data] : reads) + m_clipboard->add(format, data); + m_clipboard->close(); + + m_screen->sendClipboardEvent(EventTypes::ClipboardGrabbed, kClipboardClipboard); +#else + (void)session; +#endif +} + +void PortalInputCapture::handleSelectionOwnerChanged(XdpSession *session, GStrv mimeTypes, gboolean isOwner) +{ +#ifdef HAVE_LIBPORTAL_CLIPBOARD + LOG_DEBUG( + "selection owner changed, session owns: %s, mime types: %s", isOwner ? "yes" : "no", + mimeTypes ? formatMimeTypes(const_cast(mimeTypes)).constData() : "(none)" + ); + + if (isOwner) { + LOG_DEBUG("ignoring selection owner change, session already owns the selection"); + return; + } + if (!mimeTypes) { + LOG_DEBUG("ignoring selection owner change, selection cleared"); + return; + } + + if (!pickSupportedMime(mimeTypes)) { + LOG_DEBUG("ignoring selection owner change, no supported mime types: %s", formatMimeTypes(mimeTypes).constData()); + return; + } + + m_screen->sendClipboardEvent(EventTypes::ClipboardGrabbed, kClipboardClipboard); + + if (!m_isActive) { + LOG_DEBUG("deferring clipboard read until input capture activates"); + m_pendingClipboardRead = true; + return; + } + + readClipboardSelection(session); +#endif +} + +void PortalInputCapture::handleSelectionTransfer(XdpSession *session, const char *mimeType, uint32_t serial) +{ +#ifdef HAVE_LIBPORTAL_CLIPBOARD + constexpr int kClipboardWriteTimeoutMs = 200; + + LOG_DEBUG("clipboard selection transfer requested, mime type: %s, serial: %u", mimeType, serial); + + const auto *requested = findSupportedMime(mimeType); + if (!requested) { + LOG_DEBUG("rejecting clipboard selection transfer, unsupported mime type: %s", mimeType); + xdp_session_selection_write_done(session, serial, false); + return; + } + + int fd = xdp_session_selection_write(session, serial); + if (fd < 0) { + LOG_WARN("failed to write clipboard content: invalid fd"); + xdp_session_selection_write_done(session, serial, false); + return; + } + + m_clipboard->open(0); + std::string raw; + const bool hasFormat = m_clipboard->has(requested->format); + if (hasFormat) + raw = m_clipboard->get(requested->format); + m_clipboard->close(); + + LOG_DEBUG( + "clipboard format requested: %d, has: %d, raw bytes: %zu", static_cast(requested->format), hasFormat, + raw.size() + ); + + QByteArray data = encodeFormat(requested->format, raw); + + LOG_DEBUG("writing clipboard content, mime type: %s, bytes: %lld", mimeType, static_cast(data.size())); + + QFile pipe; + if (!pipe.open(fd, QIODevice::WriteOnly, QFileDevice::AutoCloseHandle)) { + LOG_WARN("failed to wrap clipboard pipe"); + ::close(fd); + xdp_session_selection_write_done(session, serial, false); + return; + } + + const char *buf = data.constData(); + qint64 total = data.size(); + qint64 written = 0; + + while (written < total) { + pollfd pfd{fd, POLLOUT, 0}; + if (poll(&pfd, 1, kClipboardWriteTimeoutMs) <= 0) { + LOG_ERR("timed out writing clipboard selection"); + xdp_session_selection_write_done(session, serial, false); + return; + } + qint64 n = pipe.write(buf + written, total - written); + if (n < 0) { + LOG_ERR("failed to write clipboard selection"); + xdp_session_selection_write_done(session, serial, false); + return; + } + if (n == 0) { + LOG_ERR("clipboard pipe accepted no bytes"); + xdp_session_selection_write_done(session, serial, false); + return; + } + written += n; + } + + xdp_session_selection_write_done(session, serial, true); + LOG_DEBUG("clipboard write complete"); + +#endif +} + void PortalInputCapture::setupSession(XdpInputCaptureSession *session) { g_autoptr(GError) error = nullptr; + XdpSession *parentSession = xdp_input_capture_session_get_session(session); + +#ifdef HAVE_LIBPORTAL_CLIPBOARD + if (!xdp_session_is_clipboard_enabled(parentSession)) { + // Restored sessions can pre-date clipboard support, leaving the channel + // disabled even though we requested it. Drop the saved token and recreate + // the session from scratch so the user gets a fresh permission dialog. + LOG_WARN("clipboard not enabled on session, discarding restore token to force a fresh session"); +#ifdef HAVE_LIBPORTAL_INPUTCAPTURE_RESTORE + Settings::setValue(Settings::Server::XdpRestoreToken, QString()); +#endif + g_object_unref(m_session); + m_session = nullptr; + g_idle_add([](gpointer data) { return static_cast(data)->initSession(); }, this); + return; + } +#endif auto fd = xdp_input_capture_session_connect_to_eis(session, &error); if (fd < 0) { @@ -233,13 +480,19 @@ void PortalInputCapture::setupSession(XdpInputCaptureSession *session) m_events->addEvent(Event(EventTypes::EIConnected, m_screen->getEventTarget(), EiScreen::EiConnectInfo::alloc(fd))); using enum Signal; - XdpSession *parentSession = xdp_input_capture_session_get_session(session); m_signals.at(Disabled) = g_signal_connect(G_OBJECT(session), "disabled", G_CALLBACK(disabled), this); - m_signals.at(Activated) = g_signal_connect(G_OBJECT(m_session), "activated", G_CALLBACK(activated), this); - m_signals.at(Deactivated) = g_signal_connect(G_OBJECT(m_session), "deactivated", G_CALLBACK(deactivated), this); - m_signals.at(ZonesChanged) = g_signal_connect(G_OBJECT(m_session), "zones-changed", G_CALLBACK(zonesChanged), 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(ZonesChanged) = g_signal_connect(G_OBJECT(session), "zones-changed", G_CALLBACK(zonesChanged), this); m_signals.at(SessionClosed) = g_signal_connect(G_OBJECT(parentSession), "closed", G_CALLBACK(sessionClosed), this); - handleZonesChanged(m_session, nullptr); + handleZonesChanged(session, nullptr); + +#ifdef HAVE_LIBPORTAL_CLIPBOARD + m_signals.at(SelectionOwnerChanged) = + g_signal_connect(G_OBJECT(parentSession), "selection-owner-changed", G_CALLBACK(selectionOwnerChanged), this); + m_signals.at(SelectionTransfer) = + g_signal_connect(G_OBJECT(parentSession), "selection-transfer", G_CALLBACK(selectionTransfer), this); +#endif } void PortalInputCapture::handleInitSession(GObject *object, GAsyncResult *res) @@ -507,6 +760,7 @@ gboolean PortalInputCapture::initSession() return FALSE; } m_session = session; + xdp_session_request_clipboard(xdp_input_capture_session_get_session(session)); xdp_input_capture_session_set_session_persistence(session, XDP_INPUT_CAPTURE_SESSION_PERSISTENCE_PERSISTENT); if (auto sessionToken = Settings::value(Settings::Server::XdpRestoreToken).toByteArray(); !sessionToken.isEmpty()) { xdp_input_capture_session_set_restore_token(session, strdup(sessionToken.data())); @@ -638,6 +892,30 @@ void PortalInputCapture::handleActivated( } m_activationId = activationId; m_isActive = true; + +#ifdef HAVE_LIBPORTAL_CLIPBOARD + if (m_session) { + m_pendingClipboardRead = false; + LOG_DEBUG("reading clipboard selection on activation"); + m_screen->sendClipboardEvent(EventTypes::ClipboardGrabbed, kClipboardClipboard); + + XdpSession *session = xdp_input_capture_session_get_session(m_session); + const char **mimeTypes = xdp_session_get_selection_mime_types(session); + + if (mimeTypes && mimeTypes[0]) { + LOG_DEBUG("clipboard current selection mime types: %s", formatMimeTypes(mimeTypes).constData()); + if (!xdp_session_is_selection_owned_by_session(session)) + readClipboardSelection(session); + } else { + LOG_DEBUG("no current clipboard selection"); + } + + claimClipboardOwnership(session); + LOG_DEBUG("activation clipboard handling complete"); + } else { + LOG_WARN("input capture activated without a session, skipping clipboard read"); + } +#endif } void PortalInputCapture::handleDeactivated( @@ -721,4 +999,149 @@ void PortalInputCapture::glibThread(const void *) LOG_DEBUG("shutting down glib thread"); } +#ifdef HAVE_LIBPORTAL_CLIPBOARD + +const PortalInputCapture::SupportedMime *PortalInputCapture::findSupportedMime(const char *mime) +{ + if (!mime) + return nullptr; + + for (const auto &entry : kSupportedMimes) { + if (g_strcmp0(mime, entry.mime) == 0) + return &entry; + } + + return nullptr; +} + +const PortalInputCapture::SupportedMime *PortalInputCapture::pickSupportedMime(const char *const *available) +{ + if (!available) { + LOG_WARN("cannot pick mime type, no mime types provided"); + return nullptr; + } + + for (const auto &entry : kSupportedMimes) { + if (g_strv_contains(available, entry.mime)) + return &entry; + } + + return nullptr; +} + +QByteArray PortalInputCapture::dibToBmp(const std::string &dib) +{ + if (dib.size() < sizeof(quint32)) + return {}; + quint32 headerSize; + std::memcpy(&headerSize, dib.data(), sizeof(headerSize)); + headerSize = qFromLittleEndian(headerSize); + if (headerSize < 12 || headerSize > dib.size()) + return {}; + + const quint32 fileSize = static_cast(14 + dib.size()); + const quint32 pixelOffset = 14 + headerSize; + + QByteArray bmp; + QDataStream ds(&bmp, QIODevice::WriteOnly); + ds.setByteOrder(QDataStream::LittleEndian); + ds.writeRawData("BM", 2); + ds << fileSize; + ds << quint32(0); + ds << pixelOffset; + ds.writeRawData(dib.data(), static_cast(dib.size())); + return bmp; +} + +std::string PortalInputCapture::bmpToDib(const QByteArray &bmp) +{ + if (bmp.size() < 14) + return {}; + return std::string(bmp.constData() + 14, bmp.size() - 14); +} + +QByteArray PortalInputCapture::encodeFormat(IClipboard::Format format, const std::string &data) +{ + if (data.empty()) + return {}; + if (format == IClipboard::Format::Bitmap) { + QByteArray bmpFile = dibToBmp(data); + if (bmpFile.isEmpty()) { + LOG_WARN("clipboard bitmap data is malformed"); + return {}; + } + QImage image; + if (!image.loadFromData(bmpFile, "BMP")) { + LOG_WARN("failed to decode clipboard bitmap"); + return {}; + } + QByteArray png; + QBuffer buf(&png); + buf.open(QIODevice::WriteOnly); + if (!image.save(&buf, "PNG")) { + LOG_WARN("failed to encode clipboard image as png"); + return {}; + } + return png; + } + return QByteArray::fromStdString(data); +} + +std::string PortalInputCapture::decodeFormat(IClipboard::Format format, const QByteArray &bytes) +{ + if (bytes.isEmpty()) + return {}; + if (format == IClipboard::Format::Bitmap) { + QImage image; + if (!image.loadFromData(bytes, "PNG")) { + LOG_WARN("failed to decode clipboard png"); + return {}; + } + QByteArray bmp; + QBuffer buf(&bmp); + buf.open(QIODevice::WriteOnly); + if (!image.save(&buf, "BMP")) { + LOG_WARN("failed to encode clipboard image as bmp"); + return {}; + } + return bmpToDib(bmp); + } + return bytes.toStdString(); +} + +QByteArray PortalInputCapture::readSelectionBytes(XdpSession *session, const char *mime, qint64 maxBytes) +{ + constexpr int kClipboardReadTimeoutMs = 200; + constexpr qint64 kInitialReserveBytes = 64 * 1024; + + int fd = xdp_session_selection_read(session, mime); + if (fd < 0) { + LOG_ERR("failed to read clipboard selection: invalid fd"); + return {}; + } + + QFile pipe; + if (!pipe.open(fd, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) { + LOG_WARN("failed to wrap clipboard pipe"); + ::close(fd); + return {}; + } + + QByteArray contents; + contents.reserve(std::min(maxBytes, kInitialReserveBytes)); + while (contents.size() < maxBytes) { + pollfd pfd{fd, POLLIN, 0}; + if (poll(&pfd, 1, kClipboardReadTimeoutMs) <= 0) + break; + QByteArray chunk = pipe.read(maxBytes - contents.size()); + if (chunk.isEmpty()) + break; + contents.append(chunk); + } + + return contents; +} + +#endif // HAVE_LIBPORTAL_CLIPBOARD + } // namespace deskflow diff --git a/src/lib/platform/PortalInputCapture.h b/src/lib/platform/PortalInputCapture.h index dfe1a9e97..ecd6984a8 100644 --- a/src/lib/platform/PortalInputCapture.h +++ b/src/lib/platform/PortalInputCapture.h @@ -1,16 +1,19 @@ /* * Deskflow -- mouse and keyboard sharing utility * SPDX-FileCopyrightText: (C) 2025 - 2026 Deskflow Developers - * SPDX-FileCopyrightText: (C) 2024 Synergy App Ltd - * SPDX-FileCopyrightText: (C) 2022 Red Hat, Inc. + * SPDX-FileCopyrightText: (C) 2024, 2026 Synergy App Ltd + * SPDX-FileCopyrightText: (C) 2022, 2026 Red Hat, Inc. * SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception */ #pragma once +#include "deskflow/IClipboard.h" #include "mt/Thread.h" #include "platform/EiScreen.h" +#include + #include #include #include @@ -22,11 +25,16 @@ namespace deskflow { +class EiClipboard; + class PortalInputCapture { public: PortalInputCapture(EiScreen *screen, IEventQueue *events); ~PortalInputCapture(); + + // Get the clipboard for the specified ID + EiClipboard *getClipboard(ClipboardID id) const; void enable(); void disable(); void release(); @@ -51,6 +59,33 @@ private: handleDeactivated(const XdpInputCaptureSession *session, const std::uint32_t activationId, const GVariant *options); void handleZonesChanged(XdpInputCaptureSession *session, const GVariant *options); + void handleSelectionOwnerChanged(XdpSession *session, GStrv mimeTypes, gboolean isOwner); + void handleSelectionTransfer(XdpSession *session, const char *mimeType, uint32_t serial); + void readClipboardSelection(XdpSession *session); + void claimClipboardOwnership(XdpSession *session); + + struct SupportedMime + { + const char *mime; + IClipboard::Format format; + }; + + // Listed in preference order: richer formats first. + static constexpr SupportedMime kSupportedMimes[] = { + {"image/png", IClipboard::Format::Bitmap}, + {"text/plain;charset=utf-8", IClipboard::Format::Text}, + {"text/plain", IClipboard::Format::Text}, + }; + + static QByteArray formatMimeTypes(const char *const *mimeTypes); + static const SupportedMime *findSupportedMime(const char *mime); + static const SupportedMime *pickSupportedMime(const char *const *available); + static QByteArray dibToBmp(const std::string &dib); + static std::string bmpToDib(const QByteArray &bmp); + static QByteArray encodeFormat(IClipboard::Format format, const std::string &data); + static std::string decodeFormat(IClipboard::Format format, const QByteArray &bytes); + static QByteArray readSelectionBytes(XdpSession *session, const char *mime, qint64 maxBytes); + /// g_signal_connect callback wrapper static void sessionClosed(XdpSession *session, const gpointer data) { @@ -77,6 +112,14 @@ private: { static_cast(data)->handleZonesChanged(session, options); } + static void selectionOwnerChanged(XdpSession *session, GStrv mimeTypes, gboolean isOwner, const gpointer data) + { + static_cast(data)->handleSelectionOwnerChanged(session, mimeTypes, isOwner); + } + static void selectionTransfer(XdpSession *session, const char *mimeType, uint32_t serial, const gpointer data) + { + static_cast(data)->handleSelectionTransfer(session, mimeType, serial); + } private: enum class Signal : uint8_t @@ -85,7 +128,11 @@ private: Disabled, Activated, Deactivated, - ZonesChanged + ZonesChanged, + + // Clipboard signals + SelectionOwnerChanged, + SelectionTransfer, }; enum class BarrierSide : uint8_t @@ -141,19 +188,21 @@ private: XdpInputCaptureSession *m_session = nullptr; std::map m_signals = { - {Signal::SessionClosed, 0}, - {Signal::Disabled, 0}, - {Signal::Activated, 0}, - {Signal::Deactivated, 0}, - {Signal::ZonesChanged, 0} + {Signal::SessionClosed, 0}, {Signal::Disabled, 0}, {Signal::Activated, 0}, + {Signal::Deactivated, 0}, {Signal::ZonesChanged, 0}, + + {Signal::SelectionOwnerChanged, 0}, {Signal::SelectionTransfer, 0}, }; bool m_enabled = false; bool m_isActive = false; + bool m_pendingClipboardRead = false; std::uint32_t m_activationId = 0; std::vector m_barriers; std::vector m_barrierInfo; + + EiClipboard *m_clipboard = nullptr; }; } // namespace deskflow