deskflow/src/lib/platform/PortalClipboard.cpp
Nick Bolton 03a4800677 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.
2026-06-18 22:06:37 +01:00

332 lines
8.7 KiB
C++

/*
* 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