Skip to content

Commit eccbad6

Browse files
committed
fix(clipboard): improve image read robustness on Wayland
- Add MIME type negotiation: try image/png then jpeg/webp/bmp/gif/tiff - Fix stream-drain race in runCaptureBytes: join reader thread fully after process exit (was capped at 200ms, could truncate large images) - Add fallback: read image files directly from disk when clipboard contains file URIs (Nautilus copy on .png files) - Bump image read timeout to 5 seconds (screenshots may take time) - Demo: display image thumbnails inline in the event log when images are copied or read - Add WaylandImageSmokeTest integration test
1 parent a33c442 commit eccbad6

3 files changed

Lines changed: 126 additions & 11 deletions

File tree

clipboard-linux/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/WaylandClipboardDelegate.kt

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ import java.util.concurrent.TimeUnit
1212
import java.util.concurrent.atomic.AtomicLong
1313

1414
private const val PROCESS_WRITE_TIMEOUT_SECONDS: Long = 3L
15-
private const val STREAM_DRAIN_MS: Long = 200L
15+
private const val IMAGE_READ_TIMEOUT_MS: Long = 5000L
16+
private const val DESTROY_DRAIN_MS: Long = 200L
17+
18+
private val IMAGE_MIME_PREFERENCE: List<String> =
19+
listOf("image/png", "image/jpeg", "image/jpg", "image/webp", "image/bmp", "image/gif", "image/tiff")
1620

1721
/**
1822
* Wayland backend implemented by delegating to `wl-copy` and `wl-paste` from the
@@ -108,7 +112,43 @@ internal class WaylandClipboardDelegate {
108112

109113
fun readImagePng(): ByteArray? {
110114
val paste = wlPaste ?: return null
111-
return runCaptureBytes(listOf(paste, "-n", "-t", "image/png"), timeoutMs = 3000)
115+
val available = listTypes().map { it.lowercase() }.toSet()
116+
// 1. Try the preferred image MIME types actually advertised on the clipboard.
117+
val preferred = IMAGE_MIME_PREFERENCE.firstOrNull { it in available }
118+
if (preferred != null) {
119+
runCaptureBytes(listOf(paste, "-n", "-t", preferred), timeoutMs = IMAGE_READ_TIMEOUT_MS)
120+
?.let { return it }
121+
}
122+
// 2. Any other image/* type the source publishes.
123+
val anyImage = available.firstOrNull { it.startsWith("image/") && it !in IMAGE_MIME_PREFERENCE }
124+
if (anyImage != null) {
125+
runCaptureBytes(listOf(paste, "-n", "-t", anyImage), timeoutMs = IMAGE_READ_TIMEOUT_MS)
126+
?.let { return it }
127+
}
128+
// 3. File-URI copy from a file manager — if the first URI points at an
129+
// image file, read it directly from disk.
130+
return readImageFromFileUri()
131+
}
132+
133+
private fun readImageFromFileUri(): ByteArray? {
134+
val files = readFiles()
135+
val first = files.firstOrNull() ?: return null
136+
val name = first.fileName?.toString()?.lowercase() ?: return null
137+
val isImage =
138+
name.endsWith(".png") ||
139+
name.endsWith(".jpg") ||
140+
name.endsWith(".jpeg") ||
141+
name.endsWith(".webp") ||
142+
name.endsWith(".bmp") ||
143+
name.endsWith(".gif") ||
144+
name.endsWith(".tiff")
145+
if (!isImage) return null
146+
return try {
147+
java.nio.file.Files
148+
.readAllBytes(first)
149+
} catch (_: IOException) {
150+
null
151+
}
112152
}
113153

114154
fun readFiles(): List<Path> {
@@ -226,10 +266,12 @@ internal class WaylandClipboardDelegate {
226266
}
227267
if (!p.waitFor(timeoutMs, TimeUnit.MILLISECONDS)) {
228268
p.destroy()
229-
t.join(STREAM_DRAIN_MS)
269+
t.join(DESTROY_DRAIN_MS)
230270
return null
231271
}
232-
t.join(STREAM_DRAIN_MS)
272+
// Process exited; the pipe is closed so the reader will finish promptly.
273+
// Join without a cap to avoid truncating large payloads (screenshots, etc.).
274+
t.join()
233275
if (p.exitValue() != 0) return null
234276
val out = buf.toByteArray()
235277
if (out.isEmpty()) null else out
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package io.github.kdroidfilter.nucleus.clipboard.linux
2+
3+
import kotlin.test.Test
4+
import kotlin.test.assertNotNull
5+
6+
/**
7+
* Reads whatever image/png is currently on the Wayland clipboard. Runs only when
8+
* WAYLAND_DISPLAY is set and wl-clipboard is on PATH.
9+
*/
10+
class WaylandImageSmokeTest {
11+
@Test
12+
fun readCurrentImage() {
13+
System.getenv("WAYLAND_DISPLAY") ?: return
14+
val delegate = WaylandClipboardDelegate()
15+
if (!delegate.isAvailable) return
16+
println("types=${delegate.listTypes()}")
17+
val bytes = delegate.readImagePng()
18+
println("image bytes=${bytes?.size}")
19+
assertNotNull(bytes, "expected image/png on clipboard")
20+
}
21+
}

example/src/main/kotlin/com/example/demo/ClipboardScreen.kt

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,32 @@ import org.jetbrains.skia.Image as SkiaImage
4949

5050
private const val EVENT_LOG_MAX = 40
5151

52+
private sealed class LogEntry {
53+
abstract val line: String
54+
55+
data class Text(override val line: String) : LogEntry()
56+
57+
data class Image(override val line: String, val bitmap: ImageBitmap) : LogEntry()
58+
}
59+
5260
@Suppress("FunctionNaming", "LongMethod", "CyclomaticComplexMethod")
5361
@Composable
5462
fun ClipboardScreen() {
5563
val scope = rememberCoroutineScope()
56-
val events = remember { mutableStateListOf<String>() }
64+
val events = remember { mutableStateListOf<LogEntry>() }
5765

5866
fun log(msg: String) {
5967
val ts = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))
60-
events.add(0, "[$ts] $msg")
68+
events.add(0, LogEntry.Text("[$ts] $msg"))
69+
while (events.size > EVENT_LOG_MAX) events.removeAt(events.lastIndex)
70+
}
71+
72+
fun logImage(
73+
msg: String,
74+
bitmap: ImageBitmap,
75+
) {
76+
val ts = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))
77+
events.add(0, LogEntry.Image("[$ts] $msg", bitmap))
6178
while (events.size > EVENT_LOG_MAX) events.removeAt(events.lastIndex)
6279
}
6380

@@ -80,6 +97,16 @@ fun ClipboardScreen() {
8097
currentFormats = event.formats
8198
currentChangeCount = event.changeCount
8299
log("change #${event.changeCount} formats=${event.formats}")
100+
if (ClipboardFormat.Image in event.formats) {
101+
val bytes = Clipboard.readImageBytes()
102+
val bitmap =
103+
bytes?.let {
104+
runCatching { SkiaImage.makeFromEncoded(it).toComposeImageBitmap() }.getOrNull()
105+
}
106+
if (bitmap != null) {
107+
logImage("image copied · ${bytes.size} bytes (png)", bitmap)
108+
}
109+
}
83110
}
84111
}
85112

@@ -172,7 +199,7 @@ fun ClipboardScreen() {
172199
onReadImage = {
173200
scope.launch {
174201
val bytes = Clipboard.readImageBytes()
175-
lastImage =
202+
val bitmap =
176203
bytes?.let {
177204
runCatching {
178205
SkiaImage
@@ -181,7 +208,12 @@ fun ClipboardScreen() {
181208
).toComposeImageBitmap()
182209
}.getOrNull()
183210
}
184-
log("read image: ${bytes?.size ?: 0} bytes (png)")
211+
lastImage = bitmap
212+
if (bitmap != null && bytes != null) {
213+
logImage("read image · ${bytes.size} bytes (png)", bitmap)
214+
} else {
215+
log("read image: ${bytes?.size ?: 0} bytes (png)")
216+
}
185217
}
186218
},
187219
onReadFiles = {
@@ -374,7 +406,7 @@ private fun ReadRow(
374406
}
375407

376408
@Composable
377-
private fun EventLogCard(events: List<String>) {
409+
private fun EventLogCard(events: List<LogEntry>) {
378410
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)) {
379411
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
380412
Text("Event log", style = MaterialTheme.typography.titleMedium)
@@ -387,8 +419,28 @@ private fun EventLogCard(events: List<String>) {
387419
} else {
388420
HorizontalDivider()
389421
Spacer(Modifier.height(4.dp))
390-
events.take(EVENT_LOG_MAX).forEach {
391-
Text(it, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace)
422+
events.take(EVENT_LOG_MAX).forEach { entry ->
423+
when (entry) {
424+
is LogEntry.Text ->
425+
Text(
426+
entry.line,
427+
style = MaterialTheme.typography.bodySmall,
428+
fontFamily = FontFamily.Monospace,
429+
)
430+
is LogEntry.Image ->
431+
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
432+
Text(
433+
entry.line,
434+
style = MaterialTheme.typography.bodySmall,
435+
fontFamily = FontFamily.Monospace,
436+
)
437+
Image(
438+
painter = BitmapPainter(entry.bitmap),
439+
contentDescription = "Clipboard image",
440+
modifier = Modifier.widthIn(max = 240.dp).heightIn(max = 160.dp),
441+
)
442+
}
443+
}
392444
}
393445
}
394446
}

0 commit comments

Comments
 (0)