deskflow/src/lib/platform/PortalClipboard.cpp

333 lines
8.7 KiB
C++
Raw Normal View History

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