Skip to content

fix(tao): route the Linux clipboard through GTK instead of AWT - #583

Merged
kdroidFilter merged 3 commits into
nucleus-2.5from
fix/582-tao-linux-gtk-clipboard
Aug 20, 2026
Merged

fix(tao): route the Linux clipboard through GTK instead of AWT#583
kdroidFilter merged 3 commits into
nucleus-2.5from
fix/582-tao-linux-gtk-clipboard

Conversation

@kdroidFilter

@kdroidFilter kdroidFilter commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Closes #582.

Summary

  • Compose Desktop's only clipboard implementation reads java.awt.Toolkit.getSystemClipboard(), which is X11-only on Linux. A Tao window is a GTK window on whichever GDK backend the session provides, so on Wayland the two disagree: KWin publishes the Wayland selection to XWayland only while an X11 window is active, so Ctrl+V in a Wayland-native window pastes nothing, and with no XWayland at all the AWT call is headless.
  • New libnucleus_tao_linux_clipboard.so (GTK/GObject dlopen-ed RTLD_LOCAL, links -ldl only) plus a Clipboard / ClipboardManager pair installed over LocalClipboard / LocalClipboardManager in TaoComposeSceneHostLinux.
  • GDK rather than wl-clipboard / arboard: it speaks the current backend with the window's own seat, needs no wlr-data-control, serves the data from this process and forks nothing.
  • Falls back to the inherited AWT clipboard whenever the helper is unavailable (non-Linux, missing library, headless, GTK never initialised), so macOS and Windows are untouched and the locals are simply left alone.
  • Text crosses JNI as UTF-8 byte arrays, not jstring: GetStringUTFChars produces modified UTF-8 and would corrupt every non-BMP character (emoji).
  • Only text/plain crosses the process boundary. Compose's AnnotatedString flavor is JVM-local and no real selection can carry it (AWT cannot either), so the last published entry is cached and handed back while the selection still holds that text — span styles survive an in-app copy/paste.
  • LocalClipboardManager is deprecated upstream but still provided: legacy call sites would otherwise keep reading the X11 selection while everything else reads the Wayland one. Its blocking reads spin a nested GTK main loop, so off-thread callers are handed to AWT instead.
  • Plumbing: build.sh section + listings, the three CI verify lists (build-natives, pre-merge, publish-maven), and reachability-metadata.json entries for the bridge, the callback interface and its single named implementation.

Notes from testing on a real Wayland session

  • Without focus nothing works on Wayland: the compositor only sends selection offers to the focused client and only accepts set_selection with a serial from a real input event. No impact on real usage (Ctrl+C / Ctrl+V happen in a focused window), but a test that skips focusing measures GTK's local cache only.
  • While the app owns the selection, the GTK main loop is what answers a reader's data request — blocking that thread on wl-paste deadlocks both sides. The production path never blocks it.
  • Toolkit.getSystemClipboard() appears only in the three Compose classes this bypasses, so there is no second AWT path left in foundation / ui.

