From dc2ab76c36ad30a5ee366027e94992d4bbbccd33 Mon Sep 17 00:00:00 2001 From: Geoffrey McRae Date: Fri, 14 Aug 2026 11:46:39 +1000 Subject: [PATCH] [client] clipboard: suppress Wayland self-copy feedback --- client/displayservers/Wayland/clipboard.c | 70 +++++++++++++----- client/src/clipboard.c | 8 ++ client/tests/CMakeLists.txt | 4 + client/tests/clipboard_test.c | 60 +++++++++++++++ client/tests/wayland_clipboard_test.c | 90 ++++++++++++++++++++++- 5 files changed, 213 insertions(+), 19 deletions(-) diff --git a/client/displayservers/Wayland/clipboard.c b/client/displayservers/Wayland/clipboard.c index 5db0d410..1c78c594 100644 --- a/client/displayservers/Wayland/clipboard.c +++ b/client/displayservers/Wayland/clipboard.c @@ -192,6 +192,15 @@ static bool hasImageMimetype(char ** mimetypes) return false; } +enum ClipboardInvalidateMode +{ + CLIPBOARD_INVALIDATE_SILENT, + CLIPBOARD_INVALIDATE_CONDITIONAL, + CLIPBOARD_INVALIDATE_FORCE, +}; + +static void waylandCBInvalidateLocal(enum ClipboardInvalidateMode mode); + /* wlCb.lock must be held. */ static struct ClipboardRead * clipboardReadTakeCurrentNL(void) { @@ -312,14 +321,15 @@ static void dataDeviceHandleSelection(void * opaque, LG_LOCK(wlCb.lock); wlCb.selectionSource = NULL; LG_UNLOCK(wlCb.lock); - waylandCBInvalidate(); + waylandCBInvalidateLocal(CLIPBOARD_INVALIDATE_FORCE); return; } struct DataOffer * extra = wl_data_offer_get_user_data(offer); - if (!hasAnyMimetype(extra->mimetypes) || extra->isSelfCopy) + const bool selfCopy = extra->isSelfCopy; + if (!hasAnyMimetype(extra->mimetypes) || selfCopy) { - if (!extra->isSelfCopy) + if (!selfCopy) { LG_LOCK(wlCb.lock); wlCb.selectionSource = NULL; @@ -329,7 +339,12 @@ static void dataDeviceHandleSelection(void * opaque, for (enum LG_ClipboardData i = 0; i < LG_CLIPBOARD_DATA_NONE; ++i) free(extra->mimetypes[i]); free(extra); - waylandCBInvalidate(); + if (!selfCopy) + waylandCBInvalidateLocal(CLIPBOARD_INVALIDATE_FORCE); + else + /* This is the compositor echoing the source we just installed. It is + * not a new local clipboard owner and must not send a provider CLEAR. */ + waylandCBInvalidateLocal(CLIPBOARD_INVALIDATE_SILENT); wl_data_offer_destroy(offer); return; } @@ -647,7 +662,7 @@ static void clipboardReadCallback(uint32_t events, void * opaque) } } -void waylandCBInvalidate(void) +static void waylandCBInvalidateLocal(enum ClipboardInvalidateMode mode) { char * mimetypes[LG_CLIPBOARD_DATA_NONE]; LG_LOCK(wlCb.lock); @@ -656,16 +671,27 @@ void waylandCBInvalidate(void) wlCb.offer = NULL; memcpy(mimetypes, wlCb.mimetypes, sizeof(mimetypes)); memset(wlCb.mimetypes, 0, sizeof(wlCb.mimetypes)); + const bool hadLocal = read || offer || hasAnyMimetype(mimetypes); + const bool notifyProvider = mode == CLIPBOARD_INVALIDATE_FORCE || + (mode == CLIPBOARD_INVALIDATE_CONDITIONAL && hadLocal); + const LG_ClipboardRequest request = read ? read->request : + LG_CLIPBOARD_REQUEST_INVALID; + if (read) + clipboardReadRetire(read); + + /* Serialize the provider release with source publication. Clipboard + * provider operations may not synchronously deliver event callbacks, so + * calling this while holding wlCb.lock cannot re-enter the display server. */ + if (notifyProvider) + { + if (read) + lgClipboard_abort(request); + lgClipboard_release(); + } LG_UNLOCK(wlCb.lock); - if (read) - { - const LG_ClipboardRequest request = read->request; - clipboardReadRetire(read); + if (read && !notifyProvider) lgClipboard_abort(request); - } - - lgClipboard_release(); if (offer) wl_data_offer_destroy(offer); @@ -673,6 +699,11 @@ void waylandCBInvalidate(void) free(mimetypes[i]); } +void waylandCBInvalidate(void) +{ + waylandCBInvalidateLocal(CLIPBOARD_INVALIDATE_CONDITIONAL); +} + void waylandCBRequest(LG_ClipboardRequest request, LG_ClipboardData type) { if (request == LG_CLIPBOARD_REQUEST_INVALID || @@ -1186,6 +1217,11 @@ static void waylandCBPublish(LG_ClipboardData type) wl_data_source_offer(source, *mimetype); wl_data_source_offer(source, wlCb.lgMimetype); + /* Publishing a remote selection supersedes the old external offer. Retire + * it before set_selection so a simultaneous focus loss cannot release the + * provider while this remote source is active. */ + waylandCBInvalidateLocal(CLIPBOARD_INVALIDATE_SILENT); + LG_LOCK(wlCb.lock); if (wlCb.dataDevice) { @@ -1194,13 +1230,13 @@ static void waylandCBPublish(LG_ClipboardData type) wlCb.selectionSource = source; wl_data_device_set_selection(wlCb.dataDevice, source, wlWm.keyboardEnterSerial); - } - else - { - wl_data_source_destroy(source); - free(transfer); + LG_UNLOCK(wlCb.lock); + return; } LG_UNLOCK(wlCb.lock); + + wl_data_source_destroy(source); + free(transfer); } void waylandCBNotice(LG_ClipboardData type) diff --git a/client/src/clipboard.c b/client/src/clipboard.c index fa77eeff..2452cf99 100644 --- a/client/src/clipboard.c +++ b/client/src/clipboard.c @@ -485,6 +485,10 @@ static void eventNotice(void * opaque, generation = clipboard.remoteGeneration; transfer = clearRemoteRequestNL(); notice = clipboard.localAvailable && g_params.clipboardToLocal; + /* Once published, the remote notice replaces the local clipboard. Do not + * replay that stale local offer if the provider is rebound. */ + if (notice) + clipboard.localTypeCount = 0; LG_UNLOCK(clipboard.stateLock); LG_UNLOCK(clipboard.requestLock); @@ -1222,6 +1226,10 @@ void lgClipboard_setLocalAvailable(bool available) notice = available && clipboard.remoteNotice && g_params.clipboardToLocal; type = clipboard.remoteType; + /* A pending remote notice replaces the local clipboard when it becomes + * publishable. Do not replay the superseded local offer afterwards. */ + if (notice) + clipboard.localTypeCount = 0; if (!available) transfer = clearRemoteRequestNL(); LG_UNLOCK(clipboard.stateLock); diff --git a/client/tests/CMakeLists.txt b/client/tests/CMakeLists.txt index b7eef4da..5c8deb5a 100644 --- a/client/tests/CMakeLists.txt +++ b/client/tests/CMakeLists.txt @@ -211,6 +211,8 @@ if(ENABLE_WAYLAND) teardown teardown-external self-copy + ignored + invalidate-lock dnd ) foreach(name IN LISTS WAYLAND_CLIPBOARD_CASES) @@ -471,6 +473,8 @@ target_link_libraries(clipboard-tests set(CLIPBOARD_CASES preference request + remote-local + pending-remote invalid cancel generation diff --git a/client/tests/clipboard_test.c b/client/tests/clipboard_test.c index 52bec160..55140937 100644 --- a/client/tests/clipboard_test.c +++ b/client/tests/clipboard_test.c @@ -627,6 +627,64 @@ static void testRequest(void) lgClipboard_free(); } +static void testRemoteReplacesLocal(void) +{ + init(); + bind(&p); + const LG_ClipboardData types[] = { LG_CLIPBOARD_DATA_TEXT }; + + lgClipboard_notifyTypes(types, 1); + CHECK(p.notice == 1); + + g_params.clipboardToLocal = false; + notice(&p, types, 1); + CHECK(d.notice == 0); + + /* A notice which is not published locally must not discard the local + * clipboard advertised towards the guest. */ + lgClipboard_setTransport(&plainOps, &q); + CHECK(q.attach == 1); + CHECK(q.notice == 1); + CHECK(q.release == 0); + + g_params.clipboardToLocal = true; + notice(&q, types, 1); + CHECK(d.notice == 1); + + /* Rebinding after a remote notice must not replay the superseded local + * offer back to the provider. */ + lgClipboard_setTransport(&plainOps, &r); + CHECK(r.attach == 1); + CHECK(r.notice == 0); + CHECK(r.release == 1); + + lgClipboard_free(); +} + +static void testPendingRemoteReplacesLocal(void) +{ + init(); + bind(&p); + const LG_ClipboardData types[] = { LG_CLIPBOARD_DATA_TEXT }; + + lgClipboard_notifyTypes(types, 1); + CHECK(p.notice == 1); + lgClipboard_setLocalAvailable(false); + + notice(&p, types, 1); + CHECK(d.notice == 0); + + lgClipboard_setLocalAvailable(true); + CHECK(d.notice == 1); + + lgClipboard_setTransport(&plainOps, &q); + CHECK(q.attach == 1); + CHECK(q.notice == 0); + CHECK(q.release == 1); + + lgClipboard_free(); +} + static void testInvalid(void) { init(); @@ -1071,6 +1129,8 @@ static const struct Test tests[] = { { "preference", testPreference }, { "request" , testRequest }, + { "remote-local", testRemoteReplacesLocal }, + { "pending-remote", testPendingRemoteReplacesLocal }, { "invalid" , testInvalid }, { "cancel" , testCancel }, { "generation", testGeneration }, diff --git a/client/tests/wayland_clipboard_test.c b/client/tests/wayland_clipboard_test.c index 2c2c5a93..bc698d4b 100644 --- a/client/tests/wayland_clipboard_test.c +++ b/client/tests/wayland_clipboard_test.c @@ -155,7 +155,9 @@ struct Log bool requestOK; bool requestCancelSync; bool requestBeginSync; + bool expectReleaseLocked; bool firing; + unsigned int releaseLockedN; }; struct WaylandDSState wlWm; @@ -440,6 +442,12 @@ void lgClipboard_notifyTypes( void lgClipboard_release(void) { + if (rec.expectReleaseLocked) + { + CHECK(atomic_flag_test_and_set_explicit( + &wlCb.lock, memory_order_acquire)); + ++rec.releaseLockedN; + } ++rec.releaseN; } @@ -690,11 +698,72 @@ static void testMime(void) } static void testSelfCopy(void) +{ + start(); + struct Proxy * local = textOffer(); + CHECK(rec.noticeN == 1); + + waylandCBNotice(LG_CLIPBOARD_DATA_TEXT); + struct Proxy * source = &proto.source[0]; + CHECK(wlCb.selectionSource == (struct wl_data_source *)source); + + /* Losing focus before the compositor echoes our source must not release + * the remote clipboard. */ + waylandCBInvalidate(); + CHECK(rec.releaseN == 0); + CHECK(wlCb.selectionSource == (struct wl_data_source *)source); + + struct Proxy * echo = newOffer(); + offerMime(echo, "text/plain"); + offerMime(echo, wlCb.lgMimetype); + selectOffer(echo); + + CHECK(rec.noticeN == 1); + CHECK(rec.releaseN == 0); + CHECK(wlCb.selectionSource == (struct wl_data_source *)source); + CHECK(!wlCb.offer); + for (enum LG_ClipboardData i = 0; i < LG_CLIPBOARD_DATA_NONE; ++i) + CHECK(!wlCb.mimetypes[i]); + CHECK(local->dead); + CHECK(echo->dead); + CHECK(proto.offerDestroyN == 2); + CHECK(!echo->user); + + waylandCBInvalidate(); + CHECK(rec.releaseN == 0); + CHECK(wlCb.selectionSource == (struct wl_data_source *)source); + + int fds[2]; + CHECK(pipe(fds) == 0); + sourceListener(source)->send(source->data, + (struct wl_data_source *)source, "text/plain", fds[1]); + CHECK(rec.requestN == 1); + CHECK(rec.requestType == LG_CLIPBOARD_DATA_TEXT); + CHECK(rec.requestStream); + CHECK(rec.requestOpaque); + CHECK(rec.requestStream->begin(rec.requestOpaque, + LG_CLIPBOARD_DATA_TEXT, LG_CLIPBOARD_SIZE_UNKNOWN) == + LG_CLIPBOARD_RESULT_ACCEPTED); + CHECK(rec.requestStream->end(rec.requestOpaque, 0) == + LG_CLIPBOARD_RESULT_ACCEPTED); + uint8_t value; + CHECK(read(fds[0], &value, 1) == 0); + CHECK(close(fds[0]) == 0); + checkClosed(fds[1]); + + selectOffer(NULL); + CHECK(rec.releaseN == 1); + CHECK(!wlCb.selectionSource); + + CHECK(proto.dirtyDestroyN == 0); + finish(); +} + +static void testIgnoredOffer(void) { start(); struct Proxy * offer = newOffer(); - offerMime(offer, "text/plain"); - offerMime(offer, wlCb.lgMimetype); + offerMime(offer, "application/x-unsupported"); selectOffer(offer); CHECK(rec.noticeN == 0); @@ -706,6 +775,21 @@ static void testSelfCopy(void) finish(); } +static void testInvalidateSerialization(void) +{ + start(); + struct Proxy * offer = textOffer(); + rec.expectReleaseLocked = true; + + waylandCBInvalidate(); + + CHECK(rec.releaseN == 1); + CHECK(rec.releaseLockedN == 1); + CHECK(offer->dead); + CHECK(!wlCb.offer); + finish(); +} + static void testReplace(void) { start(); @@ -1245,6 +1329,8 @@ static const struct Test tests[] = { "teardown" , testTeardown }, { "teardown-external", testTeardownExternalOwner }, { "self-copy" , testSelfCopy }, + { "ignored" , testIgnoredOffer }, + { "invalidate-lock", testInvalidateSerialization }, { "dnd" , testDnd }, };