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