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.
This commit is contained in:
Nick Bolton 2026-05-19 20:01:38 +01:00
parent 66cb367473
commit 03a4800677
9 changed files with 537 additions and 318 deletions

View file

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

View file

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

View file

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

View file

@ -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 <cstring>
#include <poll.h>
#include <unistd.h>
#include <QBuffer>
#include <QByteArrayList>
#include <QDataStream>
#include <QFile>
#include <QImage>
#include <QList>
#include <QPair>
#include <QSet>
#include <QVarLengthArray>
#include <QtEndian>
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<qint64>(sizeof(quint32)))
return {};
quint32 headerSize;
std::memcpy(&headerSize, dib.constData(), sizeof(headerSize));
headerSize = qFromLittleEndian(headerSize);
if (headerSize < kMinDibHeaderSize || headerSize > static_cast<quint32>(dib.size()))
return {};
const auto fileSize = static_cast<quint32>(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<int>(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<qint64>(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<qint64>(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<const char *, std::size(kSupportedMimes) + 1> 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<long long>(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<long long>(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<QPair<IClipboard::Format, QByteArray>> reads;
QSet<IClipboard::Format> 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<long long>(reads.size()));
return true;
}
} // namespace deskflow

View file

@ -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 <QByteArray>
#include <libportal/portal.h>
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

View file

@ -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 <cmath>
#include <limits>
#include <poll.h>
#include <set>
#include <sys/socket.h> // for EIS fd hack, remove
#include <sys/un.h> // 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<qint64>(m_screen->maximumClipboardSize()) * 1024;
LOG_DEBUG("clipboard read cap: %lld bytes", static_cast<long long>(kClipboardReadCap));
const qint64 maxBytes = static_cast<qint64>(m_screen->maximumClipboardSize()) * 1024;
LOG_DEBUG("clipboard read cap: %lld bytes", static_cast<long long>(maxBytes));
const char **mimeTypes = xdp_session_get_selection_mime_types(session);
if (!mimeTypes) {
@ -282,53 +266,7 @@ 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<std::pair<IClipboard::Format, std::string>> reads;
std::set<IClipboard::Format> 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();
if (PortalClipboard::readSelectionIntoCache(m_clipboard, session, mimeTypes, maxBytes))
m_screen->sendClipboardEvent(EventTypes::ClipboardGrabbed, kClipboardClipboard);
#else
(void)session;
@ -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<int>(requested->format), hasFormat,
raw.size()
);
QByteArray data = encodeFormat(requested->format, raw);
LOG_DEBUG("writing clipboard content, mime type: %s, bytes: %lld", mimeType, static_cast<long long>(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<quint32>(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<int>(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

View file

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

View file

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

View file

@ -13,6 +13,8 @@
#include <glib.h>
#include <libportal/portal.h>
#include <QByteArray>
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<PortalRemoteDesktop *>(data)->handleSessionClosed(session);
}
static void selectionTransferCallback(XdpSession *session, const char *mimeType, uint32_t serial, gpointer data)
{
static_cast<PortalRemoteDesktop *>(data)->handleSelectionTransfer(session, mimeType, serial);
}
static void selectionOwnerChangedCallback(XdpSession *session, char **mimeTypes, gboolean isOwner, gpointer data)
{
static_cast<PortalRemoteDesktop *>(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;