From 389a47b0880d2c320ecae8adaab4fd0d361b7e6a Mon Sep 17 00:00:00 2001 From: Aleix Pol Date: Wed, 3 Mar 2021 15:24:24 +0100 Subject: [PATCH] Add support for clipboard integration Copies over David's implementation in Klipper and integrates it in the plugin. To do so it splits the ClipboardListener class into 2 subclasses: one that uses QClipboard and the other that uses the DataControl classes. BUG: 359747 --- plugins/clipboard/CMakeLists.txt | 17 +- plugins/clipboard/clipboardlistener.cpp | 108 ++++-- plugins/clipboard/clipboardlistener.h | 54 ++- plugins/clipboard/datacontrol.cpp | 351 ++++++++++++++++++ plugins/clipboard/datacontrol.h | 35 ++ .../wlr-data-control-unstable-v1.xml | 278 ++++++++++++++ 6 files changed, 798 insertions(+), 45 deletions(-) create mode 100644 plugins/clipboard/datacontrol.cpp create mode 100644 plugins/clipboard/datacontrol.h create mode 100644 plugins/clipboard/wlr-data-control-unstable-v1.xml diff --git a/plugins/clipboard/CMakeLists.txt b/plugins/clipboard/CMakeLists.txt index 30f62989e..7e0c0a58c 100644 --- a/plugins/clipboard/CMakeLists.txt +++ b/plugins/clipboard/CMakeLists.txt @@ -1,16 +1,29 @@ +find_package(QtWaylandScanner REQUIRED) +find_package(Wayland 1.15 COMPONENTS Client) +find_package(Qt5 ${QT_MIN_VERSION} CONFIG REQUIRED COMPONENTS WaylandClient) + set(debug_file_SRCS) ecm_qt_declare_logging_category( debug_file_SRCS HEADER plugin_clipboard_debug.h IDENTIFIER KDECONNECT_PLUGIN_CLIPBOARD CATEGORY_NAME kdeconnect.plugin.clipboard DEFAULT_SEVERITY Warning EXPORT kdeconnect-kde DESCRIPTION "kdeconnect (plugin clipboard)") - set(kdeconnect_clipboard_SRCS clipboardplugin.cpp clipboardlistener.cpp + datacontrol.cpp ${debug_file_SRCS} ) +ecm_add_qtwayland_client_protocol(kdeconnect_clipboard_SRCS + PROTOCOL wlr-data-control-unstable-v1.xml + BASENAME wlr-data-control-unstable-v1 +) + kdeconnect_add_plugin(kdeconnect_clipboard JSON kdeconnect_clipboard.json SOURCES ${kdeconnect_clipboard_SRCS}) -target_link_libraries(kdeconnect_clipboard kdeconnectcore Qt5::Gui) +target_link_libraries(kdeconnect_clipboard kdeconnectcore + Qt5::Gui + Qt5::GuiPrivate # for native interface to get wl_seat + Wayland::Client Qt::WaylandClient + ) diff --git a/plugins/clipboard/clipboardlistener.cpp b/plugins/clipboard/clipboardlistener.cpp index af1e33f60..e542549c7 100644 --- a/plugins/clipboard/clipboardlistener.cpp +++ b/plugins/clipboard/clipboardlistener.cpp @@ -5,33 +5,13 @@ */ #include "clipboardlistener.h" +#include +#include + +#include "datacontrol.h" ClipboardListener::ClipboardListener() - : clipboard(QGuiApplication::clipboard()) -{ -#ifdef Q_OS_MAC - connect(&m_clipboardMonitorTimer, &QTimer::timeout, this, [this](){ updateClipboard(QClipboard::Clipboard); }); - m_clipboardMonitorTimer.start(1000); // Refresh 1s -#endif - connect(clipboard, &QClipboard::changed, this, &ClipboardListener::updateClipboard); -} - -void ClipboardListener::updateClipboard(QClipboard::Mode mode) -{ - if (mode != QClipboard::Clipboard) { - return; - } - - QString content = clipboard->text(); - - if (content == m_currentContent) { - return; - } - m_updateTimestamp = QDateTime::currentDateTime().toMSecsSinceEpoch(); - m_currentContent = content; - - Q_EMIT clipboardChanged(content); -} +{} QString ClipboardListener::currentContent() { @@ -43,9 +23,85 @@ qint64 ClipboardListener::updateTimestamp(){ return m_updateTimestamp; } -void ClipboardListener::setText(const QString& content) +ClipboardListener* ClipboardListener::instance() +{ + static ClipboardListener* me = nullptr; + if (!me) { + if (QGuiApplication::platformName().startsWith(QLatin1String("wayland"), Qt::CaseInsensitive)) { + me = new WaylandClipboardListener(); + } else { + me = new QClipboardListener(); + } + } + return me; +} + +void ClipboardListener::refreshContent(const QString& content) { m_updateTimestamp = QDateTime::currentDateTime().toMSecsSinceEpoch(); m_currentContent = content; +} + +QClipboardListener::QClipboardListener() + : clipboard(QGuiApplication::clipboard()) +{ +#ifdef Q_OS_MAC + connect(&m_clipboardMonitorTimer, &QTimer::timeout, this, [this](){ updateClipboard(QClipboard::Clipboard); }); + m_clipboardMonitorTimer.start(1000); // Refresh 1s +#endif + connect(clipboard, &QClipboard::changed, this, &QClipboardListener::updateClipboard); +} + +void QClipboardListener::updateClipboard(QClipboard::Mode mode) +{ + if (mode != QClipboard::Clipboard) { + return; + } + + const QString content = clipboard->text(); + if (content == m_currentContent) { + return; + } + refreshContent(content); + Q_EMIT clipboardChanged(content); +} + +void QClipboardListener::setText(const QString& content) +{ + refreshContent(content); clipboard->setText(content); } + +WaylandClipboardListener::WaylandClipboardListener() + : m_dataControl(new DataControl(this)) +{ + connect(m_dataControl, &DataControl::receivedSelectionChanged, this, [this] { + refresh(m_dataControl->receivedSelection()); + }); + connect(m_dataControl, &DataControl::selectionChanged, this, [this] { + refresh(m_dataControl->selection()); + }); + +} + +void WaylandClipboardListener::setText(const QString& content) +{ + refreshContent(content); + auto mime = new QMimeData; + mime->setText(content); + m_dataControl->setSelection(mime, true); +} + +void WaylandClipboardListener::refresh(const QMimeData *mime) +{ + if (!mime || !mime->hasText()) { + return; + } + + const QString content = mime->text(); + if (content == m_currentContent) { + return; + } + refreshContent(content); + Q_EMIT clipboardChanged(content); +} diff --git a/plugins/clipboard/clipboardlistener.h b/plugins/clipboard/clipboardlistener.h index 74f0036e3..72d218490 100644 --- a/plugins/clipboard/clipboardlistener.h +++ b/plugins/clipboard/clipboardlistener.h @@ -16,33 +16,23 @@ /** * Wrapper around QClipboard, which emits clipboardChanged only when it really changed */ + class ClipboardListener : public QObject { Q_OBJECT -private: +protected: ClipboardListener(); + void refreshContent(const QString &content); QString m_currentContent; + +private: qint64 m_updateTimestamp = 0; - QClipboard* clipboard; -#ifdef Q_OS_MAC - QTimer m_clipboardMonitorTimer; -#endif public: + static ClipboardListener* instance(); - static ClipboardListener* instance() - { - static ClipboardListener* me = nullptr; - if (!me) { - me = new ClipboardListener(); - } - return me; - } - - void updateClipboard(QClipboard::Mode mode); - - void setText(const QString& content); + virtual void setText(const QString& content) = 0; QString currentContent(); qint64 updateTimestamp(); @@ -51,4 +41,34 @@ Q_SIGNALS: void clipboardChanged(const QString& content); }; +class QClipboardListener : public ClipboardListener +{ +public: + QClipboardListener(); + + void setText(const QString & content) override; + +private: +#ifdef Q_OS_MAC + QTimer m_clipboardMonitorTimer; +#endif + void updateClipboard(QClipboard::Mode mode); + QClipboard* clipboard; +}; + +class DataControl; + +class WaylandClipboardListener : public ClipboardListener +{ +public: + WaylandClipboardListener(); + + void setText(const QString & content) override; + +private: + void refresh(const QMimeData *mime); + + DataControl *m_dataControl; +}; + #endif diff --git a/plugins/clipboard/datacontrol.cpp b/plugins/clipboard/datacontrol.cpp new file mode 100644 index 000000000..3d52660a3 --- /dev/null +++ b/plugins/clipboard/datacontrol.cpp @@ -0,0 +1,351 @@ +/* + SPDX-FileCopyrightText: 2020 David Edmundson + + SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL +*/ + +#include "datacontrol.h" +#include "qwayland-wlr-data-control-unstable-v1.h" +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +class DataControlDeviceManager : public QWaylandClientExtensionTemplate, public QtWayland::zwlr_data_control_manager_v1 +{ + Q_OBJECT +public: + DataControlDeviceManager() + : QWaylandClientExtensionTemplate(1) + { + } + + ~DataControlDeviceManager() + { + destroy(); + } +}; + +class DataControlOffer : public QMimeData, public QtWayland::zwlr_data_control_offer_v1 +{ + Q_OBJECT +public: + DataControlOffer(struct ::zwlr_data_control_offer_v1 *id) + : QtWayland::zwlr_data_control_offer_v1(id) + { + } + + ~DataControlOffer() + { + destroy(); + } + + QStringList formats() const override + { + return m_receivedFormats; + } + + bool hasFormat(const QString &format) const override + { + return m_receivedFormats.contains(format); + } + +protected: + void zwlr_data_control_offer_v1_offer(const QString &mime_type) override + { + m_receivedFormats << mime_type; + } + + QVariant retrieveData(const QString &mimeType, QVariant::Type type) const override; + +private: + static bool readData(int fd, QByteArray &data); + QStringList m_receivedFormats; +}; + +QVariant DataControlOffer::retrieveData(const QString &mimeType, QVariant::Type type) const +{ + if (!hasFormat(mimeType)) { + return QVariant(); + } + Q_UNUSED(type); + + int pipeFds[2]; + if (pipe(pipeFds) != 0) { + return QVariant(); + } + + auto t = const_cast(this); + t->receive(mimeType, pipeFds[1]); + + close(pipeFds[1]); + + /* + * Ideally we need to introduce a non-blocking QMimeData object + * Or a non-blocking constructor to QMimeData with the mimetypes that are relevant + * + * However this isn't actually any worse than X. + */ + + QPlatformNativeInterface *native = qApp->platformNativeInterface(); + auto display = static_cast(native->nativeResourceForIntegration("wl_display")); + wl_display_flush(display); + + QFile readPipe; + if (readPipe.open(pipeFds[0], QIODevice::ReadOnly)) { + QByteArray data; + if (readData(pipeFds[0], data)) { + return data; + } + close(pipeFds[0]); + } + return QVariant(); +} + +// reads data from a file descriptor with a timeout of 1 second +// true if data is read successfully +bool DataControlOffer::readData(int fd, QByteArray &data) +{ + fd_set readset; + FD_ZERO(&readset); + FD_SET(fd, &readset); + struct timeval timeout; + timeout.tv_sec = 1; + timeout.tv_usec = 0; + + Q_FOREVER { + int ready = select(FD_SETSIZE, &readset, nullptr, nullptr, &timeout); + if (ready < 0) { + qCWarning(KDECONNECT_PLUGIN_CLIPBOARD) << "DataControlOffer: select() failed"; + return false; + } else if (ready == 0) { + qCWarning(KDECONNECT_PLUGIN_CLIPBOARD) << "DataControlOffer: timeout reading from pipe"; + return false; + } else { + char buf[4096]; + int n = read(fd, buf, sizeof buf); + + if (n < 0) { + qWarning("DataControlOffer: read() failed"); + return false; + } else if (n == 0) { + return true; + } else if (n > 0) { + data.append(buf, n); + } + } + } +} + +class DataControlSource : public QObject, public QtWayland::zwlr_data_control_source_v1 +{ + Q_OBJECT +public: + DataControlSource(struct ::zwlr_data_control_source_v1 *id, QMimeData *mimeData); + DataControlSource(); + ~DataControlSource() + { + destroy(); + } + + QMimeData *mimeData() + { + return m_mimeData; + } + +Q_SIGNALS: + void cancelled(); + +protected: + void zwlr_data_control_source_v1_send(const QString &mime_type, int32_t fd) override; + void zwlr_data_control_source_v1_cancelled() override; + +private: + QMimeData *m_mimeData; +}; + +DataControlSource::DataControlSource(struct ::zwlr_data_control_source_v1 *id, QMimeData *mimeData) + : QtWayland::zwlr_data_control_source_v1(id) + , m_mimeData(mimeData) +{ + for (const QString &format : mimeData->formats()) { + offer(format); + } + if(mimeData->hasText()) + { + // ensure GTK applications get this mimetype to avoid them discarding the offer + offer(QStringLiteral("text/plain;charset=utf-8")); + } +} + +void DataControlSource::zwlr_data_control_source_v1_send(const QString &mime_type, int32_t fd) +{ + QFile c; + QString send_mime_type = mime_type; + if(send_mime_type == QStringLiteral("text/plain;charset=utf-8")) { + // if we get a request on the fallback mime, send the data from the original mime type + send_mime_type = QStringLiteral("text/plain"); + } + if (c.open(fd, QFile::WriteOnly, QFile::AutoCloseHandle)) { + c.write(m_mimeData->data(send_mime_type)); + c.close(); + } +} + +void DataControlSource::zwlr_data_control_source_v1_cancelled() +{ + Q_EMIT cancelled(); +} + +class DataControlDevice : public QObject, public QtWayland::zwlr_data_control_device_v1 +{ + Q_OBJECT +public: + DataControlDevice(struct ::zwlr_data_control_device_v1 *id) + : QtWayland::zwlr_data_control_device_v1(id) + { + } + + ~DataControlDevice() + { + destroy(); + } + + void setSelection(std::unique_ptr selection); + QMimeData *receivedSelection() + { + return m_receivedSelection.get(); + } + QMimeData *selection() + { + return m_selection ? m_selection->mimeData() : nullptr; + } + +Q_SIGNALS: + void receivedSelectionChanged(); + void selectionChanged(); + +protected: + void zwlr_data_control_device_v1_data_offer(struct ::zwlr_data_control_offer_v1 *id) override + { + new DataControlOffer(id); + // this will become memory managed when we retrieve the selection event + // a compositor calling data_offer without doing that would be a bug + } + + void zwlr_data_control_device_v1_selection(struct ::zwlr_data_control_offer_v1 *id) override + { + if (!id) { + m_receivedSelection.reset(); + } else { + auto deriv = QtWayland::zwlr_data_control_offer_v1::fromObject(id); + auto offer = dynamic_cast(deriv); // dynamic because of the dual inheritance + m_receivedSelection.reset(offer); + } + Q_EMIT receivedSelectionChanged(); + } + +private: + std::unique_ptr m_selection; // selection set locally + std::unique_ptr m_receivedSelection; // latest selection set from externally to here +}; + +void DataControlDevice::setSelection(std::unique_ptr selection) +{ + m_selection = std::move(selection); + connect(m_selection.get(), &DataControlSource::cancelled, this, [this]() { + m_selection.reset(); + Q_EMIT selectionChanged(); + }); + set_selection(m_selection->object()); + Q_EMIT selectionChanged(); +} + +class DataControlPrivate +{ +public: + DataControlPrivate() + : m_manager(new DataControlDeviceManager) + {} + + std::unique_ptr m_manager; + std::unique_ptr m_device; +}; + + +DataControl::DataControl(QObject *parent) + : QObject(parent) + , d(new DataControlPrivate) +{ + connect(d->m_manager.get(), &DataControlDeviceManager::activeChanged, this, [this]() { + if (d->m_manager->isActive()) { + QPlatformNativeInterface *native = qGuiApp->platformNativeInterface(); + if (!native) { + return; + } + auto seat = static_cast(native->nativeResourceForIntegration("wl_seat")); + if (!seat) { + return; + } + + d->m_device.reset(new DataControlDevice(d->m_manager->get_data_device(seat))); + + connect(d->m_device.get(), &DataControlDevice::receivedSelectionChanged, this, &DataControl::receivedSelectionChanged); + connect(d->m_device.get(), &DataControlDevice::selectionChanged, this, &DataControl::selectionChanged); + } else { + d->m_device.reset(); + } + }); +} + +DataControl::~DataControl() = default; + +QMimeData *DataControl::selection() const +{ + return d->m_device ? d->m_device->selection() : nullptr; +} + +QMimeData * DataControl::receivedSelection() const +{ + return d->m_device ? d->m_device->receivedSelection() : nullptr; +} + +void DataControl::setSelection(QMimeData* mime, bool ownMime) +{ + if (!d->m_device) { + return; + } + + auto source = std::make_unique(d->m_manager->create_data_source(), mime); + if (ownMime) { + mime->setParent(source.get()); + } + d->m_device->setSelection(std::move(source)); +} + +void DataControl::clearSelection() +{ + if (!d->m_device) { + return; + } + d->m_device->set_selection(nullptr); +} + +void DataControl::clearPrimarySelection() +{ + if (!d->m_device) { + return; + } + + if (zwlr_data_control_device_v1_get_version(d->m_device->object()) >= ZWLR_DATA_CONTROL_DEVICE_V1_SET_PRIMARY_SELECTION_SINCE_VERSION) { + d->m_device->set_primary_selection(nullptr); + } +} + +#include "datacontrol.moc" diff --git a/plugins/clipboard/datacontrol.h b/plugins/clipboard/datacontrol.h new file mode 100644 index 000000000..2fece5e6d --- /dev/null +++ b/plugins/clipboard/datacontrol.h @@ -0,0 +1,35 @@ +/* + SPDX-FileCopyrightText: 2020 David Edmundson + + SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL +*/ + +#pragma once +#include +#include + +class DataControlPrivate; +class QMimeData; + +class DataControl : public QObject +{ + Q_OBJECT +public: + DataControl(QObject *parent = nullptr); + ~DataControl() override; + + QMimeData* selection() const; + QMimeData* receivedSelection() const; + + void clearSelection(); + void clearPrimarySelection(); + + void setSelection(QMimeData *mime, bool ownMime); + +Q_SIGNALS: + void receivedSelectionChanged(); + void selectionChanged(); + +private: + QScopedPointer d; +}; diff --git a/plugins/clipboard/wlr-data-control-unstable-v1.xml b/plugins/clipboard/wlr-data-control-unstable-v1.xml new file mode 100644 index 000000000..75e8671b0 --- /dev/null +++ b/plugins/clipboard/wlr-data-control-unstable-v1.xml @@ -0,0 +1,278 @@ + + + + Copyright © 2018 Simon Ser + Copyright © 2019 Ivan Molodetskikh + + Permission to use, copy, modify, distribute, and sell this + software and its documentation for any purpose is hereby granted + without fee, provided that the above copyright notice appear in + all copies and that both that copyright notice and this permission + notice appear in supporting documentation, and that the name of + the copyright holders not be used in advertising or publicity + pertaining to distribution of the software without specific, + written prior permission. The copyright holders make no + representations about the suitability of this software for any + purpose. It is provided "as is" without express or implied + warranty. + + THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS + SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND + FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY + SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN + AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, + ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF + THIS SOFTWARE. + + + + This protocol allows a privileged client to control data devices. In + particular, the client will be able to manage the current selection and take + the role of a clipboard manager. + + Warning! The protocol described in this file is experimental and + backward incompatible changes may be made. Backward compatible changes + may be added together with the corresponding interface version bump. + Backward incompatible changes are done by bumping the version number in + the protocol and interface names and resetting the interface version. + Once the protocol is to be declared stable, the 'z' prefix and the + version number in the protocol and interface names are removed and the + interface version number is reset. + + + + + This interface is a manager that allows creating per-seat data device + controls. + + + + + Create a new data source. + + + + + + + Create a data device that can be used to manage a seat's selection. + + + + + + + + All objects created by the manager will still remain valid, until their + appropriate destroy request has been called. + + + + + + + This interface allows a client to manage a seat's selection. + + When the seat is destroyed, this object becomes inert. + + + + + This request asks the compositor to set the selection to the data from + the source on behalf of the client. + + The given source may not be used in any further set_selection or + set_primary_selection requests. Attempting to use a previously used + source is a protocol error. + + To unset the selection, set the source to NULL. + + + + + + + Destroys the data device object. + + + + + + The data_offer event introduces a new wlr_data_control_offer object, + which will subsequently be used in either the + wlr_data_control_device.selection event (for the regular clipboard + selections) or the wlr_data_control_device.primary_selection event (for + the primary clipboard selections). Immediately following the + wlr_data_control_device.data_offer event, the new data_offer object + will send out wlr_data_control_offer.offer events to describe the MIME + types it offers. + + + + + + + The selection event is sent out to notify the client of a new + wlr_data_control_offer for the selection for this device. The + wlr_data_control_device.data_offer and the wlr_data_control_offer.offer + events are sent out immediately before this event to introduce the data + offer object. The selection event is sent to a client when a new + selection is set. The wlr_data_control_offer is valid until a new + wlr_data_control_offer or NULL is received. The client must destroy the + previous selection wlr_data_control_offer, if any, upon receiving this + event. + + The first selection event is sent upon binding the + wlr_data_control_device object. + + + + + + + This data control object is no longer valid and should be destroyed by + the client. + + + + + + + + The primary_selection event is sent out to notify the client of a new + wlr_data_control_offer for the primary selection for this device. The + wlr_data_control_device.data_offer and the wlr_data_control_offer.offer + events are sent out immediately before this event to introduce the data + offer object. The primary_selection event is sent to a client when a + new primary selection is set. The wlr_data_control_offer is valid until + a new wlr_data_control_offer or NULL is received. The client must + destroy the previous primary selection wlr_data_control_offer, if any, + upon receiving this event. + + If the compositor supports primary selection, the first + primary_selection event is sent upon binding the + wlr_data_control_device object. + + + + + + + This request asks the compositor to set the primary selection to the + data from the source on behalf of the client. + + The given source may not be used in any further set_selection or + set_primary_selection requests. Attempting to use a previously used + source is a protocol error. + + To unset the primary selection, set the source to NULL. + + The compositor will ignore this request if it does not support primary + selection. + + + + + + + + + + + + The wlr_data_control_source object is the source side of a + wlr_data_control_offer. It is created by the source client in a data + transfer and provides a way to describe the offered data and a way to + respond to requests to transfer the data. + + + + + + + + + This request adds a MIME type to the set of MIME types advertised to + targets. Can be called several times to offer multiple types. + + Calling this after wlr_data_control_device.set_selection is a protocol + error. + + + + + + + Destroys the data source object. + + + + + + Request for data from the client. Send the data as the specified MIME + type over the passed file descriptor, then close it. + + + + + + + + This data source is no longer valid. The data source has been replaced + by another data source. + + The client should clean up and destroy this data source. + + + + + + + A wlr_data_control_offer represents a piece of data offered for transfer + by another client (the source client). The offer describes the different + MIME types that the data can be converted to and provides the mechanism + for transferring the data directly from the source client. + + + + + To transfer the offered data, the client issues this request and + indicates the MIME type it wants to receive. The transfer happens + through the passed file descriptor (typically created with the pipe + system call). The source client writes the data in the MIME type + representation requested and then closes the file descriptor. + + The receiving client reads from the read end of the pipe until EOF and + then closes its end, at which point the transfer is complete. + + This request may happen multiple times for different MIME types. + + + + + + + + Destroys the data offer object. + + + + + + Sent immediately after creating the wlr_data_control_offer object. + One event per offered MIME type. + + + + +