From d7d3f8d4c89d51f9eee102486649ea57b121d4fd Mon Sep 17 00:00:00 2001 From: Peter Hutterer Date: Thu, 5 Feb 2026 14:01:33 +1000 Subject: [PATCH] feat: inputcapture clipboard support Send the clipboard over the libportal InputCapture portal. Only built if the installed libportal has clipboard support. AI disclosure: Claude was used on both sides of this work. - Peter Hutterer: "The second commit was purely written by Claude after I stared at this for too long without seeing any hope of getting started. Turns out Claude wrote something that at least looks correct and it does work. It needs review but also needs knowledge of deskflow's internals that I don't have..." - Nick Bolton: Claude also assisted with the follow-up work listed below (image copy/paste, session re-request, race fixes, size/mime validation, and CMake detection). Server side (Wayland host, Peter Hutterer): - Initial scaffolding for inputcapture clipboard support, guarded so it only builds when the portal exposes the clipboard API. - Integrate clipboard data into the inputcapture session so the host clipboard can be offered to the remote (the Claude-written commit referenced above). Client side (Wayland receiver, KoljaFrahm): - Receive clipboard data from the server and feed it into the local clipboard through libportal. Built on the reference code by Peter: https://github.com/whot/libportal-clipboard-test/tree/main Build integration and rebase (Chris Rizzitello, sithlord48): - Add the original CMake check for libportal/clipboard.h to define HAVE_LIBPORTAL_CLIPBOARD, and guard the libportal clipboard use on it. - Update the code style and rebase onto the updated input capture session persistence: https://github.com/deskflow/deskflow/pull/9415 Image support and reliability fixes (Nick Bolton): - Support image copy/paste in addition to text, so screenshots and other image clipboard contents work both ways through inputcapture. - Re-request the input capture session when it comes up without a clipboard, so new testers with existing sessions can get clipboard functionality without needing to clear the session. - Defer clipboard reads until the input capture session is active, avoiding races where reads happen before the portal is ready. - Fix assorted style issues in original commit to match conventions. - Check the configured clipboard size limit and validate incoming mime types before forwarding inputcapture clipboard data. - Switch the CMake clipboard probe to check_symbol_exists so detection matches what the compiler actually sees in the headers. Co-authored-by: Nick Bolton Co-authored-by: KoljaFrahm Co-authored-by: Chris Rizzitello --- src/lib/platform/CMakeLists.txt | 7 + src/lib/platform/EiClipboard.cpp | 102 ++++++ src/lib/platform/EiClipboard.h | 52 +++ src/lib/platform/EiScreen.cpp | 85 +++-- src/lib/platform/EiScreen.h | 20 +- src/lib/platform/PortalInputCapture.cpp | 437 +++++++++++++++++++++++- src/lib/platform/PortalInputCapture.h | 65 +++- 7 files changed, 714 insertions(+), 54 deletions(-) create mode 100644 src/lib/platform/EiClipboard.cpp create mode 100644 src/lib/platform/EiClipboard.h 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