From 03a4800677506f1daba9697e0f84065a9b70f441 Mon Sep 17 00:00:00 2001 From: Nick Bolton Date: Tue, 19 May 2026 20:01:38 +0100 Subject: [PATCH] feat: clipboard support for portal remote desktop Add libportal clipboard handling to the remote desktop. Supports both directions (server to Wayland client paste, and copy from the Wayland client back to the server) for UTF-8 text, plain text, and PNG/BMP images. The shared mime-type table, BMP/DIB and PNG encode/decode helpers, and portal selection read/write live in a new PortalClipboard helper used by both PortalInputCapture and PortalRemoteDesktop. If the portal returns a restored RemoteDesktop session without clipboard enabled (a token persisted before clipboard support existed), discard the restore token and reconnect so the user gets a fresh permission dialog. Otherwise the session would loop with a clipboard-less token forever. --- src/lib/platform/CMakeLists.txt | 6 + src/lib/platform/EiScreen.cpp | 8 +- src/lib/platform/EiScreen.h | 6 + src/lib/platform/PortalClipboard.cpp | 332 +++++++++++++++++++++++ src/lib/platform/PortalClipboard.h | 63 +++++ src/lib/platform/PortalInputCapture.cpp | 307 ++------------------- src/lib/platform/PortalInputCapture.h | 25 +- src/lib/platform/PortalRemoteDesktop.cpp | 89 +++++- src/lib/platform/PortalRemoteDesktop.h | 19 +- 9 files changed, 537 insertions(+), 318 deletions(-) create mode 100644 src/lib/platform/PortalClipboard.cpp create mode 100644 src/lib/platform/PortalClipboard.h diff --git a/src/lib/platform/CMakeLists.txt b/src/lib/platform/CMakeLists.txt index 3add0a553..a71be51e7 100644 --- a/src/lib/platform/CMakeLists.txt +++ b/src/lib/platform/CMakeLists.txt @@ -164,6 +164,12 @@ elseif(UNIX) PortalRemoteDesktop.cpp PortalRemoteDesktop.h ) + if(HAVE_LP_CLIPBOARD) + list(APPEND PLATFORM_SOURCES + PortalClipboard.cpp + PortalClipboard.h + ) + endif() endif() endif() endif() diff --git a/src/lib/platform/EiScreen.cpp b/src/lib/platform/EiScreen.cpp index d56500dc6..c6b0facc2 100644 --- a/src/lib/platform/EiScreen.cpp +++ b/src/lib/platform/EiScreen.cpp @@ -424,7 +424,13 @@ bool EiScreen::setClipboard(ClipboardID id, const IClipboard *clipboard) return false; } - return IClipboard::copy(m_clipboard, clipboard); + bool ok = IClipboard::copy(m_clipboard, clipboard); + + if (ok && m_portalRemoteDesktop && id == kClipboardClipboard) { + m_portalRemoteDesktop->claimClipboard(); + } + + return ok; } void EiScreen::checkClipboards() diff --git a/src/lib/platform/EiScreen.h b/src/lib/platform/EiScreen.h index 4e95dd366..08a5add11 100644 --- a/src/lib/platform/EiScreen.h +++ b/src/lib/platform/EiScreen.h @@ -82,6 +82,12 @@ public: // Send clipboard event (needed by PortalInputCapture) void sendClipboardEvent(EventTypes type, ClipboardID id) const; + // Local clipboard cache (used by PortalRemoteDesktop to land selection reads) + EiClipboard *getClipboardCache() const + { + return m_clipboard; + } + // Maximum clipboard size in KB, configured by the server size_t maximumClipboardSize() const { diff --git a/src/lib/platform/PortalClipboard.cpp b/src/lib/platform/PortalClipboard.cpp new file mode 100644 index 000000000..72f375f8e --- /dev/null +++ b/src/lib/platform/PortalClipboard.cpp @@ -0,0 +1,332 @@ +/* + * 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 "platform/PortalClipboard.h" + +#include "base/Log.h" +#include "platform/EiClipboard.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace deskflow { + +static constexpr int kBmpSignatureSize = 2; +static constexpr quint32 kBmpFileHeaderSize = 14; +static constexpr quint32 kMinDibHeaderSize = 12; + +QByteArray PortalClipboard::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(", "); +} + +const PortalClipboard::SupportedMime *PortalClipboard::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 PortalClipboard::SupportedMime *PortalClipboard::pickSupportedMime(const char *const *available) +{ + if (!available) + return nullptr; + + for (const auto &entry : kSupportedMimes) { + if (g_strv_contains(available, entry.mime)) + return &entry; + } + + return nullptr; +} + +QByteArray PortalClipboard::dibToBmp(const QByteArray &dib) +{ + if (dib.size() < static_cast(sizeof(quint32))) + return {}; + + quint32 headerSize; + std::memcpy(&headerSize, dib.constData(), sizeof(headerSize)); + headerSize = qFromLittleEndian(headerSize); + if (headerSize < kMinDibHeaderSize || headerSize > static_cast(dib.size())) + return {}; + + const auto fileSize = static_cast(kBmpFileHeaderSize + dib.size()); + const quint32 pixelOffset = kBmpFileHeaderSize + headerSize; + + QByteArray bmp; + QDataStream ds(&bmp, QIODevice::WriteOnly); + ds.setByteOrder(QDataStream::LittleEndian); + ds.writeRawData("BM", kBmpSignatureSize); + ds << fileSize; + ds << quint32(0); + ds << pixelOffset; + ds.writeRawData(dib.constData(), static_cast(dib.size())); + return bmp; +} + +QByteArray PortalClipboard::bmpToDib(const QByteArray &bmp) +{ + if (bmp.size() < kBmpFileHeaderSize) + return {}; + + return bmp.mid(kBmpFileHeaderSize); +} + +QByteArray PortalClipboard::encodeFormat(IClipboard::Format format, const QByteArray &data) +{ + if (data.isEmpty()) + return {}; + + if (format == IClipboard::Format::Bitmap) { + const auto 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 data; +} + +QByteArray PortalClipboard::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; +} + +QByteArray PortalClipboard::readSelectionBytes(XdpSession *session, const char *mime, qint64 maxBytes) +{ + const 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, kChunkBytes)); + while (contents.size() < maxBytes) { + pollfd pfd{fd, POLLIN, 0}; + if (poll(&pfd, 1, kReadTimeoutMs) <= 0) + break; + + const auto chunk = pipe.read(std::min(kChunkBytes, maxBytes - contents.size())); + if (chunk.isEmpty()) + break; + + contents.append(chunk); + } + return contents; +} + +void PortalClipboard::claimOwnership(EiClipboard *cache, XdpSession *session) +{ + if (!cache || !session) + return; + + cache->open(0); + QVarLengthArray mimeTypes; + for (const auto &entry : kSupportedMimes) { + if (cache->has(entry.format)) + mimeTypes.append(entry.mime); + } + cache->close(); + + if (mimeTypes.isEmpty()) { + LOG_DEBUG("clipboard cache empty, nothing to claim"); + return; + } + mimeTypes.append(nullptr); + + LOG_DEBUG("claiming clipboard, mimes: %s", formatMimeTypes(mimeTypes.data()).constData()); + xdp_session_set_selection(session, mimeTypes.data()); +} + +void PortalClipboard::serveSelectionTransfer(EiClipboard *cache, XdpSession *session, const char *mime, uint32_t serial) +{ + LOG_DEBUG("clipboard selection transfer requested, mime: %s, serial: %u", mime, serial); + + const auto *requested = findSupportedMime(mime); + if (!requested || !cache) { + LOG_DEBUG("rejecting clipboard selection, unsupported mime: %s", mime); + xdp_session_selection_write_done(session, serial, false); + return; + } + + cache->open(0); + QByteArray raw; + const bool hasFormat = cache->has(requested->format); + if (hasFormat) + raw = QByteArray::fromStdString(cache->get(requested->format)); + cache->close(); + + const auto data = encodeFormat(requested->format, raw); + if (data.isEmpty()) { + LOG_DEBUG("clipboard has no data for mime: %s", mime); + xdp_session_selection_write_done(session, serial, false); + return; + } + + const int fd = xdp_session_selection_write(session, serial); + if (fd < 0) { + LOG_WARN("failed to open clipboard selection write fd"); + xdp_session_selection_write_done(session, serial, false); + return; + } + + 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, kWriteTimeoutMs) <= 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("clipboard pipe write returned %lld", static_cast(n)); + xdp_session_selection_write_done(session, serial, false); + return; + } + written += n; + } + + xdp_session_selection_write_done(session, serial, true); + LOG_DEBUG("clipboard selection transfer complete, bytes: %lld", static_cast(written)); +} + +bool PortalClipboard::readSelectionIntoCache( + EiClipboard *cache, XdpSession *session, const char *const *mimeTypes, qint64 maxBytes +) +{ + if (!cache || !session || !mimeTypes || !mimeTypes[0]) + return false; + + if (!pickSupportedMime(mimeTypes)) { + LOG_DEBUG("clipboard no supported mime types: %s", formatMimeTypes(mimeTypes).constData()); + return false; + } + + QList> reads; + QSet seen; + for (const auto &entry : kSupportedMimes) { + if (seen.contains(entry.format)) + continue; + if (!g_strv_contains(mimeTypes, entry.mime)) + continue; + + auto bytes = readSelectionBytes(session, entry.mime, maxBytes); + if (bytes.isEmpty()) { + LOG_DEBUG("clipboard read returned no data for mime: %s", entry.mime); + continue; + } + + if (entry.format != IClipboard::Format::Bitmap) { + while (bytes.endsWith('\0')) + bytes.chop(1); + bytes.replace("\r\n", "\n"); + } + + auto data = decodeFormat(entry.format, bytes); + if (data.isEmpty()) + continue; + + reads.append({entry.format, std::move(data)}); + seen.insert(entry.format); + } + + if (reads.isEmpty()) { + LOG_DEBUG("clipboard read produced no data, leaving existing clipboard intact"); + return false; + } + + cache->open(0); + cache->empty(); + for (const auto &[format, data] : reads) + cache->add(format, data.toStdString()); + cache->close(); + + LOG_DEBUG("clipboard read local selection, formats: %lld", static_cast(reads.size())); + return true; +} + +} // namespace deskflow diff --git a/src/lib/platform/PortalClipboard.h b/src/lib/platform/PortalClipboard.h new file mode 100644 index 000000000..29229fee2 --- /dev/null +++ b/src/lib/platform/PortalClipboard.h @@ -0,0 +1,63 @@ +/* + * 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 "deskflow/IClipboard.h" + +#include + +#include + +namespace deskflow { + +class EiClipboard; + +class PortalClipboard +{ +public: + 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 constexpr int kReadTimeoutMs = 200; + static constexpr int kWriteTimeoutMs = 200; + static constexpr qint64 kChunkBytes = 64 * 1024; + + static QByteArray formatMimeTypes(const char *const *mimeTypes); + static const SupportedMime *findSupportedMime(const char *mime); + static const SupportedMime *pickSupportedMime(const char *const *available); + static QByteArray encodeFormat(IClipboard::Format format, const QByteArray &data); + static QByteArray decodeFormat(IClipboard::Format format, const QByteArray &bytes); + static QByteArray readSelectionBytes(XdpSession *session, const char *mime, qint64 maxBytes); + + /// Advertise the cache's formats to the portal selection. + static void claimOwnership(EiClipboard *cache, XdpSession *session); + + /// Respond to a selection-transfer signal by writing the cache's bytes for + /// \p mime to the portal-provided fd. Always calls write_done. + static void serveSelectionTransfer(EiClipboard *cache, XdpSession *session, const char *mime, uint32_t serial); + + /// Read every supported format offered by the portal into the cache. + /// Returns true if any data was deposited. + static bool + readSelectionIntoCache(EiClipboard *cache, XdpSession *session, const char *const *mimeTypes, qint64 maxBytes); + +private: + static QByteArray dibToBmp(const QByteArray &dib); + static QByteArray bmpToDib(const QByteArray &bmp); +}; + +} // namespace deskflow diff --git a/src/lib/platform/PortalInputCapture.cpp b/src/lib/platform/PortalInputCapture.cpp index 96aa8a68e..27786eea2 100644 --- a/src/lib/platform/PortalInputCapture.cpp +++ b/src/lib/platform/PortalInputCapture.cpp @@ -1,6 +1,6 @@ /* * Deskflow -- mouse and keyboard sharing utility - * SPDX-FileCopyrightText: (C) 2025 - 2026 Deskflow Developers + * SPDX-FileCopyrightText: (C) 2024 - 2026 Deskflow Developers * 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,6 +14,10 @@ #include "deskflow/ClipboardTypes.h" #include "platform/EiClipboard.h" +#ifdef HAVE_LIBPORTAL_CLIPBOARD +#include "platform/PortalClipboard.h" +#endif + #ifdef HAVE_LIBPORTAL_INPUTCAPTURE_RESTORE #include "common/Settings.h" #endif @@ -22,7 +26,6 @@ #include #include #include -#include #include // for EIS fd hack, remove #include // for EIS fd hack, remove @@ -244,37 +247,18 @@ 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); + PortalClipboard::claimOwnership(m_clipboard, session); #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 qint64 maxBytes = static_cast(m_screen->maximumClipboardSize()) * 1024; + LOG_DEBUG("clipboard read cap: %lld bytes", static_cast(maxBytes)); const char **mimeTypes = xdp_session_get_selection_mime_types(session); if (!mimeTypes) { @@ -282,54 +266,8 @@ void PortalInputCapture::readClipboardSelection(XdpSession *session) 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); + if (PortalClipboard::readSelectionIntoCache(m_clipboard, session, mimeTypes, maxBytes)) + m_screen->sendClipboardEvent(EventTypes::ClipboardGrabbed, kClipboardClipboard); #else (void)session; #endif @@ -338,81 +276,15 @@ void PortalInputCapture::readClipboardSelection(XdpSession *session) void PortalInputCapture::handleSelectionTransfer(XdpSession *session, const char *mimeType, uint32_t serial) { #ifdef HAVE_LIBPORTAL_CLIPBOARD - constexpr int kClipboardWriteTimeoutMs = 200; - if (m_isActive) { LOG_DEBUG("skipping clipboard selection transfer, clipboard is active"); return; } - - 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"); - + PortalClipboard::serveSelectionTransfer(m_clipboard, session, mimeType, serial); +#else + (void)session; + (void)mimeType; + (void)serial; #endif } @@ -422,7 +294,7 @@ void PortalInputCapture::setupSession(XdpInputCaptureSession *session) XdpSession *parentSession = xdp_input_capture_session_get_session(session); #ifdef HAVE_LIBPORTAL_CLIPBOARD - if (!xdp_session_is_clipboard_enabled(parentSession)) { + if (!xdp_session_is_clipboard_enabled(parentSession) && m_portalVersion > 1) { // 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. @@ -869,7 +741,7 @@ void PortalInputCapture::handleActivated( 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()); + LOG_DEBUG("clipboard current selection mime types: %s", PortalClipboard::formatMimeTypes(mimeTypes).constData()); if (!xdp_session_is_selection_owned_by_session(session)) readClipboardSelection(session); } else { @@ -965,149 +837,4 @@ 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 bbde0a059..b67c16642 100644 --- a/src/lib/platform/PortalInputCapture.h +++ b/src/lib/platform/PortalInputCapture.h @@ -1,6 +1,6 @@ /* * Deskflow -- mouse and keyboard sharing utility - * SPDX-FileCopyrightText: (C) 2025 - 2026 Deskflow Developers + * SPDX-FileCopyrightText: (C) 2024 - 2026 Deskflow Developers * 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 @@ -8,7 +8,6 @@ #pragma once -#include "deskflow/IClipboard.h" #include "mt/Thread.h" #include "platform/EiScreen.h" @@ -63,28 +62,6 @@ private: 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) { diff --git a/src/lib/platform/PortalRemoteDesktop.cpp b/src/lib/platform/PortalRemoteDesktop.cpp index fe60af876..7c12d25ad 100644 --- a/src/lib/platform/PortalRemoteDesktop.cpp +++ b/src/lib/platform/PortalRemoteDesktop.cpp @@ -1,16 +1,21 @@ /* * Deskflow -- mouse and keyboard sharing utility - * SPDX-FileCopyrightText: (C) 2025 Deskflow Developers - * SPDX-FileCopyrightText: (C) 2024 Synergy App Ltd + * SPDX-FileCopyrightText: (C) 2024 - 2025 Deskflow Developers + * SPDX-FileCopyrightText: (C) 2024, 2026 Synergy App Ltd * SPDX-FileCopyrightText: (C) 2022 Red Hat, Inc. * SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception */ #include "platform/PortalRemoteDesktop.h" + #include "base/Log.h" #include "base/TMethodJob.h" #include "common/Settings.h" +#ifdef HAVE_LIBPORTAL_CLIPBOARD +#include "platform/PortalClipboard.h" +#endif + namespace deskflow { PortalRemoteDesktop::PortalRemoteDesktop(EiScreen *screen, IEventQueue *events) @@ -43,6 +48,10 @@ PortalRemoteDesktop::~PortalRemoteDesktop() if (m_sessionSignalId) g_signal_handler_disconnect(m_session, m_sessionSignalId); + if (m_selectionTransferSignalId) + g_signal_handler_disconnect(m_session, m_selectionTransferSignalId); + if (m_selectionOwnerChangedSignalId) + g_signal_handler_disconnect(m_session, m_selectionOwnerChangedSignalId); if (m_session) g_object_unref(m_session); g_object_unref(m_portal); @@ -89,6 +98,30 @@ void PortalRemoteDesktop::handleSessionStarted(GObject *object, GAsyncResult *re return; } +#ifdef HAVE_LIBPORTAL_CLIPBOARD + if (!xdp_session_is_clipboard_enabled(session)) { + LOG_WARN("clipboard not enabled on remote desktop session, discarding restore token to force a fresh session"); + Settings::setValue(Settings::Client::XdpRestoreToken, QString()); + free(m_sessionRestoreToken); + m_sessionRestoreToken = nullptr; + if (m_selectionTransferSignalId) { + g_signal_handler_disconnect(session, m_selectionTransferSignalId); + m_selectionTransferSignalId = 0; + } + if (m_selectionOwnerChangedSignalId) { + g_signal_handler_disconnect(session, m_selectionOwnerChangedSignalId); + m_selectionOwnerChangedSignalId = 0; + } + if (m_sessionSignalId) { + g_signal_handler_disconnect(session, m_sessionSignalId); + m_sessionSignalId = 0; + } + g_clear_object(&m_session); + reconnect(0); + return; + } +#endif + m_sessionRestoreToken = xdp_session_get_restore_token(session); if (m_sessionRestoreToken) { Settings::setValue(Settings::Client::XdpRestoreToken, QString(m_sessionRestoreToken)); @@ -135,6 +168,14 @@ void PortalRemoteDesktop::handleInitSession(GObject *object, GAsyncResult *res) // the static function m_sessionSignalId = g_signal_connect(G_OBJECT(session), "closed", G_CALLBACK(handleSessionClosedCallback), this); +#ifdef HAVE_LIBPORTAL_CLIPBOARD + xdp_session_request_clipboard(session); + m_selectionTransferSignalId = + g_signal_connect(G_OBJECT(session), "selection-transfer", G_CALLBACK(selectionTransferCallback), this); + m_selectionOwnerChangedSignalId = + g_signal_connect(G_OBJECT(session), "selection-owner-changed", G_CALLBACK(selectionOwnerChangedCallback), this); +#endif + LOG_DEBUG("portal remote desktop session starting"); xdp_session_start( session, @@ -178,4 +219,48 @@ void PortalRemoteDesktop::glibThread(const void *) } } +void PortalRemoteDesktop::claimClipboard() +{ +#ifdef HAVE_LIBPORTAL_CLIPBOARD + if (!m_session) { + LOG_DEBUG("portal remote desktop clipboard claim deferred, no session yet"); + return; + } + if (!xdp_session_is_clipboard_enabled(m_session)) { + LOG_WARN("portal remote desktop clipboard not enabled on session, cannot claim"); + return; + } + PortalClipboard::claimOwnership(m_screen->getClipboardCache(), m_session); +#endif +} + +void PortalRemoteDesktop::handleSelectionTransfer(XdpSession *session, const char *mimeType, uint32_t serial) +{ +#ifdef HAVE_LIBPORTAL_CLIPBOARD + PortalClipboard::serveSelectionTransfer(m_screen->getClipboardCache(), session, mimeType, serial); +#else + (void)session; + (void)mimeType; + (void)serial; +#endif +} + +void PortalRemoteDesktop::handleSelectionOwnerChanged(XdpSession *session, char **mimeTypes, gboolean isOwner) +{ +#ifdef HAVE_LIBPORTAL_CLIPBOARD + if (isOwner) { + LOG_DEBUG("portal remote desktop selection owner changed, we own it, ignoring"); + return; + } + + const qint64 maxBytes = static_cast(m_screen->maximumClipboardSize()) * 1024; + if (PortalClipboard::readSelectionIntoCache(m_screen->getClipboardCache(), session, mimeTypes, maxBytes)) + m_screen->sendClipboardEvent(EventTypes::ClipboardGrabbed, kClipboardClipboard); +#else + (void)session; + (void)mimeTypes; + (void)isOwner; +#endif +} + } // namespace deskflow diff --git a/src/lib/platform/PortalRemoteDesktop.h b/src/lib/platform/PortalRemoteDesktop.h index fe2d31e4f..8bc2ea2e4 100644 --- a/src/lib/platform/PortalRemoteDesktop.h +++ b/src/lib/platform/PortalRemoteDesktop.h @@ -13,6 +13,8 @@ #include #include +#include + namespace deskflow { class PortalRemoteDesktop @@ -21,6 +23,8 @@ public: PortalRemoteDesktop(EiScreen *screen, IEventQueue *events); ~PortalRemoteDesktop(); + void claimClipboard(); + private: void glibThread(const void *); gboolean timeoutHandler() const; @@ -28,14 +32,25 @@ private: void handleInitSession(GObject *object, GAsyncResult *res); void handleSessionStarted(GObject *object, GAsyncResult *res); void handleSessionClosed(XdpSession *session); + void handleSelectionTransfer(XdpSession *session, const char *mimeType, uint32_t serial); + void handleSelectionOwnerChanged(XdpSession *session, char **mimeTypes, gboolean isOwner); void reconnect(unsigned int timeout = 1000); - /// g_signal_connect callback wrapper static void handleSessionClosedCallback(XdpSession *session, gpointer data) { static_cast(data)->handleSessionClosed(session); } + static void selectionTransferCallback(XdpSession *session, const char *mimeType, uint32_t serial, gpointer data) + { + static_cast(data)->handleSelectionTransfer(session, mimeType, serial); + } + + static void selectionOwnerChangedCallback(XdpSession *session, char **mimeTypes, gboolean isOwner, gpointer data) + { + static_cast(data)->handleSelectionOwnerChanged(session, mimeTypes, isOwner); + } + private: EiScreen *m_screen; IEventQueue *m_events; @@ -48,6 +63,8 @@ private: char *m_sessionRestoreToken = nullptr; guint m_sessionSignalId = 0; + guint m_selectionTransferSignalId = 0; + guint m_selectionOwnerChangedSignalId = 0; /// The number of successful sessions we've had already guint m_sessionIteration = 0;