979 lines
33 KiB
Diff
979 lines
33 KiB
Diff
From 9bd6f0a62d1ac5fb93803a0dfb26c61870b9b6b9 Mon Sep 17 00:00:00 2001
|
|
From: =?UTF-8?q?Dav=C3=AD=C3=B0=20Steinn=20Geirsson?= <david@dsg.is>
|
|
Date: Wed, 18 Feb 2026 10:39:15 +0000
|
|
Subject: [PATCH 3/6] vmsilo: add clipboard isolation for VMs, whitelist
|
|
protocols
|
|
|
|
Isolate clipboard (selection + primary selection) between vmsilo security
|
|
contexts so VMs cannot snoop on each other's or the host's clipboard data.
|
|
|
|
A new VmsiloClipboardManager swaps per-security-context clipboard buffers
|
|
in/out of the Wayland Seat on focus transitions. Two new takeSelection()
|
|
and takePrimarySelection() methods on SeatInterface detach the current
|
|
source without canceling it. Keyboard shortcuts Ctrl+Shift+C/V transfer
|
|
data between context and host clipboards with MIME filtering (text/* and
|
|
image/* only).
|
|
|
|
Also implements a whitelist for allowed wayland protocols for vmsilo
|
|
windows, to protect against new kwin protocols opening new attack vectors.
|
|
This can be configured in kwinrc with AllowedProtocols= (comma seperated)
|
|
under [Vmsilo] section. If not set, it uses a built-in default list.
|
|
|
|
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
|
---
|
|
src/CMakeLists.txt | 1 +
|
|
src/vmsilo_clipboard_manager.cpp | 514 +++++++++++++++++++++++++++++++
|
|
src/vmsilo_clipboard_manager.h | 139 +++++++++
|
|
src/wayland/seat.cpp | 24 ++
|
|
src/wayland/seat.h | 14 +
|
|
src/wayland_server.cpp | 82 +++++
|
|
src/wayland_server.h | 8 +
|
|
src/workspace.cpp | 4 +
|
|
8 files changed, 786 insertions(+)
|
|
create mode 100644 src/vmsilo_clipboard_manager.cpp
|
|
create mode 100644 src/vmsilo_clipboard_manager.h
|
|
|
|
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
|
|
index 67d14286dc..a07310be69 100644
|
|
--- a/src/CMakeLists.txt
|
|
+++ b/src/CMakeLists.txt
|
|
@@ -207,6 +207,7 @@ target_sources(kwin PRIVATE
|
|
virtualdesktops.cpp
|
|
virtualdesktopsdbustypes.cpp
|
|
virtualkeyboard_dbus.cpp
|
|
+ vmsilo_clipboard_manager.cpp
|
|
wayland_server.cpp
|
|
waylandshellintegration.cpp
|
|
waylandwindow.cpp
|
|
diff --git a/src/vmsilo_clipboard_manager.cpp b/src/vmsilo_clipboard_manager.cpp
|
|
new file mode 100644
|
|
index 0000000000..7765f2cbd3
|
|
--- /dev/null
|
|
+++ b/src/vmsilo_clipboard_manager.cpp
|
|
@@ -0,0 +1,514 @@
|
|
+/*
|
|
+ SPDX-FileCopyrightText: 2026 Davíð Steinn Geirsson
|
|
+
|
|
+ SPDX-License-Identifier: GPL-2.0-or-later
|
|
+*/
|
|
+
|
|
+#include "vmsilo_clipboard_manager.h"
|
|
+#include "osd.h"
|
|
+#include "utils/common.h"
|
|
+#include "utils/filedescriptor.h"
|
|
+#include "utils/pipe.h"
|
|
+#include "wayland/display.h"
|
|
+#include "wayland/seat.h"
|
|
+#include "wayland_server.h"
|
|
+#include "window.h"
|
|
+#include "workspace.h"
|
|
+
|
|
+#include <KGlobalAccel>
|
|
+#include <KLocalizedString>
|
|
+
|
|
+#include <QAction>
|
|
+#include <QThreadPool>
|
|
+#include <QSocketNotifier>
|
|
+#include <QTimer>
|
|
+
|
|
+#include <fcntl.h>
|
|
+#include <poll.h>
|
|
+#include <unistd.h>
|
|
+
|
|
+namespace KWin
|
|
+{
|
|
+
|
|
+// --- VmsiloBufferSource ---
|
|
+
|
|
+VmsiloBufferSource::VmsiloBufferSource(const QHash<QString, QByteArray> &data, QObject *parent)
|
|
+ : AbstractDataSource(parent)
|
|
+ , m_data(data)
|
|
+{
|
|
+}
|
|
+
|
|
+static void vmsiloWriteData(FileDescriptor fd, const QByteArray &buffer)
|
|
+{
|
|
+ size_t remainingSize = buffer.size();
|
|
+
|
|
+ pollfd pfds[1];
|
|
+ pfds[0].fd = fd.get();
|
|
+ pfds[0].events = POLLOUT;
|
|
+
|
|
+ while (remainingSize > 0) {
|
|
+ const int ready = poll(pfds, 1, 5000);
|
|
+ if (ready < 0) {
|
|
+ if (errno != EINTR) {
|
|
+ return;
|
|
+ }
|
|
+ } else if (ready == 0) {
|
|
+ return;
|
|
+ } else if (!(pfds[0].revents & POLLOUT)) {
|
|
+ return;
|
|
+ } else {
|
|
+ const char *chunk = buffer.constData() + (buffer.size() - remainingSize);
|
|
+ const ssize_t n = write(fd.get(), chunk, remainingSize);
|
|
+ if (n <= 0) {
|
|
+ return;
|
|
+ }
|
|
+ remainingSize -= n;
|
|
+ }
|
|
+ }
|
|
+}
|
|
+
|
|
+void VmsiloBufferSource::requestData(const QString &mimeType, FileDescriptor fd)
|
|
+{
|
|
+ const auto it = m_data.find(mimeType);
|
|
+ if (it == m_data.end()) {
|
|
+ return;
|
|
+ }
|
|
+ const QByteArray data = it.value();
|
|
+ QThreadPool::globalInstance()->start([data, pipe = std::move(fd)]() mutable {
|
|
+ vmsiloWriteData(std::move(pipe), data);
|
|
+ });
|
|
+}
|
|
+
|
|
+void VmsiloBufferSource::cancel()
|
|
+{
|
|
+}
|
|
+
|
|
+QStringList VmsiloBufferSource::mimeTypes() const
|
|
+{
|
|
+ return m_data.keys();
|
|
+}
|
|
+
|
|
+const QHash<QString, QByteArray> &VmsiloBufferSource::data() const
|
|
+{
|
|
+ return m_data;
|
|
+}
|
|
+
|
|
+// --- VmsiloAsyncCopyOperation ---
|
|
+
|
|
+static constexpr qsizetype maxClipboardSize = 128 * 1024 * 1024; // 128 MB
|
|
+static constexpr int pipeBufferSize = 1024 * 1024; // 1 MB
|
|
+static constexpr int readChunkSize = 64 * 1024; // 64 KB
|
|
+static constexpr int copyTimeoutMs = 10000; // 10 seconds
|
|
+
|
|
+VmsiloAsyncCopyOperation::VmsiloAsyncCopyOperation(AbstractDataSource *source, QObject *parent)
|
|
+ : QObject(parent)
|
|
+{
|
|
+ const QStringList types = source->mimeTypes();
|
|
+ for (const QString &mimeType : types) {
|
|
+ if (!isSafeMimeType(mimeType)) {
|
|
+ continue;
|
|
+ }
|
|
+
|
|
+ std::optional<Pipe> pipe = Pipe::create(O_CLOEXEC);
|
|
+ if (!pipe) {
|
|
+ qCWarning(KWIN_CORE) << "Vmsilo: failed to create pipe for" << mimeType;
|
|
+ continue;
|
|
+ }
|
|
+
|
|
+ // Set read end to non-blocking. Write end stays blocking so client
|
|
+ // applications don't get EAGAIN (which most don't handle).
|
|
+ fcntl(pipe->readEndpoint.get(), F_SETFL, O_NONBLOCK);
|
|
+
|
|
+ // Increase kernel pipe buffer to 1MB for better throughput.
|
|
+ // Failure is non-fatal — falls back to default 64KB.
|
|
+ fcntl(pipe->readEndpoint.get(), F_SETPIPE_SZ, pipeBufferSize);
|
|
+
|
|
+ source->requestData(mimeType, std::move(pipe->writeEndpoint));
|
|
+
|
|
+ auto *reader = new PipeReader{
|
|
+ .mimeType = mimeType,
|
|
+ .fd = std::move(pipe->readEndpoint),
|
|
+ .notifier = nullptr,
|
|
+ .buffer = {},
|
|
+ };
|
|
+
|
|
+ reader->notifier = new QSocketNotifier(reader->fd.get(), QSocketNotifier::Read, this);
|
|
+ connect(reader->notifier, &QSocketNotifier::activated, this, [this, reader]() {
|
|
+ onReadable(reader);
|
|
+ });
|
|
+
|
|
+ m_readers.append(reader);
|
|
+ }
|
|
+
|
|
+ m_activeCount = m_readers.size();
|
|
+
|
|
+ // Single flush after all requestData() calls — send_send() enqueues Wayland
|
|
+ // protocol messages; the flush ensures all are delivered atomically.
|
|
+ waylandServer()->display()->flush();
|
|
+
|
|
+ if (m_activeCount == 0) {
|
|
+ // No safe MIME types — finish immediately on next event loop iteration.
|
|
+ QTimer::singleShot(0, this, [this]() {
|
|
+ complete();
|
|
+ });
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ // Safety timeout to prevent permanent "Copying..." state if a source hangs.
|
|
+ m_timeoutTimer = new QTimer(this);
|
|
+ m_timeoutTimer->setSingleShot(true);
|
|
+ connect(m_timeoutTimer, &QTimer::timeout, this, &VmsiloAsyncCopyOperation::onTimeout);
|
|
+ m_timeoutTimer->start(copyTimeoutMs);
|
|
+}
|
|
+
|
|
+VmsiloAsyncCopyOperation::~VmsiloAsyncCopyOperation()
|
|
+{
|
|
+ // Disable notifiers before closing fds. The notifiers are parented to
|
|
+ // `this` and will be deleted by QObject::~QObject(), but we must ensure
|
|
+ // they don't reference closed fds between PipeReader deletion and
|
|
+ // QObject child cleanup.
|
|
+ for (auto *reader : std::as_const(m_readers)) {
|
|
+ delete reader->notifier;
|
|
+ reader->notifier = nullptr;
|
|
+ }
|
|
+ // Closing pipe read ends causes source clients to get EPIPE on their
|
|
+ // write ends — clean cancellation with no protocol violations.
|
|
+ qDeleteAll(m_readers);
|
|
+}
|
|
+
|
|
+void VmsiloAsyncCopyOperation::onReadable(PipeReader *reader)
|
|
+{
|
|
+ char chunk[readChunkSize];
|
|
+
|
|
+ while (true) {
|
|
+ const ssize_t n = read(reader->fd.get(), chunk, sizeof(chunk));
|
|
+ if (n > 0) {
|
|
+ reader->buffer.append(chunk, n);
|
|
+ if (reader->buffer.size() > maxClipboardSize) {
|
|
+ qCWarning(KWIN_CORE) << "Vmsilo: data for" << reader->mimeType
|
|
+ << "exceeds max clipboard size, truncating";
|
|
+ reader->notifier->setEnabled(false);
|
|
+ tryFinish();
|
|
+ return;
|
|
+ }
|
|
+ } else if (n == 0) {
|
|
+ // EOF — this pipe is done.
|
|
+ reader->notifier->setEnabled(false);
|
|
+ tryFinish();
|
|
+ return;
|
|
+ } else {
|
|
+ // n < 0
|
|
+ if (errno == EAGAIN || errno == EWOULDBLOCK) {
|
|
+ // No more data available right now — wait for next notification.
|
|
+ return;
|
|
+ }
|
|
+ if (errno == EINTR) {
|
|
+ continue;
|
|
+ }
|
|
+ // Actual error — treat this pipe as done.
|
|
+ qCWarning(KWIN_CORE) << "Vmsilo: read error on pipe for" << reader->mimeType << ":" << strerror(errno);
|
|
+ reader->notifier->setEnabled(false);
|
|
+ tryFinish();
|
|
+ return;
|
|
+ }
|
|
+ }
|
|
+}
|
|
+
|
|
+void VmsiloAsyncCopyOperation::tryFinish()
|
|
+{
|
|
+ --m_activeCount;
|
|
+ if (m_activeCount <= 0) {
|
|
+ complete();
|
|
+ }
|
|
+}
|
|
+
|
|
+void VmsiloAsyncCopyOperation::onTimeout()
|
|
+{
|
|
+ qCWarning(KWIN_CORE) << "Vmsilo: async copy timed out with" << m_activeCount << "pipes still active";
|
|
+ m_partial = true;
|
|
+ for (auto *r : std::as_const(m_readers)) {
|
|
+ if (r->notifier) {
|
|
+ r->notifier->setEnabled(false);
|
|
+ }
|
|
+ }
|
|
+ m_activeCount = 0;
|
|
+ complete();
|
|
+}
|
|
+
|
|
+void VmsiloAsyncCopyOperation::complete()
|
|
+{
|
|
+ if (m_completed) {
|
|
+ return;
|
|
+ }
|
|
+ m_completed = true;
|
|
+
|
|
+ if (m_timeoutTimer) {
|
|
+ m_timeoutTimer->stop();
|
|
+ }
|
|
+
|
|
+ QHash<QString, QByteArray> data;
|
|
+ for (const auto *reader : std::as_const(m_readers)) {
|
|
+ if (!reader->buffer.isEmpty()) {
|
|
+ data.insert(reader->mimeType, reader->buffer);
|
|
+ }
|
|
+ }
|
|
+
|
|
+ Q_EMIT finished(data);
|
|
+}
|
|
+
|
|
+bool VmsiloAsyncCopyOperation::isPartial() const
|
|
+{
|
|
+ return m_partial;
|
|
+}
|
|
+
|
|
+bool VmsiloAsyncCopyOperation::isSafeMimeType(const QString &mimeType)
|
|
+{
|
|
+ return mimeType.startsWith(QLatin1String("text/"))
|
|
+ || mimeType.startsWith(QLatin1String("image/"));
|
|
+}
|
|
+
|
|
+// --- VmsiloClipboardManager ---
|
|
+
|
|
+VmsiloClipboardManager::VmsiloClipboardManager(QObject *parent)
|
|
+ : QObject(parent)
|
|
+{
|
|
+ // Connect to focus changes. windowActivated fires BEFORE the seat sends
|
|
+ // clipboard offers to the newly focused client (verified: activation.cpp:270
|
|
+ // emits the signal, then keyboard_input.cpp slot calls seat->setFocusedKeyboardSurface
|
|
+ // which calls setFocusedDataDeviceSurface). This ordering is critical — our swap
|
|
+ // must complete before the seat delivers offers.
|
|
+ connect(workspace(), &Workspace::windowActivated,
|
|
+ this, &VmsiloClipboardManager::onWindowActivated);
|
|
+
|
|
+ // Register Ctrl+Shift+C: copy focused context clipboard to global buffer
|
|
+ auto *copyAction = new QAction(this);
|
|
+ copyAction->setProperty("componentName", QStringLiteral("kwin"));
|
|
+ copyAction->setObjectName(QStringLiteral("VmSilo Copy to Global Clipboard"));
|
|
+ copyAction->setText(i18n("Copy to Global Clipboard"));
|
|
+ KGlobalAccel::self()->setDefaultShortcut(copyAction,
|
|
+ QList<QKeySequence>() << (Qt::CTRL | Qt::SHIFT | Qt::Key_C));
|
|
+ KGlobalAccel::self()->setShortcut(copyAction,
|
|
+ QList<QKeySequence>() << (Qt::CTRL | Qt::SHIFT | Qt::Key_C));
|
|
+ connect(copyAction, &QAction::triggered, this, &VmsiloClipboardManager::copyToGlobal);
|
|
+
|
|
+ // Register Ctrl+Shift+V: paste global buffer to focused context clipboard
|
|
+ auto *pasteAction = new QAction(this);
|
|
+ pasteAction->setProperty("componentName", QStringLiteral("kwin"));
|
|
+ pasteAction->setObjectName(QStringLiteral("VmSilo Paste from Global Clipboard"));
|
|
+ pasteAction->setText(i18n("Paste from Global Clipboard"));
|
|
+ KGlobalAccel::self()->setDefaultShortcut(pasteAction,
|
|
+ QList<QKeySequence>() << (Qt::CTRL | Qt::SHIFT | Qt::Key_V));
|
|
+ KGlobalAccel::self()->setShortcut(pasteAction,
|
|
+ QList<QKeySequence>() << (Qt::CTRL | Qt::SHIFT | Qt::Key_V));
|
|
+ connect(pasteAction, &QAction::triggered, this, &VmsiloClipboardManager::pasteFromGlobal);
|
|
+}
|
|
+
|
|
+void VmsiloClipboardManager::cleanupStaleContexts()
|
|
+{
|
|
+ QSet<QString> activeContexts;
|
|
+ const auto windows = workspace()->windows();
|
|
+ for (const Window *w : windows) {
|
|
+ if (w->hasVmsiloSecurityContext()) {
|
|
+ activeContexts.insert(w->vmsiloName());
|
|
+ }
|
|
+ }
|
|
+
|
|
+ auto it = m_contextBuffers.begin();
|
|
+ while (it != m_contextBuffers.end()) {
|
|
+ if (!activeContexts.contains(it.key())) {
|
|
+ qCDebug(KWIN_CORE) << "Vmsilo: cleaning up stale clipboard for context" << it.key();
|
|
+ // Clean up saved sources for this dead context
|
|
+ if (auto *sel = it->selection.data()) {
|
|
+ if (qobject_cast<VmsiloBufferSource *>(sel)) {
|
|
+ delete sel;
|
|
+ } else {
|
|
+ sel->cancel();
|
|
+ }
|
|
+ }
|
|
+ if (auto *pri = it->primarySelection.data()) {
|
|
+ if (qobject_cast<VmsiloBufferSource *>(pri)) {
|
|
+ delete pri;
|
|
+ } else {
|
|
+ pri->cancel();
|
|
+ }
|
|
+ }
|
|
+ it = m_contextBuffers.erase(it);
|
|
+ } else {
|
|
+ ++it;
|
|
+ }
|
|
+ }
|
|
+}
|
|
+
|
|
+void VmsiloClipboardManager::scheduleCleanup()
|
|
+{
|
|
+ if (!m_cleanupScheduled) {
|
|
+ m_cleanupScheduled = true;
|
|
+ QTimer::singleShot(0, this, [this]() {
|
|
+ m_cleanupScheduled = false;
|
|
+ cleanupStaleContexts();
|
|
+ });
|
|
+ }
|
|
+}
|
|
+
|
|
+void VmsiloClipboardManager::onWindowActivated(Window *window)
|
|
+{
|
|
+ if (m_swapping) {
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ const QString newContext = (window && window->hasVmsiloSecurityContext())
|
|
+ ? window->vmsiloName()
|
|
+ : QString();
|
|
+
|
|
+ if (newContext == m_activeContext) {
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ m_swapping = true;
|
|
+
|
|
+ qCDebug(KWIN_CORE) << "Vmsilo: clipboard context switch from"
|
|
+ << (m_activeContext.isEmpty() ? QStringLiteral("host") : m_activeContext)
|
|
+ << "to" << (newContext.isEmpty() ? QStringLiteral("host") : newContext);
|
|
+
|
|
+ // Save the current seat selection to the outgoing context
|
|
+ saveToContext(m_activeContext);
|
|
+
|
|
+ // Load the incoming context's selection into the seat
|
|
+ m_activeContext = newContext;
|
|
+ restoreFromContext(m_activeContext);
|
|
+
|
|
+ m_swapping = false;
|
|
+
|
|
+ // Defer cleanup to idle — removes O(n) window iteration from the focus-switch
|
|
+ // hot path. Safe because cleanup only removes entries for contexts with zero
|
|
+ // remaining windows, while the swap operates on contexts that have at least one.
|
|
+ scheduleCleanup();
|
|
+}
|
|
+
|
|
+void VmsiloClipboardManager::saveToContext(const QString &context)
|
|
+{
|
|
+ auto *seat = waylandServer()->seat();
|
|
+ auto *selection = seat->takeSelection();
|
|
+ auto *primarySelection = seat->takePrimarySelection();
|
|
+
|
|
+ if (context.isEmpty()) {
|
|
+ m_hostSelection = selection;
|
|
+ m_hostPrimarySelection = primarySelection;
|
|
+ } else {
|
|
+ m_contextBuffers[context] = ContextClipboard{
|
|
+ .selection = selection,
|
|
+ .primarySelection = primarySelection,
|
|
+ };
|
|
+ }
|
|
+}
|
|
+
|
|
+void VmsiloClipboardManager::restoreFromContext(const QString &context)
|
|
+{
|
|
+ auto *seat = waylandServer()->seat();
|
|
+ auto *display = waylandServer()->display();
|
|
+ const auto serial = display->nextSerial();
|
|
+
|
|
+ AbstractDataSource *selection = nullptr;
|
|
+ AbstractDataSource *primarySelection = nullptr;
|
|
+
|
|
+ if (context.isEmpty()) {
|
|
+ selection = m_hostSelection.data();
|
|
+ primarySelection = m_hostPrimarySelection.data();
|
|
+ } else {
|
|
+ const auto it = m_contextBuffers.constFind(context);
|
|
+ if (it != m_contextBuffers.constEnd()) {
|
|
+ selection = it->selection.data();
|
|
+ primarySelection = it->primarySelection.data();
|
|
+ }
|
|
+ }
|
|
+
|
|
+ seat->setSelection(selection, serial);
|
|
+ seat->setPrimarySelection(primarySelection, serial);
|
|
+}
|
|
+
|
|
+void VmsiloClipboardManager::copyToGlobal()
|
|
+{
|
|
+ auto *seat = waylandServer()->seat();
|
|
+ AbstractDataSource *source = seat->selection();
|
|
+ if (!source) {
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ // Cancel any in-flight copy operation. Destroying the QObject disconnects
|
|
+ // all signals (DirectConnection, same thread), so no stale finished delivery.
|
|
+ delete m_activeCopy;
|
|
+ m_activeCopy = nullptr;
|
|
+
|
|
+ const QString contextName = m_activeContext.isEmpty() ? QStringLiteral("host") : m_activeContext;
|
|
+
|
|
+ m_activeCopy = new VmsiloAsyncCopyOperation(source, this);
|
|
+ m_copyInProgress = true;
|
|
+
|
|
+ qCDebug(KWIN_CORE) << "Vmsilo: starting async clipboard copy from context" << contextName;
|
|
+ OSD::show(i18n("Copying clipboard from %1...", contextName),
|
|
+ QStringLiteral("edit-copy"), 5000);
|
|
+
|
|
+ connect(m_activeCopy, &VmsiloAsyncCopyOperation::finished, this,
|
|
+ [this, contextName](const QHash<QString, QByteArray> &data) {
|
|
+ const bool partial = m_activeCopy->isPartial();
|
|
+
|
|
+ // Delete old global buffer if we own it and it's not the seat's active selection
|
|
+ auto *seat = waylandServer()->seat();
|
|
+ if (auto *old = qobject_cast<VmsiloBufferSource *>(m_globalSelection.data())) {
|
|
+ if (old != seat->selection()) {
|
|
+ delete old;
|
|
+ }
|
|
+ }
|
|
+
|
|
+ if (!data.isEmpty()) {
|
|
+ auto *bufferSource = new VmsiloBufferSource(data, this);
|
|
+ m_globalSelection = bufferSource;
|
|
+ }
|
|
+
|
|
+ m_copyInProgress = false;
|
|
+ // Use deleteLater() — we are inside a signal emitted by m_activeCopy,
|
|
+ // so deleting it directly would destroy the emitting object mid-signal.
|
|
+ // Qt's signal machinery may access the object after our slot returns.
|
|
+ m_activeCopy->deleteLater();
|
|
+ m_activeCopy = nullptr;
|
|
+
|
|
+ if (data.isEmpty()) {
|
|
+ qCDebug(KWIN_CORE) << "Vmsilo: async copy produced no data";
|
|
+ } else if (partial) {
|
|
+ qCDebug(KWIN_CORE) << "Vmsilo: clipboard partially copied from context" << contextName;
|
|
+ OSD::show(i18n("Clipboard copied from %1 (incomplete)", contextName),
|
|
+ QStringLiteral("edit-copy"), 2000);
|
|
+ } else {
|
|
+ qCDebug(KWIN_CORE) << "Vmsilo: clipboard copied from context" << contextName;
|
|
+ OSD::show(i18n("Clipboard copied from %1", contextName),
|
|
+ QStringLiteral("edit-copy"), 2000);
|
|
+ }
|
|
+ });
|
|
+}
|
|
+
|
|
+void VmsiloClipboardManager::pasteFromGlobal()
|
|
+{
|
|
+ if (m_copyInProgress) {
|
|
+ OSD::show(i18n("Copy still in progress..."),
|
|
+ QStringLiteral("dialog-warning"), 2000);
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ auto *bufferSource = qobject_cast<VmsiloBufferSource *>(m_globalSelection.data());
|
|
+ if (!bufferSource) {
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ auto *clone = new VmsiloBufferSource(bufferSource->data(), this);
|
|
+ auto *seat = waylandServer()->seat();
|
|
+ auto *display = waylandServer()->display();
|
|
+
|
|
+ seat->setSelection(clone, display->nextSerial());
|
|
+
|
|
+ const QString contextName = m_activeContext.isEmpty() ? QStringLiteral("host") : m_activeContext;
|
|
+ qCDebug(KWIN_CORE) << "Vmsilo: clipboard pasted to context" << contextName;
|
|
+ OSD::show(i18n("Clipboard pasted to %1", contextName),
|
|
+ QStringLiteral("edit-paste"), 2000);
|
|
+}
|
|
+
|
|
+}
|
|
diff --git a/src/vmsilo_clipboard_manager.h b/src/vmsilo_clipboard_manager.h
|
|
new file mode 100644
|
|
index 0000000000..34cfa95c07
|
|
--- /dev/null
|
|
+++ b/src/vmsilo_clipboard_manager.h
|
|
@@ -0,0 +1,139 @@
|
|
+/*
|
|
+ SPDX-FileCopyrightText: 2026 Davíð Steinn Geirsson
|
|
+
|
|
+ SPDX-License-Identifier: GPL-2.0-or-later
|
|
+*/
|
|
+
|
|
+#pragma once
|
|
+
|
|
+#include "wayland/abstract_data_source.h"
|
|
+
|
|
+#include "utils/filedescriptor.h"
|
|
+
|
|
+#include <QHash>
|
|
+#include <QObject>
|
|
+#include <QPointer>
|
|
+#include <QString>
|
|
+#include <QSocketNotifier>
|
|
+#include <QTimer>
|
|
+
|
|
+namespace KWin
|
|
+{
|
|
+
|
|
+class Window;
|
|
+
|
|
+/**
|
|
+ * Synthetic data source that holds eagerly-read clipboard data in memory.
|
|
+ * Used for cross-context clipboard transfers (Ctrl+Shift+C/V) where we
|
|
+ * cannot pass the original client-owned source across context boundaries.
|
|
+ */
|
|
+class VmsiloBufferSource : public AbstractDataSource
|
|
+{
|
|
+ Q_OBJECT
|
|
+
|
|
+public:
|
|
+ explicit VmsiloBufferSource(const QHash<QString, QByteArray> &data, QObject *parent = nullptr);
|
|
+
|
|
+ void requestData(const QString &mimeType, FileDescriptor fd) override;
|
|
+ void cancel() override;
|
|
+ QStringList mimeTypes() const override;
|
|
+ const QHash<QString, QByteArray> &data() const;
|
|
+
|
|
+private:
|
|
+ QHash<QString, QByteArray> m_data;
|
|
+};
|
|
+
|
|
+/**
|
|
+ * Async operation that reads clipboard data from an AbstractDataSource
|
|
+ * without blocking the compositor's main thread.
|
|
+ *
|
|
+ * Opens one pipe per MIME type, calls requestData() for each, then uses
|
|
+ * QSocketNotifier to read data as it arrives on the event loop. Emits
|
|
+ * finished() when all pipes are drained or on timeout.
|
|
+ *
|
|
+ * After construction, this object holds only pipe read-end file descriptors.
|
|
+ * It does not retain a reference to the source object.
|
|
+ */
|
|
+class VmsiloAsyncCopyOperation : public QObject
|
|
+{
|
|
+ Q_OBJECT
|
|
+
|
|
+public:
|
|
+ explicit VmsiloAsyncCopyOperation(AbstractDataSource *source, QObject *parent = nullptr);
|
|
+ ~VmsiloAsyncCopyOperation() override;
|
|
+
|
|
+ bool isPartial() const;
|
|
+
|
|
+Q_SIGNALS:
|
|
+ void finished(const QHash<QString, QByteArray> &data);
|
|
+
|
|
+private:
|
|
+ struct PipeReader {
|
|
+ QString mimeType;
|
|
+ FileDescriptor fd;
|
|
+ QSocketNotifier *notifier = nullptr;
|
|
+ QByteArray buffer;
|
|
+ };
|
|
+
|
|
+ void onReadable(PipeReader *reader);
|
|
+ void onTimeout();
|
|
+ void tryFinish();
|
|
+ void complete();
|
|
+
|
|
+ static bool isSafeMimeType(const QString &mimeType);
|
|
+
|
|
+ QList<PipeReader *> m_readers;
|
|
+ int m_activeCount = 0;
|
|
+ bool m_partial = false;
|
|
+ bool m_completed = false;
|
|
+ QTimer *m_timeoutTimer = nullptr;
|
|
+};
|
|
+
|
|
+/**
|
|
+ * Manages per-security-context clipboard isolation for vmsilo.
|
|
+ *
|
|
+ * Maintains separate clipboard and primary selection buffers for each vmsilo
|
|
+ * security context. On focus transitions between contexts, swaps the active
|
|
+ * buffer in the Wayland Seat. Provides Ctrl+Shift+C/V shortcuts to explicitly
|
|
+ * transfer clipboard data between the focused context and a global buffer.
|
|
+ */
|
|
+class VmsiloClipboardManager : public QObject
|
|
+{
|
|
+ Q_OBJECT
|
|
+
|
|
+public:
|
|
+ explicit VmsiloClipboardManager(QObject *parent = nullptr);
|
|
+
|
|
+private:
|
|
+ struct ContextClipboard {
|
|
+ QPointer<AbstractDataSource> selection;
|
|
+ QPointer<AbstractDataSource> primarySelection;
|
|
+ };
|
|
+
|
|
+ void onWindowActivated(Window *window);
|
|
+ void copyToGlobal();
|
|
+ void pasteFromGlobal();
|
|
+
|
|
+ void saveToContext(const QString &context);
|
|
+ void restoreFromContext(const QString &context);
|
|
+ void cleanupStaleContexts();
|
|
+ void scheduleCleanup();
|
|
+
|
|
+ QString m_activeContext;
|
|
+ QPointer<AbstractDataSource> m_hostSelection;
|
|
+ QPointer<AbstractDataSource> m_hostPrimarySelection;
|
|
+ QHash<QString, ContextClipboard> m_contextBuffers;
|
|
+
|
|
+ // Global buffer for explicit cross-context transfers (Ctrl+Shift+C/V)
|
|
+ QPointer<AbstractDataSource> m_globalSelection;
|
|
+
|
|
+ // Prevent recursive swaps
|
|
+ bool m_swapping = false;
|
|
+
|
|
+ // Async copy state
|
|
+ bool m_copyInProgress = false;
|
|
+ VmsiloAsyncCopyOperation *m_activeCopy = nullptr;
|
|
+ bool m_cleanupScheduled = false;
|
|
+};
|
|
+
|
|
+}
|
|
diff --git a/src/wayland/seat.cpp b/src/wayland/seat.cpp
|
|
index d2d7a8708c..c8df945ad9 100644
|
|
--- a/src/wayland/seat.cpp
|
|
+++ b/src/wayland/seat.cpp
|
|
@@ -1192,6 +1192,18 @@ void SeatInterface::setSelection(AbstractDataSource *selection, UInt32Serial ser
|
|
Q_EMIT selectionChanged(selection);
|
|
}
|
|
|
|
+AbstractDataSource *SeatInterface::takeSelection()
|
|
+{
|
|
+ AbstractDataSource *taken = d->currentSelection;
|
|
+ if (taken) {
|
|
+ qDeleteAll(d->globalDataDevice.selectionOffers);
|
|
+ d->globalDataDevice.selectionOffers.clear();
|
|
+ disconnect(taken, nullptr, this, nullptr);
|
|
+ }
|
|
+ d->currentSelection = nullptr;
|
|
+ return taken;
|
|
+}
|
|
+
|
|
AbstractDataSource *SeatInterface::primarySelection() const
|
|
{
|
|
return d->currentPrimarySelection;
|
|
@@ -1230,6 +1242,18 @@ void SeatInterface::setPrimarySelection(AbstractDataSource *selection, UInt32Ser
|
|
Q_EMIT primarySelectionChanged(selection);
|
|
}
|
|
|
|
+AbstractDataSource *SeatInterface::takePrimarySelection()
|
|
+{
|
|
+ AbstractDataSource *taken = d->currentPrimarySelection;
|
|
+ if (taken) {
|
|
+ qDeleteAll(d->globalDataDevice.primarySelectionOffers);
|
|
+ d->globalDataDevice.primarySelectionOffers.clear();
|
|
+ disconnect(taken, nullptr, this, nullptr);
|
|
+ }
|
|
+ d->currentPrimarySelection = nullptr;
|
|
+ return taken;
|
|
+}
|
|
+
|
|
void SeatInterface::setFocusedDataDeviceSurface(SurfaceInterface *surface)
|
|
{
|
|
ClientConnection *client = surface ? surface->client() : nullptr;
|
|
diff --git a/src/wayland/seat.h b/src/wayland/seat.h
|
|
index a8e65d7adc..acdc23d7a3 100644
|
|
--- a/src/wayland/seat.h
|
|
+++ b/src/wayland/seat.h
|
|
@@ -618,6 +618,20 @@ public:
|
|
AbstractDataSource *primarySelection() const;
|
|
void setPrimarySelection(AbstractDataSource *selection, UInt32Serial serial);
|
|
|
|
+ /**
|
|
+ * Detaches the current clipboard selection from the seat and returns it.
|
|
+ * Unlike setSelection(nullptr), this does NOT call cancel() on the source,
|
|
+ * so the owning client is not notified. Used by VmsiloClipboardManager to
|
|
+ * save a context's clipboard before swapping in another context's clipboard.
|
|
+ */
|
|
+ AbstractDataSource *takeSelection();
|
|
+
|
|
+ /**
|
|
+ * Detaches the current primary selection from the seat and returns it.
|
|
+ * Same semantics as takeSelection() but for primary selection.
|
|
+ */
|
|
+ AbstractDataSource *takePrimarySelection();
|
|
+
|
|
void setFocusedDataDeviceSurface(SurfaceInterface *surface);
|
|
|
|
/**
|
|
diff --git a/src/wayland_server.cpp b/src/wayland_server.cpp
|
|
index 56d3bfd52d..216ad61920 100644
|
|
--- a/src/wayland_server.cpp
|
|
+++ b/src/wayland_server.cpp
|
|
@@ -25,6 +25,7 @@
|
|
#include "utils/kernel.h"
|
|
#include "utils/serviceutils.h"
|
|
#include "virtualdesktops.h"
|
|
+#include "vmsilo_clipboard_manager.h"
|
|
#include "wayland/alphamodifier_v1.h"
|
|
#include "wayland/appmenu.h"
|
|
#include "wayland/clientconnection.h"
|
|
@@ -104,6 +105,9 @@
|
|
#include <QDir>
|
|
#include <QFileInfo>
|
|
|
|
+#include <KConfigGroup>
|
|
+#include <KConfigWatcher>
|
|
+
|
|
// system
|
|
#include <sys/socket.h>
|
|
#include <sys/types.h>
|
|
@@ -125,6 +129,7 @@ public:
|
|
KWinDisplay(QObject *parent)
|
|
: FilteredDisplay(parent)
|
|
{
|
|
+ initVmsiloConfig();
|
|
}
|
|
|
|
QStringList fetchRequestedInterfaces(ClientConnection *client) const
|
|
@@ -135,6 +140,68 @@ public:
|
|
return KWin::fetchRequestedInterfaces(client->executablePath());
|
|
}
|
|
|
|
+ void initVmsiloConfig()
|
|
+ {
|
|
+ reloadVmsiloConfig();
|
|
+
|
|
+ m_vmsiloConfigWatcher = KConfigWatcher::create(kwinApp()->config());
|
|
+ connect(m_vmsiloConfigWatcher.data(), &KConfigWatcher::configChanged, this, [this](const KConfigGroup &group) {
|
|
+ if (group.name() == QLatin1String("Vmsilo")) {
|
|
+ reloadVmsiloConfig();
|
|
+ }
|
|
+ });
|
|
+ }
|
|
+
|
|
+ void reloadVmsiloConfig()
|
|
+ {
|
|
+ static const QStringList defaultProtocols = {
|
|
+ QStringLiteral("wl_shm"),
|
|
+ QStringLiteral("wl_compositor"),
|
|
+ QStringLiteral("wl_subcompositor"),
|
|
+ QStringLiteral("xdg_wm_base"),
|
|
+ QStringLiteral("wl_data_device_manager"),
|
|
+ QStringLiteral("zxdg_output_manager_v1"),
|
|
+ QStringLiteral("zwp_primary_selection_device_manager_v1"),
|
|
+ QStringLiteral("gtk_primary_selection_device_manager"),
|
|
+ QStringLiteral("wl_seat"),
|
|
+ QStringLiteral("wl_output"),
|
|
+ QStringLiteral("org_kde_kwin_server_decoration_manager"),
|
|
+ QStringLiteral("zxdg_decoration_manager_v1"),
|
|
+ QStringLiteral("zwp_relative_pointer_manager_v1"),
|
|
+ QStringLiteral("zwp_pointer_constraints_v1"),
|
|
+ QStringLiteral("wp_viewporter"),
|
|
+ QStringLiteral("wp_cursor_shape_manager_v1"),
|
|
+ QStringLiteral("wp_fractional_scale_manager_v1"),
|
|
+ QStringLiteral("wp_single_pixel_buffer_manager_v1"),
|
|
+ QStringLiteral("wp_alpha_modifier_v1"),
|
|
+ QStringLiteral("wp_color_representation_manager_v1"),
|
|
+ QStringLiteral("wp_color_manager_v1"),
|
|
+ QStringLiteral("frog_color_management_factory_v1"),
|
|
+ QStringLiteral("wp_fifo_manager_v1"),
|
|
+ QStringLiteral("wp_presentation"),
|
|
+ QStringLiteral("commit_timing_v1"),
|
|
+ QStringLiteral("ext_data_control_v1"),
|
|
+ QStringLiteral("xdg_dialog_v1"),
|
|
+ };
|
|
+
|
|
+ const KConfigGroup group = kwinApp()->config()->group(QStringLiteral("Vmsilo"));
|
|
+
|
|
+ QStringList protocols;
|
|
+ if (group.hasKey("AllowedProtocols")) {
|
|
+ protocols = group.readEntry("AllowedProtocols", QStringList());
|
|
+ } else {
|
|
+ protocols = defaultProtocols;
|
|
+ }
|
|
+
|
|
+ QSet<QByteArray> newSet;
|
|
+ for (const QString &protocol : protocols) {
|
|
+ newSet.insert(protocol.trimmed().toUtf8());
|
|
+ }
|
|
+ vmsiloAllowedInterfaces = std::move(newSet);
|
|
+
|
|
+ qCDebug(KWIN_CORE) << "Vmsilo allowed protocols loaded:" << vmsiloAllowedInterfaces.size() << "protocols";
|
|
+ }
|
|
+
|
|
const QSet<QByteArray> interfacesBlackList = {
|
|
QByteArrayLiteral("org_kde_plasma_window_management"),
|
|
QByteArrayLiteral("org_kde_kwin_fake_input"),
|
|
@@ -150,6 +217,9 @@ public:
|
|
QByteArrayLiteral("xwayland_shell_v1"),
|
|
};
|
|
|
|
+ QSet<QByteArray> vmsiloAllowedInterfaces;
|
|
+ KConfigWatcher::Ptr m_vmsiloConfigWatcher;
|
|
+
|
|
QSet<QString> m_reported;
|
|
|
|
bool allowInterface(ClientConnection *client, const QByteArray &interfaceName) override
|
|
@@ -158,6 +228,10 @@ public:
|
|
return false;
|
|
}
|
|
|
|
+ if (client->securityContextAppId().startsWith(QLatin1String("vmsilo:"))) {
|
|
+ return vmsiloAllowedInterfaces.contains(interfaceName);
|
|
+ }
|
|
+
|
|
if (client->processId() == getpid()) {
|
|
return true;
|
|
}
|
|
@@ -571,6 +645,9 @@ XdgExportedSurface *WaylandServer::exportAsForeign(SurfaceInterface *surface)
|
|
|
|
void WaylandServer::initWorkspace()
|
|
{
|
|
+ auto display = static_cast<KWinDisplay *>(m_display);
|
|
+ connect(workspace(), &Workspace::configChanged, display, &KWinDisplay::reloadVmsiloConfig);
|
|
+
|
|
auto inputPanelV1Integration = new InputPanelV1Integration(this);
|
|
connect(inputPanelV1Integration, &InputPanelV1Integration::windowCreated,
|
|
this, &WaylandServer::registerWindow);
|
|
@@ -859,6 +936,11 @@ PointerWarpV1 *WaylandServer::pointerWarp() const
|
|
return m_pointerWarp;
|
|
}
|
|
|
|
+void WaylandServer::createVmsiloClipboardManager()
|
|
+{
|
|
+ m_vmsiloClipboardManager = new VmsiloClipboardManager(this);
|
|
+}
|
|
+
|
|
void WaylandServer::setRenderBackend(RenderBackend *backend)
|
|
{
|
|
if (backend->drmDevice()->supportsSyncObjTimelines()) {
|
|
diff --git a/src/wayland_server.h b/src/wayland_server.h
|
|
index 5423a28b3f..28810978a7 100644
|
|
--- a/src/wayland_server.h
|
|
+++ b/src/wayland_server.h
|
|
@@ -42,6 +42,7 @@ class XdgForeignV2Interface;
|
|
class XdgOutputManagerV1Interface;
|
|
class DrmClientBufferIntegration;
|
|
class VmsiloManagerV1Interface;
|
|
+class VmsiloClipboardManager;
|
|
class LinuxDmaBufV1ClientBufferIntegration;
|
|
class TabletManagerV2Interface;
|
|
class KeyboardShortcutsInhibitManagerV1Interface;
|
|
@@ -120,6 +121,10 @@ public:
|
|
{
|
|
return m_vmsiloManager;
|
|
}
|
|
+ VmsiloClipboardManager *vmsiloClipboardManager() const
|
|
+ {
|
|
+ return m_vmsiloClipboardManager;
|
|
+ }
|
|
ServerSideDecorationManagerInterface *decorationManager() const
|
|
{
|
|
return m_decorationManager;
|
|
@@ -231,6 +236,8 @@ public:
|
|
ExternalBrightnessV1 *externalBrightness() const;
|
|
PointerWarpV1 *pointerWarp() const;
|
|
|
|
+ void createVmsiloClipboardManager();
|
|
+
|
|
void setRenderBackend(RenderBackend *backend);
|
|
|
|
Q_SIGNALS:
|
|
@@ -271,6 +278,7 @@ private:
|
|
PlasmaWindowActivationFeedbackInterface *m_plasmaActivationFeedback = nullptr;
|
|
PlasmaWindowManagementInterface *m_windowManagement = nullptr;
|
|
VmsiloManagerV1Interface *m_vmsiloManager = nullptr;
|
|
+ VmsiloClipboardManager *m_vmsiloClipboardManager = nullptr;
|
|
PlasmaVirtualDesktopManagementInterface *m_virtualDesktopManagement = nullptr;
|
|
ServerSideDecorationManagerInterface *m_decorationManager = nullptr;
|
|
OutputManagementV2Interface *m_outputManagement = nullptr;
|
|
diff --git a/src/workspace.cpp b/src/workspace.cpp
|
|
index d7380d92be..fde9ca846e 100644
|
|
--- a/src/workspace.cpp
|
|
+++ b/src/workspace.cpp
|
|
@@ -185,6 +185,10 @@ Workspace::Workspace()
|
|
|
|
initShortcuts();
|
|
|
|
+ if (waylandServer()) {
|
|
+ waylandServer()->createVmsiloClipboardManager();
|
|
+ }
|
|
+
|
|
init();
|
|
}
|
|
|
|
--
|
|
2.53.0
|
|
|