Test plan

  • :decorated-window-tao:taoHeadfulTest -Dnucleus.tao.headful.filter=582 — 2 new real-window cases, green on GNOME Wayland: reading a selection owned by another process (wl-copy, the reported bug) and publishing the app's selection so wl-paste sees it, round-trip byte-identical with emoji, same ClipEntry instance returned
  • :decorated-window-tao:test
  • :decorated-window-tao:ktlintCheck, :detekt, :apiCheck
  • nucleus_tao_linux_clipboard.c compiles clean under -Wall -Wextra
  • CI cross-arch natives build (linux-aarch64 slot)
  • Manual paste check on KDE Plasma Wayland (the reporter's session)

Compose Desktop's only clipboard implementation goes through
java.awt.Toolkit.getSystemClipboard(), which on Linux is X11-only. A Tao
window is a GTK window on whichever GDK backend the session provides, so
on Wayland the two disagree: KWin publishes the Wayland selection to
XWayland only while an X11 window is active, so Ctrl+V in a
Wayland-native window pastes nothing, and with no XWayland at all the AWT
call is headless.

Adds libnucleus_tao_linux_clipboard.so (GTK/GObject dlopen-ed, -ldl only)
and installs a Clipboard / ClipboardManager pair over LocalClipboard and
LocalClipboardManager in TaoComposeSceneHostLinux, so copy/paste lands on
the same backend as the window. GDK rather than wl-clipboard/arboard: it
speaks the current backend with the window's own seat, needs no
wlr-data-control, serves the data from this process and forks nothing.

Everything falls back to the inherited AWT clipboard when the helper is
unavailable, so macOS and Windows are untouched.

Text crosses JNI as UTF-8 byte arrays, not jstring: GetStringUTFChars
produces modified UTF-8 and would corrupt every non-BMP character.

Only text/plain crosses the process boundary. Compose's AnnotatedString
flavor is JVM-local and no real selection can carry it (AWT cannot
either), so the last published entry is cached and handed back while the
selection still holds that text, preserving span styles in-app.

Closes #582
Replacing LocalClipboard on Linux narrowed every clip entry to text:
images and file lists went from "whatever AWT could see" to null on read
and to nothing at all on write. AWT does carry those flavors — the
problem #582 describes is the channel, not the format — so the fix must
not cost them.

Reads now fall back to the AWT entry when GTK reports no text, unless
that entry offers text itself: GTK just said there is none, so its text
is the stale X11 selection, and returning it would reintroduce the bug.
Writes hand any entry without a text/plain flavor to AWT untouched.

GTK can carry images (gtk_clipboard_set_image) and file lists
(text/uri-list) too, which would free them from XWayland as well; until
then the fallback keeps parity.
@kdroidFilter

Copy link
Copy Markdown
Collaborator Author

Follow-up commit: the first version narrowed every clip entry to text on Linux, so images and file lists went from whatever AWT could see to null on read and to nothing on write. AWT does carry those flavors — verified on a GNOME Wayland session, wl-copy --type image/png then imageFlavor reads back fine through XWayland — so the fix must not cost them.

Third headful case added (an image on the clipboard still reaches the app), green alongside the other two. It skips itself when AWT cannot see the image either, since there is then no parity to preserve.

Carrying images and file lists natively (gtk_clipboard_set_image, text/uri-list) would free them from XWayland too — separate PR.

Text was the only format going through GTK, so images and file lists
still depended on AWT — that is, on XWayland, which is the dependency
this branch set out to remove. AWT has always carried both; the gap was
never the format, only the channel.

Images go through GdkPixbuf, which is what makes GTK advertise and
convert between image/png, image/bmp, image/jpeg and image/tiff; PNG is
the exchange format across JNI. File lists go out as text/uri-list plus
x-special/gnome-copied-files, the target GTK file managers actually
paste, served from a payload the helper owns until the selection changes
hands.

What the selection advertises now decides which flavors a clip entry
exposes, and the image and the file list are only fetched if something
reads them: pasting text while a screenshot sits on the clipboard should
not drag megabytes through JNI. Only a selection offering nothing this
code knows about (RTF, private targets) is left to AWT.

Listing the targets and fetching the content are two round-trips, so the
selection can change hands in between; that now reports an empty
clipboard instead of asking AWT, which would answer about a different
selection.

Three headful cases join the two existing ones, covering both directions
for images and file lists against wl-copy / wl-paste. They encode two
Wayland rules that a clipboard test gets wrong easily: a client is handed
the selection as it gains keyboard focus, so the external tool has to own
it first, and wl-copy returns before its serving process has taken over,
so the handover is confirmed through wl-paste rather than assumed.
@kdroidFilter

Copy link
Copy Markdown
Collaborator Author

Images and file lists now go through GTK too, so the AWT fallback is no longer load-bearing for anything but formats this code does not know (RTF, private targets).

  • images via GdkPixbuf — that is what makes GTK advertise and convert between image/png, image/bmp, image/jpeg, image/tiff; PNG is the exchange format across JNI;
  • file lists as text/uri-list + x-special/gnome-copied-files (the target GTK file managers actually paste), served from a payload the helper owns until the selection changes hands;
  • what the selection advertises decides which flavors a clip entry exposes, and the image / file list are fetched only if something reads them — pasting text while a screenshot sits on the clipboard should not drag megabytes through JNI.

Five headful cases, both directions per format, against wl-copy / wl-paste, with a fallback that fails the case if it is ever consulted — so whatever passes, passed through GTK. Six consecutive full runs green.

Two Wayland rules turned out to matter more than the code, and both are now encoded in the harness rather than left to luck:

  1. a client is handed the selection as it gains keyboard focus. An unfocused window neither sees nor can take the selection, so the external tool has to own it before the window is focused — which is also the user flow in the issue (copy elsewhere, come back, paste);
  2. wl-copy returns as soon as it has forked, so ownership is confirmed through wl-paste (data-control, not focus-gated) instead of assumed, and retried: the session's clipboard manager re-asserts the previous selection when its owning window goes away — the preceding case, in this suite — and can win the race.

Diagnosing those took most of the work; each of my first three attempts at the flakiness treated a symptom.

@kdroidFilter
kdroidFilter merged commit 0de81fc into nucleus-2.5 Aug 20, 2026
32 of 35 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant