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;