Skip to content

Commit a33c442

Browse files
committed
feat(clipboard): add Linux X11/Wayland backend
Implement full clipboard support for Linux across both X11 (XCB + XFixes) and Wayland (wl-clipboard delegation). X11 backend handles format negotiation, INCR protocol for large transfers, change detection via XFixes. Wayland delegates to wl-copy/wl-paste with atomic format selection. Platform detection and fallback logic ensures compatibility across session types, including Xwayland on Wayland hosts.
1 parent 1129d6b commit a33c442

11 files changed

Lines changed: 2089 additions & 0 deletions

File tree

clipboard-linux/build.gradle.kts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import org.apache.tools.ant.taskdefs.condition.Os
2+
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
3+
4+
plugins {
5+
kotlin("jvm")
6+
alias(libs.plugins.vanniktechMavenPublish)
7+
}
8+
9+
val publishVersion =
10+
providers
11+
.environmentVariable("GITHUB_REF")
12+
.orNull
13+
?.removePrefix("refs/tags/v")
14+
?: "1.0.0"
15+
16+
dependencies {
17+
implementation(project(":core-runtime"))
18+
implementation(project(":clipboard-common"))
19+
implementation(libs.coroutines.core)
20+
testImplementation(kotlin("test"))
21+
}
22+
23+
java {
24+
sourceCompatibility = JavaVersion.VERSION_11
25+
targetCompatibility = JavaVersion.VERSION_11
26+
}
27+
28+
kotlin {
29+
compilerOptions {
30+
jvmTarget.set(JvmTarget.JVM_11)
31+
}
32+
}
33+
34+
val nativeResourceDir = layout.projectDirectory.dir("src/main/resources/nucleus/native")
35+
36+
val buildNativeLinux by tasks.registering(Exec::class) {
37+
description = "Compiles the C JNI bridge into a Linux shared library"
38+
group = "build"
39+
val hasPrebuilt =
40+
nativeResourceDir
41+
.dir("linux-x64")
42+
.file("libnucleus_clipboard_linux.so")
43+
.asFile
44+
.exists()
45+
enabled = Os.isFamily(Os.FAMILY_UNIX) && !Os.isFamily(Os.FAMILY_MAC) && !hasPrebuilt
46+
47+
val nativeDir = layout.projectDirectory.dir("src/main/native/linux")
48+
inputs.dir(nativeDir)
49+
outputs.dir(nativeResourceDir)
50+
workingDir(nativeDir)
51+
commandLine("bash", "build.sh")
52+
}
53+
54+
tasks.processResources {
55+
dependsOn(buildNativeLinux)
56+
}
57+
58+
tasks.configureEach {
59+
if (name == "sourcesJar") {
60+
dependsOn(buildNativeLinux)
61+
}
62+
}
63+
64+
mavenPublishing {
65+
coordinates("io.github.kdroidfilter", "nucleus.clipboard-linux", publishVersion)
66+
67+
pom {
68+
name.set("Nucleus Clipboard Linux")
69+
description.set(
70+
"Linux clipboard backend for Nucleus — XCB + XFixes on X11, " +
71+
"wl-clipboard delegation on Wayland.",
72+
)
73+
url.set("https://github.com/kdroidFilter/Nucleus")
74+
75+
licenses {
76+
license {
77+
name.set("MIT License")
78+
url.set("https://opensource.org/licenses/MIT")
79+
}
80+
}
81+
82+
developers {
83+
developer {
84+
id.set("kdroidfilter")
85+
name.set("kdroidFilter")
86+
url.set("https://github.com/kdroidFilter")
87+
}
88+
}
89+
90+
scm {
91+
url.set("https://github.com/kdroidFilter/Nucleus")
92+
connection.set("scm:git:git://github.com/kdroidFilter/Nucleus.git")
93+
developerConnection.set("scm:git:ssh://git@github.com/kdroidFilter/Nucleus.git")
94+
}
95+
}
96+
97+
publishToMavenCentral()
98+
if (project.hasProperty("signingInMemoryKey")) {
99+
signAllPublications()
100+
}
101+
}
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
package io.github.kdroidfilter.nucleus.clipboard.linux
2+
3+
import io.github.kdroidfilter.nucleus.clipboard.AccessBehavior
4+
import io.github.kdroidfilter.nucleus.clipboard.ClipboardFormat
5+
import io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardBackend
6+
import io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardWritePayload
7+
import io.github.kdroidfilter.nucleus.core.runtime.Platform
8+
import kotlinx.coroutines.Dispatchers
9+
import kotlinx.coroutines.withContext
10+
import java.nio.file.Path
11+
import java.nio.file.Paths
12+
13+
/**
14+
* Linux [ClipboardBackend] with a two-backend strategy:
15+
*
16+
* - **X11** — native XCB + XFixes via JNI (`libnucleus_clipboard_linux`).
17+
* Supports the full format matrix (text, HTML, RTF, image/png, files) and
18+
* delivers change events through XFixes selection notifications.
19+
* - **Wayland** — delegates to `wl-copy` / `wl-paste` from the `wl-clipboard`
20+
* project. This already handles the three protocol variants
21+
* (`ext-data-control-v1`, `zwlr_data_control_v1`, and focus-stealing core)
22+
* plus GNOME quirks that a native implementation would have to replicate.
23+
*
24+
* Selection heuristic:
25+
* - Wayland (`WAYLAND_DISPLAY` or `XDG_SESSION_TYPE=wayland`) → Wayland delegate
26+
* if `wl-copy`/`wl-paste` are on `PATH`, otherwise fall back to the X11
27+
* native path (Xwayland bridges CLIPBOARD for X11 clients).
28+
* - X11 → native XCB backend.
29+
*/
30+
@Suppress("TooManyFunctions")
31+
class LinuxClipboardBackend : ClipboardBackend {
32+
private enum class Mode { X11, WAYLAND, UNAVAILABLE }
33+
34+
private val wayland = WaylandClipboardDelegate()
35+
36+
private val mode: Mode by lazy { resolveMode() }
37+
38+
override val name: String
39+
get() =
40+
when (mode) {
41+
Mode.X11 -> "Linux X11 (XCB+XFixes)"
42+
Mode.WAYLAND -> "Linux Wayland (wl-clipboard)"
43+
Mode.UNAVAILABLE -> "Linux unavailable"
44+
}
45+
46+
override fun isAvailable(): Boolean = mode != Mode.UNAVAILABLE
47+
48+
private fun resolveMode(): Mode {
49+
if (Platform.Current != Platform.Linux) return Mode.UNAVAILABLE
50+
if (Platform.isWayland && wayland.isAvailable) {
51+
wayland.startWatcher()
52+
return Mode.WAYLAND
53+
}
54+
if (NativeX11ClipboardBridge.isLoaded && NativeX11ClipboardBridge.nativeInit()) {
55+
return Mode.X11
56+
}
57+
// Wayland session without wl-clipboard but with Xwayland available
58+
// (rare on modern distros — wl-clipboard is almost always shipped).
59+
return Mode.UNAVAILABLE
60+
}
61+
62+
// --- Reads ---
63+
64+
override suspend fun readText(): String? =
65+
withContext(Dispatchers.IO) {
66+
when (mode) {
67+
Mode.X11 -> NativeX11ClipboardBridge.nativeReadText()
68+
Mode.WAYLAND -> wayland.readText()
69+
Mode.UNAVAILABLE -> null
70+
}
71+
}
72+
73+
override suspend fun readHtml(): String? =
74+
withContext(Dispatchers.IO) {
75+
when (mode) {
76+
Mode.X11 -> NativeX11ClipboardBridge.nativeReadHtml()
77+
Mode.WAYLAND -> wayland.readHtml()
78+
Mode.UNAVAILABLE -> null
79+
}?.stripBom()
80+
}
81+
82+
override suspend fun readRtf(): String? =
83+
withContext(Dispatchers.IO) {
84+
when (mode) {
85+
Mode.X11 -> NativeX11ClipboardBridge.nativeReadRtf()
86+
Mode.WAYLAND -> wayland.readRtf()
87+
Mode.UNAVAILABLE -> null
88+
}
89+
}
90+
91+
override suspend fun readImagePng(): ByteArray? =
92+
withContext(Dispatchers.IO) {
93+
when (mode) {
94+
Mode.X11 -> NativeX11ClipboardBridge.nativeReadImagePng()
95+
Mode.WAYLAND -> wayland.readImagePng()
96+
Mode.UNAVAILABLE -> null
97+
}
98+
}
99+
100+
override suspend fun readFiles(): List<Path> =
101+
withContext(Dispatchers.IO) {
102+
when (mode) {
103+
Mode.X11 -> {
104+
val entries = NativeX11ClipboardBridge.nativeReadFilePaths()
105+
// First entry carries the copy/cut marker ("c" or "m"); we
106+
// currently don't expose cut-vs-copy in the public API so
107+
// it is discarded.
108+
entries
109+
.drop(1)
110+
.map(Paths::get)
111+
}
112+
Mode.WAYLAND -> wayland.readFiles()
113+
Mode.UNAVAILABLE -> emptyList()
114+
}
115+
}
116+
117+
override suspend fun availableFormats(): Set<ClipboardFormat> =
118+
withContext(Dispatchers.IO) {
119+
val targets: List<String> =
120+
when (mode) {
121+
Mode.X11 -> NativeX11ClipboardBridge.nativeAvailableTargets().toList()
122+
Mode.WAYLAND -> wayland.listTypes()
123+
Mode.UNAVAILABLE -> emptyList()
124+
}
125+
targets.toClipboardFormats()
126+
}
127+
128+
// --- Writes ---
129+
130+
override suspend fun write(payload: ClipboardWritePayload): Boolean =
131+
withContext(Dispatchers.IO) {
132+
when (mode) {
133+
Mode.X11 -> {
134+
val paths = payload.files?.map { it.toAbsolutePath().toString() }?.toTypedArray()
135+
NativeX11ClipboardBridge.nativeWrite(
136+
payload.text,
137+
payload.html,
138+
payload.rtf,
139+
payload.imagePng,
140+
paths,
141+
false,
142+
)
143+
}
144+
Mode.WAYLAND ->
145+
wayland.write(
146+
text = payload.text,
147+
html = payload.html,
148+
rtf = payload.rtf,
149+
imagePng = payload.imagePng,
150+
files = payload.files,
151+
)
152+
Mode.UNAVAILABLE -> false
153+
}
154+
}
155+
156+
override suspend fun clear(): Boolean =
157+
withContext(Dispatchers.IO) {
158+
when (mode) {
159+
Mode.X11 -> NativeX11ClipboardBridge.nativeClear()
160+
Mode.WAYLAND -> wayland.clear()
161+
Mode.UNAVAILABLE -> false
162+
}
163+
}
164+
165+
override fun changeCount(): Long =
166+
when (mode) {
167+
Mode.X11 -> NativeX11ClipboardBridge.nativeChangeCount()
168+
Mode.WAYLAND -> wayland.changeCount()
169+
Mode.UNAVAILABLE -> 0L
170+
}
171+
172+
// Linux has no OS-level pasteboard privacy toggle — callers are unrestricted.
173+
override fun setAccessBehavior(behavior: AccessBehavior) = Unit
174+
175+
override fun accessBehavior(): AccessBehavior? = null
176+
177+
override fun isAccessBehaviorSupported(): Boolean = false
178+
179+
private fun String.stripBom(): String =
180+
when {
181+
isEmpty() -> this
182+
this[0] == '\uFEFF' -> substring(1)
183+
else -> this
184+
}
185+
186+
private fun List<String>.toClipboardFormats(): Set<ClipboardFormat> =
187+
buildSet {
188+
for (t in this@toClipboardFormats) {
189+
when {
190+
t.equals("UTF8_STRING", ignoreCase = true) ||
191+
t.equals("STRING", ignoreCase = true) ||
192+
t.equals("TEXT", ignoreCase = true) ||
193+
t.equals("COMPOUND_TEXT", ignoreCase = true) ||
194+
t.startsWith("text/plain", ignoreCase = true) -> add(ClipboardFormat.Text)
195+
t.equals("text/html", ignoreCase = true) -> add(ClipboardFormat.Html)
196+
t.equals("text/rtf", ignoreCase = true) ||
197+
t.equals("application/rtf", ignoreCase = true) -> add(ClipboardFormat.Rtf)
198+
t.startsWith("image/", ignoreCase = true) -> add(ClipboardFormat.Image)
199+
t.equals("text/uri-list", ignoreCase = true) ||
200+
t.equals("x-special/gnome-copied-files", ignoreCase = true) ||
201+
t.equals("application/x-kde-cutselection", ignoreCase = true) -> add(ClipboardFormat.Files)
202+
}
203+
}
204+
}
205+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package io.github.kdroidfilter.nucleus.clipboard.linux
2+
3+
import io.github.kdroidfilter.nucleus.core.runtime.NativeLibraryLoader
4+
5+
private const val LIBRARY_NAME = "nucleus_clipboard_linux"
6+
7+
/**
8+
* JNI surface over XCB + XFixes for the X11 CLIPBOARD selection.
9+
*
10+
* The native layer opens its own XCB connection, runs a dedicated event thread,
11+
* and owns a single hidden 1×1 InputOnly window used as both owner and requestor.
12+
* All public entry points here are thread-safe; reads block the calling thread
13+
* for up to 2 s while the XCB event thread services the `SelectionNotify` round
14+
* trip.
15+
*/
16+
@Suppress("TooManyFunctions")
17+
internal object NativeX11ClipboardBridge {
18+
private val loaded = NativeLibraryLoader.load(LIBRARY_NAME, NativeX11ClipboardBridge::class.java)
19+
20+
val isLoaded: Boolean get() = loaded
21+
22+
/**
23+
* Probes for `libxcb` + `libxcb-xfixes` via `dlopen` and initializes the
24+
* hidden window + event thread. Returns `false` when either library is
25+
* missing or `$DISPLAY` is unset.
26+
*/
27+
@JvmStatic
28+
external fun nativeInit(): Boolean
29+
30+
/**
31+
* Monotonic counter bumped by the XFixes event handler whenever
32+
* `CLIPBOARD` changes ownership. Cheap — no X round trip.
33+
*/
34+
@JvmStatic
35+
external fun nativeChangeCount(): Long
36+
37+
/** Target atoms advertised by the current CLIPBOARD owner, as MIME-ish strings. */
38+
@JvmStatic
39+
external fun nativeAvailableTargets(): Array<String>
40+
41+
@JvmStatic
42+
external fun nativeReadText(): String?
43+
44+
@JvmStatic
45+
external fun nativeReadHtml(): String?
46+
47+
@JvmStatic
48+
external fun nativeReadRtf(): String?
49+
50+
@JvmStatic
51+
external fun nativeReadImagePng(): ByteArray?
52+
53+
/**
54+
* Returns a single-char prefix (`"c"` for copy or `"m"` for move/cut) followed
55+
* by absolute POSIX paths decoded from `x-special/gnome-copied-files` when
56+
* available, or `text/uri-list` otherwise. Returns an empty array if no file
57+
* list is present.
58+
*/
59+
@JvmStatic
60+
external fun nativeReadFilePaths(): Array<String>
61+
62+
/**
63+
* Atomic write — registers the set of offered targets in one shot and takes
64+
* ownership of `CLIPBOARD`. The native side retains the payload for the
65+
* lifetime of the selection.
66+
*/
67+
@JvmStatic
68+
external fun nativeWrite(
69+
text: String?,
70+
html: String?,
71+
rtf: String?,
72+
imagePng: ByteArray?,
73+
paths: Array<String>?,
74+
isCut: Boolean,
75+
): Boolean
76+
77+
/** Releases ownership of `CLIPBOARD` (best-effort SAVE_TARGETS handoff). */
78+
@JvmStatic
79+
external fun nativeClear(): Boolean
80+
}

0 commit comments

Comments
 (0)