From d219664fa6b1cdfc86378942f39fc7d0e3f494d2 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 19 Apr 2026 17:08:02 +0300 Subject: [PATCH 1/8] feat(clipboard): add rich cross-platform clipboard with macOS backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clipboard-common: Clipboard façade with suspend read/write for text, HTML, RTF, images (PNG bytes) and file lists; ClipboardWriteScope DSL for atomic multi-format writes; Flow watcher (metadata only, safe under macOS 15.4+ pasteboard privacy); ServiceLoader-based backend SPI with NoOpBackend fallback. - clipboard-macos: NSPasteboard JNI backend. Single NSPasteboardItem for multi-format writes, PNG + TIFF on images for web/AppKit compatibility, modern NSURL reads with legacy NSFilenamesPboardType fallback, HTML BOM stripping, NSPasteboard.accessBehavior guarded via respondsToSelector. - Example: new Clipboard tab with live watcher + multi-format read/write. - Docs: runtime/clipboard-common.md + runtime/clipboard-macos.md, mkdocs nav, llms.txt, roadmap, README updated. - UI: parent tabs grouped with per-group dropdown menus (custom Popup styled to match the tab chips, no Material DropdownMenu) to tame the flat tab row. --- README.md | 2 + clipboard-common/build.gradle.kts | 66 ++++ .../nucleus/clipboard/AccessBehavior.kt | 16 + .../nucleus/clipboard/Clipboard.kt | 149 ++++++++ .../nucleus/clipboard/ClipboardEvent.kt | 16 + .../nucleus/clipboard/ClipboardFormat.kt | 10 + .../nucleus/clipboard/ClipboardWriteScope.kt | 35 ++ .../clipboard/internal/BackendFactory.kt | 27 ++ .../clipboard/internal/ClipboardBackend.kt | 50 +++ .../internal/ClipboardWritePayload.kt | 46 +++ .../nucleus/clipboard/internal/NoOpBackend.kt | 31 ++ clipboard-macos/build.gradle.kts | 97 +++++ .../clipboard/macos/MacClipboardBackend.kt | 87 +++++ .../macos/NativeMacClipboardBridge.kt | 65 ++++ .../src/main/native/macos/build.sh | 65 ++++ .../src/main/native/macos/nucleus_clipboard.m | 325 ++++++++++++++++ .../reachability-metadata.json | 8 + ...ucleus.clipboard.internal.ClipboardBackend | 1 + docs/llms.txt | 8 +- docs/roadmap.md | 2 +- docs/runtime/clipboard-common.md | 186 ++++++++++ docs/runtime/clipboard-macos.md | 116 ++++++ docs/runtime/index.md | 6 +- example/build.gradle.kts | 2 + .../com/example/demo/ClipboardScreen.kt | 348 ++++++++++++++++++ .../main/kotlin/com/example/demo/GroupTabs.kt | 243 ++++++++++++ .../src/main/kotlin/com/example/demo/Main.kt | 62 ++-- mkdocs.yml | 3 + settings.gradle.kts | 2 + 29 files changed, 2048 insertions(+), 26 deletions(-) create mode 100644 clipboard-common/build.gradle.kts create mode 100644 clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/AccessBehavior.kt create mode 100644 clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/Clipboard.kt create mode 100644 clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/ClipboardEvent.kt create mode 100644 clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/ClipboardFormat.kt create mode 100644 clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/ClipboardWriteScope.kt create mode 100644 clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/internal/BackendFactory.kt create mode 100644 clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/internal/ClipboardBackend.kt create mode 100644 clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/internal/ClipboardWritePayload.kt create mode 100644 clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/internal/NoOpBackend.kt create mode 100644 clipboard-macos/build.gradle.kts create mode 100644 clipboard-macos/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/macos/MacClipboardBackend.kt create mode 100644 clipboard-macos/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/macos/NativeMacClipboardBridge.kt create mode 100755 clipboard-macos/src/main/native/macos/build.sh create mode 100644 clipboard-macos/src/main/native/macos/nucleus_clipboard.m create mode 100644 clipboard-macos/src/main/resources/META-INF/native-image/io.github.kdroidfilter/nucleus.clipboard-macos/reachability-metadata.json create mode 100644 clipboard-macos/src/main/resources/META-INF/services/io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardBackend create mode 100644 docs/runtime/clipboard-common.md create mode 100644 docs/runtime/clipboard-macos.md create mode 100644 example/src/main/kotlin/com/example/demo/ClipboardScreen.kt create mode 100644 example/src/main/kotlin/com/example/demo/GroupTabs.kt diff --git a/README.md b/README.md index a4502fa36..3456741c2 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,8 @@ Each module is published independently to Maven Central — use them together or | `nucleus.launcher-macos` | macOS Dock API — badge, menus | | `nucleus.launcher-windows` | Windows taskbar — badges, jump lists, overlay icons, thumbnail toolbar | | `nucleus.launcher-linux` | Unity Launcher — badge, progress, urgency, quicklist | +| `nucleus.clipboard-common` | Cross-platform clipboard — text, HTML, RTF, images, files + change watcher | +| `nucleus.clipboard-macos` | macOS `NSPasteboard` backend for the clipboard API | | `nucleus.media-control` | OS media controls — MPRIS (Linux), Now Playing (macOS), SMTC (Windows) | | `nucleus.menu-macos` | Native macOS menu bar | | `nucleus.freedesktop-icons` | Type-safe freedesktop icon naming constants | diff --git a/clipboard-common/build.gradle.kts b/clipboard-common/build.gradle.kts new file mode 100644 index 000000000..e2d3f16fc --- /dev/null +++ b/clipboard-common/build.gradle.kts @@ -0,0 +1,66 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + kotlin("jvm") + alias(libs.plugins.vanniktechMavenPublish) +} + +val publishVersion = + providers + .environmentVariable("GITHUB_REF") + .orNull + ?.removePrefix("refs/tags/v") + ?: "1.0.0" + +dependencies { + implementation(project(":core-runtime")) + implementation(libs.coroutines.core) + testImplementation(kotlin("test")) +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } +} + +mavenPublishing { + coordinates("io.github.kdroidfilter", "nucleus.clipboard-common", publishVersion) + + pom { + name.set("Nucleus Clipboard Common") + description.set("Cross-platform rich clipboard API (text, HTML, RTF, images, files) with change watcher") + url.set("https://github.com/kdroidFilter/Nucleus") + + licenses { + license { + name.set("MIT License") + url.set("https://opensource.org/licenses/MIT") + } + } + + developers { + developer { + id.set("kdroidfilter") + name.set("kdroidFilter") + url.set("https://github.com/kdroidFilter") + } + } + + scm { + url.set("https://github.com/kdroidFilter/Nucleus") + connection.set("scm:git:git://github.com/kdroidFilter/Nucleus.git") + developerConnection.set("scm:git:ssh://git@github.com/kdroidFilter/Nucleus.git") + } + } + + publishToMavenCentral() + if (project.hasProperty("signingInMemoryKey")) { + signAllPublications() + } +} diff --git a/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/AccessBehavior.kt b/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/AccessBehavior.kt new file mode 100644 index 000000000..1ae08c589 --- /dev/null +++ b/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/AccessBehavior.kt @@ -0,0 +1,16 @@ +package io.github.kdroidfilter.nucleus.clipboard + +/** + * Policy for background pasteboard access — maps to macOS 15.4+ `NSPasteboard.AccessBehavior`. + * No-op on platforms without a privacy model. + */ +enum class AccessBehavior { + /** Read freely (legacy macOS default, still the default on other platforms). */ + AlwaysAllow, + + /** Show a permission prompt on every read of pasteboard contents. */ + AskEveryTime, + + /** Deny programmatic reads without user interaction. */ + AlwaysDeny, +} diff --git a/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/Clipboard.kt b/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/Clipboard.kt new file mode 100644 index 000000000..b5eba3b97 --- /dev/null +++ b/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/Clipboard.kt @@ -0,0 +1,149 @@ +package io.github.kdroidfilter.nucleus.clipboard + +import io.github.kdroidfilter.nucleus.clipboard.internal.BackendFactory +import io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardBackend +import io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardWritePayload +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.nio.file.Path +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +private val DEFAULT_POLL_INTERVAL: Duration = 250.milliseconds + +/** + * Rich cross-platform clipboard façade. + * + * Reads and writes text, HTML, RTF, images, and file lists. Emits change events + * via [watch] as a cold [Flow]. Delegates to the first available platform backend + * discovered through [java.util.ServiceLoader] — on macOS that's + * `nucleus.clipboard-macos`; when no backend is loaded, all methods degrade to + * no-ops returning `null` / `false`. + * + * All suspending methods hop to the backend's native thread internally; callers + * may invoke them from any coroutine context. + */ +@Suppress("TooManyFunctions") +object Clipboard { + private val backend: ClipboardBackend by lazy { BackendFactory.discover() } + + /** True when a platform backend is loaded and operational. */ + val isAvailable: Boolean get() = backend.isAvailable() + + /** Backend name for diagnostics (e.g. `"macOS NSPasteboard"` or `"no-op"`). */ + val backendName: String get() = backend.name + + // --- Read --- + + suspend fun readText(): String? = backend.readText() + + suspend fun readHtml(): String? = backend.readHtml() + + suspend fun readRtf(): String? = backend.readRtf() + + /** Returns PNG-encoded bytes. Use your preferred decoder (`ImageIO.read` for `BufferedImage`). */ + suspend fun readImageBytes(): ByteArray? = backend.readImagePng() + + suspend fun readFiles(): List = backend.readFiles() + + /** + * Returns the set of formats currently advertised by the clipboard without + * reading any content bytes. Safe to call under restrictive pasteboard-privacy + * policies (macOS 15.4+). + */ + suspend fun availableFormats(): Set = backend.availableFormats() + + // --- Write --- + + suspend fun writeText(text: String): Boolean = + backend.write(ClipboardWritePayload(text = text, html = null, rtf = null, imagePng = null, files = null)) + + suspend fun writeHtml( + html: String, + plainTextFallback: String? = null, + ): Boolean = + backend.write( + ClipboardWritePayload( + text = plainTextFallback, + html = html, + rtf = null, + imagePng = null, + files = null, + ), + ) + + suspend fun writeRtf( + rtf: String, + plainTextFallback: String? = null, + ): Boolean = + backend.write( + ClipboardWritePayload( + text = plainTextFallback, + html = null, + rtf = rtf, + imagePng = null, + files = null, + ), + ) + + suspend fun writeImage(png: ByteArray): Boolean = + backend.write(ClipboardWritePayload(text = null, html = null, rtf = null, imagePng = png, files = null)) + + suspend fun writeFiles(paths: List): Boolean = + backend.write(ClipboardWritePayload(text = null, html = null, rtf = null, imagePng = null, files = paths)) + + /** + * Atomic multi-format write. Richer representations (HTML, RTF, image) coexist + * with a plain-text fallback on the same clipboard item. + * + * ``` + * Clipboard.write { + * html = "Hello" + * text = "Hello" + * } + * ``` + */ + suspend fun write(block: ClipboardWriteScope.() -> Unit): Boolean { + val scope = ClipboardWriteScope().apply(block) + val payload = scope.toPayload() + if (payload.isEmpty) return false + return backend.write(payload) + } + + suspend fun clear(): Boolean = backend.clear() + + /** Sets the platform privacy policy for background reads. No-op outside macOS 15.4+. */ + fun setAccessBehavior(behavior: AccessBehavior) = backend.setAccessBehavior(behavior) + + /** + * Cold [Flow] that emits whenever the clipboard contents change. + * + * The event carries only format metadata and a monotonic `changeCount`, not + * payload bytes — call a `readXxx` method to fetch content. Polls the backend's + * change counter (cheap Mach IPC on macOS, WM_CLIPBOARDUPDATE on Windows, + * XFixes on X11, data-control events on Wayland). + * + * The baseline is captured on `collect`, so the flow does not emit the current + * clipboard state — only subsequent changes. + */ + fun watch(pollInterval: Duration = DEFAULT_POLL_INTERVAL): Flow = + callbackFlow { + var last = backend.changeCount() + val job = + launch { + while (isActive) { + delay(pollInterval) + val cur = backend.changeCount() + if (cur != last) { + last = cur + trySend(ClipboardEvent(formats = availableFormats(), changeCount = cur)) + } + } + } + awaitClose { job.cancel() } + } +} diff --git a/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/ClipboardEvent.kt b/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/ClipboardEvent.kt new file mode 100644 index 000000000..121a0ce4d --- /dev/null +++ b/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/ClipboardEvent.kt @@ -0,0 +1,16 @@ +package io.github.kdroidfilter.nucleus.clipboard + +/** + * Change notification emitted by [Clipboard.watch]. + * + * Carries only metadata — no payload bytes are read from the system pasteboard, + * which keeps the watcher outside the scope of macOS 15.4+ pasteboard-privacy + * prompts. Call [Clipboard.readText] / [Clipboard.readImageBytes] / ... to fetch + * the actual content on demand. + */ +data class ClipboardEvent( + /** Formats currently advertised on the clipboard. */ + val formats: Set, + /** Backend-provided monotonic counter. Useful to deduplicate self-writes. */ + val changeCount: Long, +) diff --git a/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/ClipboardFormat.kt b/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/ClipboardFormat.kt new file mode 100644 index 000000000..e916f9a25 --- /dev/null +++ b/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/ClipboardFormat.kt @@ -0,0 +1,10 @@ +package io.github.kdroidfilter.nucleus.clipboard + +/** Content categories advertised on the clipboard. */ +enum class ClipboardFormat { + Text, + Html, + Rtf, + Image, + Files, +} diff --git a/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/ClipboardWriteScope.kt b/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/ClipboardWriteScope.kt new file mode 100644 index 000000000..8ef00f268 --- /dev/null +++ b/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/ClipboardWriteScope.kt @@ -0,0 +1,35 @@ +package io.github.kdroidfilter.nucleus.clipboard + +import io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardWritePayload +import java.nio.file.Path + +/** + * Builder for a multi-format clipboard write. Any non-null property is published + * atomically on the same pasteboard item, so consumers can pick the richest + * representation they understand. + */ +class ClipboardWriteScope internal constructor() { + /** UTF-8 plain text. Written as `public.utf8-plain-text` on macOS. */ + var text: String? = null + + /** UTF-8 HTML fragment. Written as `public.html` on macOS (no CF_HTML wrapper). */ + var html: String? = null + + /** UTF-8 RTF payload. Written as `public.rtf` on macOS. */ + var rtf: String? = null + + /** PNG-encoded image bytes. Published alongside a TIFF representation on macOS. */ + var imagePng: ByteArray? = null + + /** Absolute file paths. Written as `public.file-url` NSURLs on macOS. */ + var files: List? = null + + internal fun toPayload(): ClipboardWritePayload = + ClipboardWritePayload( + text = text, + html = html, + rtf = rtf, + imagePng = imagePng, + files = files, + ) +} diff --git a/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/internal/BackendFactory.kt b/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/internal/BackendFactory.kt new file mode 100644 index 000000000..4c2c87628 --- /dev/null +++ b/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/internal/BackendFactory.kt @@ -0,0 +1,27 @@ +package io.github.kdroidfilter.nucleus.clipboard.internal + +import java.util.ServiceLoader +import java.util.logging.Level +import java.util.logging.Logger + +internal object BackendFactory { + private val logger = Logger.getLogger(BackendFactory::class.java.simpleName) + + fun discover(): ClipboardBackend { + val loader = ServiceLoader.load(ClipboardBackend::class.java, ClipboardBackend::class.java.classLoader) + for (candidate in loader) { + try { + if (candidate.isAvailable()) { + logger.fine("Clipboard backend selected: ${candidate.name}") + return candidate + } + } catch ( + @Suppress("TooGenericExceptionCaught") e: RuntimeException, + ) { + logger.log(Level.WARNING, "Clipboard backend ${candidate.name} failed probe", e) + } + } + logger.fine("No clipboard backend available — falling back to no-op") + return NoOpBackend + } +} diff --git a/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/internal/ClipboardBackend.kt b/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/internal/ClipboardBackend.kt new file mode 100644 index 000000000..8325d1c45 --- /dev/null +++ b/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/internal/ClipboardBackend.kt @@ -0,0 +1,50 @@ +package io.github.kdroidfilter.nucleus.clipboard.internal + +import io.github.kdroidfilter.nucleus.clipboard.AccessBehavior +import io.github.kdroidfilter.nucleus.clipboard.ClipboardFormat +import java.nio.file.Path + +/** + * SPI implemented by each platform clipboard backend (macOS, Windows, Linux X11, Wayland). + * Discovered via [java.util.ServiceLoader]. + * + * All methods are side-effect-free on non-matching platforms (the backend's + * [isAvailable] returns `false`). The facade in [io.github.kdroidfilter.nucleus.clipboard.Clipboard] + * picks the first available backend and delegates. + */ +interface ClipboardBackend { + /** Human-readable backend name, for logging. */ + val name: String + + /** True when the backend can service requests on this OS and process. */ + fun isAvailable(): Boolean + + suspend fun readText(): String? + + suspend fun readHtml(): String? + + suspend fun readRtf(): String? + + /** PNG-encoded bytes, or null if no image is available. */ + suspend fun readImagePng(): ByteArray? + + suspend fun readFiles(): List + + /** Formats currently advertised without reading bytes — safe under macOS 15.4+ privacy. */ + suspend fun availableFormats(): Set + + /** Writes the payload atomically. Returns true on success. */ + suspend fun write(payload: ClipboardWritePayload): Boolean + + /** Clears the clipboard. */ + suspend fun clear(): Boolean + + /** + * Monotonic change counter, used by the watcher. Must not open/read the clipboard — + * on macOS this is `NSPasteboard.changeCount`, a cheap Mach IPC call. + */ + fun changeCount(): Long + + /** Sets the platform privacy policy (macOS 15.4+). No-op elsewhere. */ + fun setAccessBehavior(behavior: AccessBehavior) +} diff --git a/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/internal/ClipboardWritePayload.kt b/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/internal/ClipboardWritePayload.kt new file mode 100644 index 000000000..973818a1f --- /dev/null +++ b/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/internal/ClipboardWritePayload.kt @@ -0,0 +1,46 @@ +package io.github.kdroidfilter.nucleus.clipboard.internal + +import java.nio.file.Path + +/** + * Frozen snapshot passed from [io.github.kdroidfilter.nucleus.clipboard.Clipboard] + * to a [ClipboardBackend]. Part of the backend SPI; not intended for direct use + * by application code. + */ +data class ClipboardWritePayload( + val text: String?, + val html: String?, + val rtf: String?, + val imagePng: ByteArray?, + val files: List?, +) { + val isEmpty: Boolean + get() = text == null && html == null && rtf == null && imagePng == null && files.isNullOrEmpty() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ClipboardWritePayload) return false + if (text != other.text) return false + if (html != other.html) return false + if (rtf != other.rtf) return false + if (!imagePng.contentEqualsOrBothNull(other.imagePng)) return false + if (files != other.files) return false + return true + } + + override fun hashCode(): Int { + var result = text?.hashCode() ?: 0 + result = 31 * result + (html?.hashCode() ?: 0) + result = 31 * result + (rtf?.hashCode() ?: 0) + result = 31 * result + (imagePng?.contentHashCode() ?: 0) + result = 31 * result + (files?.hashCode() ?: 0) + return result + } + + private fun ByteArray?.contentEqualsOrBothNull(other: ByteArray?): Boolean = + when { + this == null && other == null -> true + this == null || other == null -> false + else -> this.contentEquals(other) + } +} diff --git a/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/internal/NoOpBackend.kt b/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/internal/NoOpBackend.kt new file mode 100644 index 000000000..181359f71 --- /dev/null +++ b/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/internal/NoOpBackend.kt @@ -0,0 +1,31 @@ +package io.github.kdroidfilter.nucleus.clipboard.internal + +import io.github.kdroidfilter.nucleus.clipboard.AccessBehavior +import io.github.kdroidfilter.nucleus.clipboard.ClipboardFormat +import java.nio.file.Path + +internal object NoOpBackend : ClipboardBackend { + override val name: String = "no-op" + + override fun isAvailable(): Boolean = false + + override suspend fun readText(): String? = null + + override suspend fun readHtml(): String? = null + + override suspend fun readRtf(): String? = null + + override suspend fun readImagePng(): ByteArray? = null + + override suspend fun readFiles(): List = emptyList() + + override suspend fun availableFormats(): Set = emptySet() + + override suspend fun write(payload: ClipboardWritePayload): Boolean = false + + override suspend fun clear(): Boolean = false + + override fun changeCount(): Long = 0L + + override fun setAccessBehavior(behavior: AccessBehavior) = Unit +} diff --git a/clipboard-macos/build.gradle.kts b/clipboard-macos/build.gradle.kts new file mode 100644 index 000000000..bcbb19272 --- /dev/null +++ b/clipboard-macos/build.gradle.kts @@ -0,0 +1,97 @@ +import org.apache.tools.ant.taskdefs.condition.Os +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + kotlin("jvm") + alias(libs.plugins.vanniktechMavenPublish) +} + +val publishVersion = + providers + .environmentVariable("GITHUB_REF") + .orNull + ?.removePrefix("refs/tags/v") + ?: "1.0.0" + +dependencies { + implementation(project(":core-runtime")) + implementation(project(":clipboard-common")) + implementation(libs.coroutines.core) +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } +} + +val nativeResourceDir = layout.projectDirectory.dir("src/main/resources/nucleus/native") + +val buildNativeMacOs by tasks.registering(Exec::class) { + description = "Compiles the Objective-C JNI bridge into macOS dylibs (arm64 + x64)" + group = "build" + val hasPrebuilt = + nativeResourceDir + .dir("darwin-aarch64") + .file("libnucleus_clipboard.dylib") + .asFile + .exists() + enabled = Os.isFamily(Os.FAMILY_MAC) && !hasPrebuilt + + val nativeDir = layout.projectDirectory.dir("src/main/native/macos") + inputs.dir(nativeDir) + outputs.dir(nativeResourceDir) + workingDir(nativeDir) + commandLine("bash", "build.sh") +} + +tasks.processResources { + dependsOn(buildNativeMacOs) +} + +tasks.configureEach { + if (name == "sourcesJar") { + dependsOn(buildNativeMacOs) + } +} + +mavenPublishing { + coordinates("io.github.kdroidfilter", "nucleus.clipboard-macos", publishVersion) + + pom { + name.set("Nucleus Clipboard macOS") + description.set("macOS NSPasteboard clipboard backend for Nucleus via JNI") + url.set("https://github.com/kdroidFilter/Nucleus") + + licenses { + license { + name.set("MIT License") + url.set("https://opensource.org/licenses/MIT") + } + } + + developers { + developer { + id.set("kdroidfilter") + name.set("kdroidFilter") + url.set("https://github.com/kdroidFilter") + } + } + + scm { + url.set("https://github.com/kdroidFilter/Nucleus") + connection.set("scm:git:git://github.com/kdroidFilter/Nucleus.git") + developerConnection.set("scm:git:ssh://git@github.com/kdroidFilter/Nucleus.git") + } + } + + publishToMavenCentral() + if (project.hasProperty("signingInMemoryKey")) { + signAllPublications() + } +} diff --git a/clipboard-macos/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/macos/MacClipboardBackend.kt b/clipboard-macos/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/macos/MacClipboardBackend.kt new file mode 100644 index 000000000..1004f5727 --- /dev/null +++ b/clipboard-macos/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/macos/MacClipboardBackend.kt @@ -0,0 +1,87 @@ +package io.github.kdroidfilter.nucleus.clipboard.macos + +import io.github.kdroidfilter.nucleus.clipboard.AccessBehavior +import io.github.kdroidfilter.nucleus.clipboard.ClipboardFormat +import io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardBackend +import io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardWritePayload +import io.github.kdroidfilter.nucleus.core.runtime.Platform +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.nio.file.Path +import java.nio.file.Paths + +/** + * macOS [ClipboardBackend] backed by `NSPasteboard.generalPasteboard` via JNI. + * + * Registered through `META-INF/services`, so adding `nucleus.clipboard-macos` + * to the runtime classpath automatically wires up the façade. + * + * All IO runs on [Dispatchers.IO]; NSPasteboard is thread-safe so no main-thread + * hop is required for V1 (delayed rendering, which needs the main thread, is + * deferred to V2). + */ +class MacClipboardBackend : ClipboardBackend { + override val name: String = "macOS NSPasteboard" + + override fun isAvailable(): Boolean = Platform.Current == Platform.MacOS && NativeMacClipboardBridge.isLoaded + + override suspend fun readText(): String? = withContext(Dispatchers.IO) { NativeMacClipboardBridge.nativeReadText() } + + override suspend fun readHtml(): String? = + withContext(Dispatchers.IO) { NativeMacClipboardBridge.nativeReadHtml()?.stripBom() } + + override suspend fun readRtf(): String? = withContext(Dispatchers.IO) { NativeMacClipboardBridge.nativeReadRtf() } + + override suspend fun readImagePng(): ByteArray? = + withContext(Dispatchers.IO) { NativeMacClipboardBridge.nativeReadImagePng() } + + override suspend fun readFiles(): List = + withContext(Dispatchers.IO) { + NativeMacClipboardBridge.nativeReadFilePaths().map(Paths::get) + } + + override suspend fun availableFormats(): Set = + withContext(Dispatchers.IO) { + val types = NativeMacClipboardBridge.nativeAvailableTypes() + buildSet { + for (uti in types) { + when (uti) { + "public.utf8-plain-text", "public.plain-text", "public.text", "NSStringPboardType" -> + add(ClipboardFormat.Text) + "public.html" -> add(ClipboardFormat.Html) + "public.rtf" -> add(ClipboardFormat.Rtf) + "public.png", "public.tiff", "public.jpeg" -> add(ClipboardFormat.Image) + "public.file-url", "NSFilenamesPboardType" -> add(ClipboardFormat.Files) + } + } + } + } + + override suspend fun write(payload: ClipboardWritePayload): Boolean = + withContext(Dispatchers.IO) { + val paths = payload.files?.map { it.toAbsolutePath().toString() }?.toTypedArray() + NativeMacClipboardBridge.nativeWrite( + payload.text, + payload.html, + payload.rtf, + payload.imagePng, + paths, + ) + } + + override suspend fun clear(): Boolean = withContext(Dispatchers.IO) { NativeMacClipboardBridge.nativeClear() } + + override fun changeCount(): Long = + if (NativeMacClipboardBridge.isLoaded) NativeMacClipboardBridge.nativeChangeCount() else 0L + + override fun setAccessBehavior(behavior: AccessBehavior) { + if (!NativeMacClipboardBridge.isLoaded) return + NativeMacClipboardBridge.nativeSetAccessBehavior(behavior.ordinal) + } + + /** + * Strips a leading UTF-8 BOM (`\uFEFF`) that Firefox and some web apps emit on + * `public.html` payloads. Keeps the rest untouched. + */ + private fun String.stripBom(): String = if (isNotEmpty() && this[0] == '\uFEFF') substring(1) else this +} diff --git a/clipboard-macos/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/macos/NativeMacClipboardBridge.kt b/clipboard-macos/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/macos/NativeMacClipboardBridge.kt new file mode 100644 index 000000000..21862a2b5 --- /dev/null +++ b/clipboard-macos/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/macos/NativeMacClipboardBridge.kt @@ -0,0 +1,65 @@ +package io.github.kdroidfilter.nucleus.clipboard.macos + +import io.github.kdroidfilter.nucleus.core.runtime.NativeLibraryLoader + +private const val LIBRARY_NAME = "nucleus_clipboard" + +/** + * Low-level JNI surface over `NSPasteboard`. All methods operate on the general + * pasteboard and are safe to call from any thread (NSPasteboard is thread-safe, + * and the ObjC side wraps each entry in an autorelease pool). + */ +internal object NativeMacClipboardBridge { + private val loaded = NativeLibraryLoader.load(LIBRARY_NAME, NativeMacClipboardBridge::class.java) + + val isLoaded: Boolean get() = loaded + + /** Returns `NSPasteboard.generalPasteboard.changeCount`. Cheap Mach IPC. */ + @JvmStatic + external fun nativeChangeCount(): Long + + /** Returns the UTI list advertised by the general pasteboard without reading bytes. */ + @JvmStatic + external fun nativeAvailableTypes(): Array + + @JvmStatic + external fun nativeReadText(): String? + + @JvmStatic + external fun nativeReadHtml(): String? + + @JvmStatic + external fun nativeReadRtf(): String? + + /** Returns PNG-encoded bytes. If the clipboard carries only TIFF, it is transcoded natively. */ + @JvmStatic + external fun nativeReadImagePng(): ByteArray? + + /** Returns an array of absolute POSIX paths read from `public.file-url` items. */ + @JvmStatic + external fun nativeReadFilePaths(): Array + + /** + * Atomic write — clears the pasteboard then publishes a single `NSPasteboardItem` + * carrying every non-null representation. Pass `null` for formats you do not + * want to publish. `paths` must be absolute POSIX paths. + */ + @JvmStatic + external fun nativeWrite( + text: String?, + html: String?, + rtf: String?, + imagePng: ByteArray?, + paths: Array?, + ): Boolean + + @JvmStatic + external fun nativeClear(): Boolean + + /** + * Sets `NSPasteboard.accessBehavior`. Values match [AccessBehavior] ordinal: + * 0 = always allow, 1 = ask every time, 2 = always deny. No-op on macOS < 15.4. + */ + @JvmStatic + external fun nativeSetAccessBehavior(value: Int) +} diff --git a/clipboard-macos/src/main/native/macos/build.sh b/clipboard-macos/src/main/native/macos/build.sh new file mode 100755 index 000000000..da2738832 --- /dev/null +++ b/clipboard-macos/src/main/native/macos/build.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# Compiles nucleus_clipboard.m into per-architecture dylibs (arm64 + x86_64). +# Requires macOS 10.13+ (AppKit NSPasteboardTypePNG). +# +# Prerequisites: Xcode command-line tools (clang). +# Usage: ./build.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SRC="$SCRIPT_DIR/nucleus_clipboard.m" +RESOURCE_DIR="$SCRIPT_DIR/../../resources/nucleus/native" +OUT_DIR_ARM64="$RESOURCE_DIR/darwin-aarch64" +OUT_DIR_X64="$RESOURCE_DIR/darwin-x64" + +if [ -z "${JAVA_HOME:-}" ]; then + JAVA_HOME=$(/usr/libexec/java_home 2>/dev/null || true) +fi +if [ -z "${JAVA_HOME:-}" ]; then + echo "ERROR: JAVA_HOME not set and /usr/libexec/java_home failed." >&2 + exit 1 +fi + +JNI_INCLUDE="$JAVA_HOME/include" +JNI_INCLUDE_DARWIN="$JAVA_HOME/include/darwin" + +if [ ! -d "$JNI_INCLUDE" ]; then + echo "ERROR: JNI headers not found at $JNI_INCLUDE" >&2 + exit 1 +fi + +mkdir -p "$OUT_DIR_ARM64" "$OUT_DIR_X64" + +COMMON_FLAGS=( + -dynamiclib + -I"$JNI_INCLUDE" -I"$JNI_INCLUDE_DARWIN" + -framework AppKit + -mmacosx-version-min=10.13 + -fobjc-arc + -Oz -flto + -fvisibility=hidden + -Wl,-dead_strip -Wl,-x +) + +LIB_NAME="libnucleus_clipboard.dylib" + +clang -arch arm64 "${COMMON_FLAGS[@]}" -o "$OUT_DIR_ARM64/$LIB_NAME" "$SRC" +strip -x "$OUT_DIR_ARM64/$LIB_NAME" + +clang -arch x86_64 "${COMMON_FLAGS[@]}" -o "$OUT_DIR_X64/$LIB_NAME" "$SRC" +strip -x "$OUT_DIR_X64/$LIB_NAME" + +echo "Built per-architecture dylibs:" +ls -lh "$OUT_DIR_ARM64/$LIB_NAME" +ls -lh "$OUT_DIR_X64/$LIB_NAME" + +# Purge NativeLibraryLoader cache so the JVM picks up the fresh dylib. +CACHE_BASE="$HOME/Library/Caches/nucleus/native" +for arch in darwin-aarch64 darwin-x64; do + CACHED="$CACHE_BASE/$arch/$LIB_NAME" + if [ -f "$CACHED" ]; then + rm -f "$CACHED" + echo "Cleared cache: $CACHED" + fi +done diff --git a/clipboard-macos/src/main/native/macos/nucleus_clipboard.m b/clipboard-macos/src/main/native/macos/nucleus_clipboard.m new file mode 100644 index 000000000..5b0350f8c --- /dev/null +++ b/clipboard-macos/src/main/native/macos/nucleus_clipboard.m @@ -0,0 +1,325 @@ +/** + * JNI bridge for macOS NSPasteboard. + * + * Exposes the general pasteboard as a flat read/write/watch API. Writes are + * atomic multi-representation — a single NSPasteboardItem carries every + * non-null format supplied from Kotlin, so consumers can pick the richest + * representation they understand. + * + * Frameworks: AppKit (NSPasteboard, NSImage, NSBitmapImageRep). + */ + +#import +#include + +// ============================================================================ +// JNI helpers +// ============================================================================ + +static JavaVM *g_jvm = NULL; + +JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) { + (void)reserved; + g_jvm = vm; + return JNI_VERSION_1_8; +} + +static NSString *toNSString(JNIEnv *env, jstring jstr) { + if (jstr == NULL) return nil; + const char *utf = (*env)->GetStringUTFChars(env, jstr, NULL); + if (utf == NULL) return nil; + NSString *str = [NSString stringWithUTF8String:utf]; + (*env)->ReleaseStringUTFChars(env, jstr, utf); + return str; +} + +static jstring toJStringOrNull(JNIEnv *env, NSString *str) { + if (str == nil) return NULL; + const char *utf = [str UTF8String]; + return (*env)->NewStringUTF(env, utf ? utf : ""); +} + +static jbyteArray toJByteArray(JNIEnv *env, NSData *data) { + if (data == nil) return NULL; + NSUInteger len = [data length]; + jbyteArray arr = (*env)->NewByteArray(env, (jsize)len); + if (arr == NULL) return NULL; + (*env)->SetByteArrayRegion(env, arr, 0, (jsize)len, (const jbyte *)[data bytes]); + return arr; +} + +static jobjectArray toJStringArray(JNIEnv *env, NSArray *items) { + jclass stringClass = (*env)->FindClass(env, "java/lang/String"); + if (stringClass == NULL) return NULL; + jobjectArray arr = (*env)->NewObjectArray(env, (jsize)items.count, stringClass, NULL); + if (arr == NULL) return NULL; + for (NSUInteger i = 0; i < items.count; i++) { + jstring js = toJStringOrNull(env, items[i]); + (*env)->SetObjectArrayElement(env, arr, (jsize)i, js); + if (js != NULL) (*env)->DeleteLocalRef(env, js); + } + return arr; +} + +// ============================================================================ +// Read helpers +// ============================================================================ + +// Resolves the first available UTI among `candidates` on the given pasteboard. +static NSString *firstAvailableType(NSPasteboard *pb, NSArray *candidates) { + NSString *match = [pb availableTypeFromArray:candidates]; + return match; +} + +static NSData *readPngFromPasteboard(NSPasteboard *pb) { + // Prefer a native PNG representation when present. + NSData *png = [pb dataForType:@"public.png"]; + if (png != nil) return png; + + // Fallback: transcode TIFF → PNG via NSBitmapImageRep. + NSData *tiff = [pb dataForType:@"public.tiff"]; + if (tiff == nil) { + tiff = [pb dataForType:NSPasteboardTypeTIFF]; + } + if (tiff == nil) return nil; + + NSBitmapImageRep *rep = [NSBitmapImageRep imageRepWithData:tiff]; + if (rep == nil) return nil; + return [rep representationUsingType:NSBitmapImageFileTypePNG properties:@{}]; +} + +static NSArray *readFilePathsFromPasteboard(NSPasteboard *pb) { + NSMutableArray *out = [NSMutableArray array]; + + // Modern path: NSURL objects with the file-URL filter. + NSArray *classes = @[[NSURL class]]; + NSDictionary *options = @{NSPasteboardURLReadingFileURLsOnlyKey: @YES}; + NSArray *urls = [pb readObjectsForClasses:classes options:options]; + if (urls != nil) { + for (NSURL *u in urls) { + if (u.isFileURL && u.path != nil) { + [out addObject:u.path]; + } + } + } + + // Legacy fallback for apps that still write NSFilenamesPboardType. + if (out.count == 0) { + id legacy = [pb propertyListForType:@"NSFilenamesPboardType"]; + if ([legacy isKindOfClass:[NSArray class]]) { + for (id item in (NSArray *)legacy) { + if ([item isKindOfClass:[NSString class]]) { + [out addObject:(NSString *)item]; + } + } + } + } + + return out; +} + +// ============================================================================ +// JNI: change count + available types +// ============================================================================ + +JNIEXPORT jlong JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_macos_NativeMacClipboardBridge_nativeChangeCount( + JNIEnv *env, jclass cls) { + (void)env; (void)cls; + @autoreleasepool { + return (jlong)[[NSPasteboard generalPasteboard] changeCount]; + } +} + +JNIEXPORT jobjectArray JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_macos_NativeMacClipboardBridge_nativeAvailableTypes( + JNIEnv *env, jclass cls) { + (void)cls; + @autoreleasepool { + NSArray *types = [[NSPasteboard generalPasteboard] types]; + if (types == nil) types = @[]; + return toJStringArray(env, types); + } +} + +// ============================================================================ +// JNI: reads +// ============================================================================ + +JNIEXPORT jstring JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_macos_NativeMacClipboardBridge_nativeReadText( + JNIEnv *env, jclass cls) { + (void)cls; + @autoreleasepool { + NSString *s = [[NSPasteboard generalPasteboard] + stringForType:@"public.utf8-plain-text"]; + if (s == nil) { + s = [[NSPasteboard generalPasteboard] stringForType:NSPasteboardTypeString]; + } + return toJStringOrNull(env, s); + } +} + +JNIEXPORT jstring JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_macos_NativeMacClipboardBridge_nativeReadHtml( + JNIEnv *env, jclass cls) { + (void)cls; + @autoreleasepool { + NSString *s = [[NSPasteboard generalPasteboard] stringForType:@"public.html"]; + return toJStringOrNull(env, s); + } +} + +JNIEXPORT jstring JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_macos_NativeMacClipboardBridge_nativeReadRtf( + JNIEnv *env, jclass cls) { + (void)cls; + @autoreleasepool { + NSData *data = [[NSPasteboard generalPasteboard] dataForType:@"public.rtf"]; + if (data == nil) return NULL; + NSString *s = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + if (s == nil) { + s = [[NSString alloc] initWithData:data encoding:NSASCIIStringEncoding]; + } + return toJStringOrNull(env, s); + } +} + +JNIEXPORT jbyteArray JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_macos_NativeMacClipboardBridge_nativeReadImagePng( + JNIEnv *env, jclass cls) { + (void)cls; + @autoreleasepool { + NSData *png = readPngFromPasteboard([NSPasteboard generalPasteboard]); + return toJByteArray(env, png); + } +} + +JNIEXPORT jobjectArray JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_macos_NativeMacClipboardBridge_nativeReadFilePaths( + JNIEnv *env, jclass cls) { + (void)cls; + @autoreleasepool { + NSArray *paths = readFilePathsFromPasteboard([NSPasteboard generalPasteboard]); + return toJStringArray(env, paths); + } +} + +// ============================================================================ +// JNI: writes +// ============================================================================ + +JNIEXPORT jboolean JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_macos_NativeMacClipboardBridge_nativeWrite( + JNIEnv *env, jclass cls, + jstring jText, jstring jHtml, jstring jRtf, jbyteArray jPng, jobjectArray jPaths) { + (void)cls; + @autoreleasepool { + NSPasteboard *pb = [NSPasteboard generalPasteboard]; + [pb clearContents]; + + NSMutableArray> *objects = [NSMutableArray array]; + + NSPasteboardItem *item = [[NSPasteboardItem alloc] init]; + BOOL itemHasContent = NO; + + NSString *text = toNSString(env, jText); + NSString *html = toNSString(env, jHtml); + NSString *rtf = toNSString(env, jRtf); + + if (html != nil) { + [item setString:html forType:@"public.html"]; + itemHasContent = YES; + } + if (rtf != nil) { + NSData *rtfData = [rtf dataUsingEncoding:NSUTF8StringEncoding]; + if (rtfData != nil) { + [item setData:rtfData forType:@"public.rtf"]; + itemHasContent = YES; + } + } + if (text != nil) { + [item setString:text forType:@"public.utf8-plain-text"]; + itemHasContent = YES; + } + + if (jPng != NULL) { + jsize len = (*env)->GetArrayLength(env, jPng); + jbyte *bytes = (*env)->GetByteArrayElements(env, jPng, NULL); + if (bytes != NULL) { + NSData *pngData = [NSData dataWithBytes:bytes length:(NSUInteger)len]; + (*env)->ReleaseByteArrayElements(env, jPng, bytes, JNI_ABORT); + + // Write PNG directly for web/Chromium consumers, plus a transcoded + // TIFF for AppKit-native pasters. + [item setData:pngData forType:@"public.png"]; + NSBitmapImageRep *rep = [NSBitmapImageRep imageRepWithData:pngData]; + if (rep != nil) { + NSData *tiff = [rep representationUsingType:NSBitmapImageFileTypeTIFF properties:@{}]; + if (tiff != nil) { + [item setData:tiff forType:@"public.tiff"]; + } + } + itemHasContent = YES; + } + } + + if (itemHasContent) { + [objects addObject:item]; + } + + // File URLs go as separate NSURL pasteboard items — they round-trip + // cleanly through readObjectsForClasses: on the reader side. + if (jPaths != NULL) { + jsize pc = (*env)->GetArrayLength(env, jPaths); + for (jsize i = 0; i < pc; i++) { + jstring jp = (jstring)(*env)->GetObjectArrayElement(env, jPaths, i); + NSString *p = toNSString(env, jp); + if (jp != NULL) (*env)->DeleteLocalRef(env, jp); + if (p == nil || p.length == 0) continue; + NSURL *url = [NSURL fileURLWithPath:p]; + if (url != nil) { + [objects addObject:url]; + } + } + } + + if (objects.count == 0) return JNI_FALSE; + + BOOL ok = [pb writeObjects:objects]; + return ok ? JNI_TRUE : JNI_FALSE; + } +} + +JNIEXPORT jboolean JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_macos_NativeMacClipboardBridge_nativeClear( + JNIEnv *env, jclass cls) { + (void)env; (void)cls; + @autoreleasepool { + [[NSPasteboard generalPasteboard] clearContents]; + return JNI_TRUE; + } +} + +// ============================================================================ +// JNI: access behavior (macOS 15.4+) +// ============================================================================ + +JNIEXPORT void JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_macos_NativeMacClipboardBridge_nativeSetAccessBehavior( + JNIEnv *env, jclass cls, jint value) { + (void)env; (void)cls; + @autoreleasepool { + NSPasteboard *pb = [NSPasteboard generalPasteboard]; + SEL sel = NSSelectorFromString(@"setAccessBehavior:"); + if (![pb respondsToSelector:sel]) return; + // macOS 15.4 enum layout: 0 = alwaysAllow, 1 = askEveryTime, 2 = alwaysDeny. + NSInteger mapped = (NSInteger)value; + NSMethodSignature *sig = [pb methodSignatureForSelector:sel]; + NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig]; + [inv setSelector:sel]; + [inv setTarget:pb]; + [inv setArgument:&mapped atIndex:2]; + [inv invoke]; + } +} diff --git a/clipboard-macos/src/main/resources/META-INF/native-image/io.github.kdroidfilter/nucleus.clipboard-macos/reachability-metadata.json b/clipboard-macos/src/main/resources/META-INF/native-image/io.github.kdroidfilter/nucleus.clipboard-macos/reachability-metadata.json new file mode 100644 index 000000000..a3476b288 --- /dev/null +++ b/clipboard-macos/src/main/resources/META-INF/native-image/io.github.kdroidfilter/nucleus.clipboard-macos/reachability-metadata.json @@ -0,0 +1,8 @@ +{ + "reflection": [ + { + "type": "io.github.kdroidfilter.nucleus.clipboard.macos.NativeMacClipboardBridge", + "jniAccessible": true + } + ] +} diff --git a/clipboard-macos/src/main/resources/META-INF/services/io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardBackend b/clipboard-macos/src/main/resources/META-INF/services/io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardBackend new file mode 100644 index 000000000..9157b638e --- /dev/null +++ b/clipboard-macos/src/main/resources/META-INF/services/io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardBackend @@ -0,0 +1 @@ +io.github.kdroidfilter.nucleus.clipboard.macos.MacClipboardBackend diff --git a/docs/llms.txt b/docs/llms.txt index 60ea63279..a251b560c 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -370,6 +370,8 @@ Nucleus provides runtime libraries for use in your application code. All are pub | Notification (Linux) | `io.github.kdroidfilter:nucleus.notification-linux` | Freedesktop Desktop Notifications API via JNI (D-Bus) | | Launcher (Linux) | `io.github.kdroidfilter:nucleus.launcher-linux` | Unity Launcher API — badge, progress, urgency, quicklist via JNI (D-Bus) | | Launcher (macOS) | `io.github.kdroidfilter:nucleus.launcher-macos` | macOS dock context menu — custom items, submenus, click callbacks via JNI | +| Clipboard (Common) | `io.github.kdroidfilter:nucleus.clipboard-common` | Cross-platform clipboard façade — text, HTML, RTF, images, file lists + change watcher (`Flow`) | +| Clipboard (macOS) | `io.github.kdroidfilter:nucleus.clipboard-macos` | macOS NSPasteboard backend for the clipboard API via JNI | | Media Control | `io.github.kdroidfilter:nucleus.media-control` | OS media controls (play/pause, metadata, seek) — MPRIS D-Bus on Linux, MPNowPlayingInfoCenter on macOS, SystemMediaTransportControls on Windows via JNI | | Menu (macOS) | `io.github.kdroidfilter:nucleus.menu-macos` | Complete NSMenu mapping — application menu bar, items, badges, delegates, SF Symbols via JNI | | SF Symbols | `io.github.kdroidfilter:nucleus.sf-symbols` | Type-safe Apple SF Symbols constants (6 195 symbols, 21 categories) | @@ -404,6 +406,8 @@ dependencies { implementation("io.github.kdroidfilter:nucleus.notification-linux:") implementation("io.github.kdroidfilter:nucleus.launcher-linux:") implementation("io.github.kdroidfilter:nucleus.launcher-macos:") + implementation("io.github.kdroidfilter:nucleus.clipboard-common:") + implementation("io.github.kdroidfilter:nucleus.clipboard-macos:") implementation("io.github.kdroidfilter:nucleus.media-control:") implementation("io.github.kdroidfilter:nucleus.menu-macos:") implementation("io.github.kdroidfilter:nucleus.sf-symbols:") @@ -431,7 +435,7 @@ dependencies { When ProGuard is enabled in a release build, the Nucleus Gradle plugin **automatically includes** the required rules for all Nucleus runtime libraries (`default-compose-desktop-rules.pro`). No manual configuration is needed. -Libraries that use JNI (`decorated-window`, `darkmode-detector`, `system-color`, `energy-manager`, `native-ssl`, `notification-macos`, `notification-windows`, `notification-linux`, `launcher-windows`, `launcher-linux`) require `-keep` rules for their native bridge classes — these are handled by the plugin automatically. +Libraries that use JNI (`decorated-window`, `darkmode-detector`, `system-color`, `energy-manager`, `native-ssl`, `notification-macos`, `notification-windows`, `notification-linux`, `launcher-windows`, `launcher-linux`, `clipboard-macos`) require `-keep` rules for their native bridge classes — these are handled by the plugin automatically. ### Overriding the ProGuard configuration @@ -741,6 +745,8 @@ Everything from the official plugin works unchanged: - [Launcher (macOS)](https://nucleus.kdroidfilter.com/runtime/launcher-macos/) - [Launcher (Windows)](https://nucleus.kdroidfilter.com/runtime/launcher-windows/) - [Launcher (Linux)](https://nucleus.kdroidfilter.com/runtime/launcher-linux/) +- [Clipboard (Common)](https://nucleus.kdroidfilter.com/runtime/clipboard-common/) +- [Clipboard (macOS)](https://nucleus.kdroidfilter.com/runtime/clipboard-macos/) - [Media Control](https://nucleus.kdroidfilter.com/runtime/media-control/) - [Dark Mode Detector](https://nucleus.kdroidfilter.com/runtime/darkmode-detector/) - [System Color](https://nucleus.kdroidfilter.com/runtime/system-color/) diff --git a/docs/roadmap.md b/docs/roadmap.md index 82c9f6e13..1433c9f53 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -9,5 +9,5 @@ Modules planned for upcoming releases. Contributions welcome. | `share-sheet` | OS share sheet (URL, file, text) | `NSSharingService` | Windows `DataTransferManager` | xdg-desktop-portal `Share` | | `power-events` | Sleep / wake / lock / unlock / screen-off / battery state events | `NSWorkspace` notifications | `WM_POWERBROADCAST` / `WTSRegisterSessionNotification` | `org.freedesktop.login1` D-Bus signals | | `fs-watcher` | Native filesystem watcher (replaces slow `WatchService`) | `FSEvents` | `ReadDirectoryChangesW` | `inotify` | -| `clipboard` | Rich clipboard — image, files, HTML, RTF — plus change watcher | `NSPasteboard` | `OleGetClipboard` / Clipboard History API | `wl-clipboard` / X11 selections | +| `clipboard` | Rich clipboard — image, files, HTML, RTF — plus change watcher. **macOS shipped** via [`clipboard-common`](runtime/clipboard-common.md) + [`clipboard-macos`](runtime/clipboard-macos.md); Windows and Linux backends pending | ✅ `NSPasteboard` | `AddClipboardFormatListener` / OLE | `wl-clipboard` / X11 selections | | `screen-capture` | Native screenshot / screen recording | `CGDisplayCreateImage` / ScreenCaptureKit | Windows Graphics Capture / DXGI | xdg-desktop-portal `Screenshot` | diff --git a/docs/runtime/clipboard-common.md b/docs/runtime/clipboard-common.md new file mode 100644 index 000000000..8c7085e21 --- /dev/null +++ b/docs/runtime/clipboard-common.md @@ -0,0 +1,186 @@ +# Clipboard (Common) + +Rich cross-platform clipboard with a reactive change watcher. Reads and writes text, HTML, RTF, images, and file lists behind a single Kotlin façade — the module discovers the right platform backend at runtime via `ServiceLoader`. + +!!! info "macOS-only in V1" + The SPI is cross-platform — the façade and all types live in `clipboard-common` — but only `clipboard-macos` is available today. When no backend is on the classpath, every method degrades to a no-op (`null` / `false` / empty flow) so calling code keeps working on Windows and Linux until those backends land. + +## Installation + +```kotlin +dependencies { + implementation("io.github.kdroidfilter:nucleus.clipboard-common:") + // Add the platform backend(s) you ship. On macOS: + implementation("io.github.kdroidfilter:nucleus.clipboard-macos:") +} +``` + +The common module pulls in `core-runtime` and `kotlinx-coroutines-core` transitively. Backend discovery happens lazily on the first call to a `Clipboard` method. + +## Quick Start + +```kotlin +import io.github.kdroidfilter.nucleus.clipboard.* + +// Simple read / write +val selection = Clipboard.readText() +Clipboard.writeText("Hello from Nucleus") + +// Atomic multi-format write — HTML + plain-text fallback + RTF +Clipboard.write { + html = "Hello from Nucleus" + text = "Hello from Nucleus" + rtf = """{\rtf1\ansi \b Hello\b0 from Nucleus.}""" +} + +// React to user copies +Clipboard.watch() + .filter { ClipboardFormat.Text in it.formats } + .onEach { event -> + val text = Clipboard.readText() ?: return@onEach + println("User copied: $text") + } + .launchIn(scope) +``` + +## API Reference + +### `Clipboard` + +Singleton façade. All suspending methods may be called from any coroutine context — the backend hops to its own thread internally. + +| Property / Method | Description | +|---|---| +| `isAvailable: Boolean` | `true` when a platform backend is loaded and operational. `false` on unsupported platforms or when the native library failed to load. | +| `backendName: String` | Backend name for diagnostics (e.g. `"macOS NSPasteboard"` or `"no-op"`). | +| `setAccessBehavior(behavior)` | Applies a privacy policy for background reads. Maps to `NSPasteboard.accessBehavior` on macOS 15.4+, no-op elsewhere. | + +#### Read + +| Method | Returns | Description | +|---|---|---| +| `readText()` | `String?` | UTF-8 plain text, or `null` if the clipboard has no text. | +| `readHtml()` | `String?` | UTF-8 HTML fragment. Leading BOMs emitted by Firefox / Chromium are stripped. | +| `readRtf()` | `String?` | UTF-8 RTF payload. | +| `readImageBytes()` | `ByteArray?` | PNG-encoded bytes. Backends transcode TIFF/DIB to PNG when only a raster format is available. Decode with your preferred library (`ImageIO.read`, Skia, Coil, ...). | +| `readFiles()` | `List` | Absolute file paths read from file-URL items. Empty list when no files are advertised. | +| `availableFormats()` | `Set` | Advertised formats **without reading content bytes**. Safe to call under restrictive pasteboard-privacy policies (macOS 15.4+). | + +#### Write + +| Method | Returns | Description | +|---|---|---| +| `writeText(text)` | `Boolean` | Publishes UTF-8 text. | +| `writeHtml(html, plainTextFallback)` | `Boolean` | Publishes an HTML fragment with an optional plain-text representation for consumers that do not understand HTML. | +| `writeRtf(rtf, plainTextFallback)` | `Boolean` | Same for RTF. | +| `writeImage(png)` | `Boolean` | Publishes a PNG image. Backends also publish a platform-native raster (TIFF on macOS) on the same item to maximise consumer compatibility. | +| `writeFiles(paths)` | `Boolean` | Publishes a list of file URLs. | +| `write { }` | `Boolean` | Atomic multi-format write — see [`ClipboardWriteScope`](#clipboardwritescope). | +| `clear()` | `Boolean` | Clears the clipboard. | + +#### Watch + +```kotlin +fun watch(pollInterval: Duration = 250.milliseconds): Flow +``` + +Cold [Flow] that emits a `ClipboardEvent` whenever the clipboard contents change. + +- The event carries only **format metadata** and a monotonic `changeCount` — **no content bytes** are read. Call a `readXxx` method on demand to fetch the payload. +- The baseline is captured at `collect` time, so the flow does **not** emit the current clipboard state — only subsequent changes. +- Self-writes via `Clipboard.writeXxx` bump the counter too; deduplicate with `changeCount` if needed. +- `pollInterval` only affects backends that must poll (macOS, where `NSPasteboard` has no push notification). Push-based backends (Windows `WM_CLIPBOARDUPDATE`, X11 XFixes, Wayland `data-control`) emit immediately and ignore the value. + +!!! info "Why polling on macOS?" + `NSPasteboard` has no push notification — Apple confirmed this in May 2025. Reading `changeCount` is a cheap Mach IPC call, so a 250 ms poll costs roughly 0.1 % CPU. The only way to detect clipboard changes on macOS is to poll this counter. + +--- + +### `ClipboardEvent` + +```kotlin +data class ClipboardEvent( + val formats: Set, + val changeCount: Long, +) +``` + +| Property | Description | +|---|---| +| `formats` | Formats currently advertised on the clipboard. | +| `changeCount` | Backend-provided monotonic counter. Use it to deduplicate self-writes or to correlate a write with the resulting event. | + +--- + +### `ClipboardFormat` + +```kotlin +enum class ClipboardFormat { Text, Html, Rtf, Image, Files } +``` + +Content categories advertised on the clipboard. Used by [`availableFormats`](#read) and [`ClipboardEvent.formats`](#clipboardevent). + +--- + +### `ClipboardWriteScope` + +Builder for a multi-format write. Any non-null property is published atomically on the same clipboard item, so consumers can pick the richest representation they understand. + +```kotlin +Clipboard.write { + html = "

Selected text

" + text = "Selected text" + rtf = """{\rtf1\ansi Selected \b text\b0 .}""" + imagePng = renderPreview().encodeToPng() + files = listOf(Path.of("/tmp/export.csv")) +} +``` + +| Property | Type | Description | +|---|---|---| +| `text` | `String?` | UTF-8 plain text. Written as `public.utf8-plain-text` on macOS. | +| `html` | `String?` | UTF-8 HTML fragment. Written as `public.html` on macOS (no CF_HTML wrapper). | +| `rtf` | `String?` | UTF-8 RTF payload. Written as `public.rtf` on macOS. | +| `imagePng` | `ByteArray?` | PNG-encoded image bytes. On macOS, a TIFF representation is transcoded and published on the same item. | +| `files` | `List?` | Absolute file paths. Written as `public.file-url` NSURLs on macOS. | + +At least one property must be set — `write { }` with everything `null` returns `false` without touching the clipboard. + +--- + +### `AccessBehavior` + +```kotlin +enum class AccessBehavior { AlwaysAllow, AskEveryTime, AlwaysDeny } +``` + +Platform privacy policy for background reads. Maps 1:1 to `NSPasteboard.AccessBehavior` on macOS 15.4+. No-op on platforms without a privacy model. Call at startup: + +```kotlin +Clipboard.setAccessBehavior(AccessBehavior.AskEveryTime) +``` + +## Sensitive-content note + +Background polling of `changeCount` and `availableFormats()` never reads payload bytes and therefore does **not** trigger the macOS 15.4+ pasteboard-privacy prompt. The prompt is only triggered on explicit `readXxx` calls when the user has enabled `EnablePasteboardPrivacyDeveloperPreview` or when `AccessBehavior.AskEveryTime` is set. + +## Architecture + +``` +Clipboard (façade, common) + └─ BackendFactory (ServiceLoader) + ├─ MacClipboardBackend → NSPasteboard via JNI (clipboard-macos) + ├─ WindowsClipboardBackend → AddClipboardFormatListener (future) + ├─ X11ClipboardBackend → XFixes + ICCCM selections (future) + └─ WaylandClipboardBackend → ext-data-control-v1 (future) +``` + +Adding a backend is a matter of implementing `io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardBackend` and registering it in `META-INF/services/`. The first backend whose `isAvailable()` returns `true` wins; if none match, a `NoOpBackend` keeps every method well-defined. + +## ProGuard + +No additional rules needed for `clipboard-common` itself. Backend modules ship their own (see [Clipboard macOS ProGuard rules](clipboard-macos.md#proguard)). + +## GraalVM + +No additional reachability metadata is needed for `clipboard-common`. Backend modules ship their own. diff --git a/docs/runtime/clipboard-macos.md b/docs/runtime/clipboard-macos.md new file mode 100644 index 000000000..13f8c92b8 --- /dev/null +++ b/docs/runtime/clipboard-macos.md @@ -0,0 +1,116 @@ +# Clipboard (macOS) + +macOS `NSPasteboard` backend for the [cross-platform clipboard API](clipboard-common.md). Reads and writes text, HTML, RTF, PNG/TIFF images, and file URLs via a single JNI bridge over the general pasteboard. + +!!! info "Use via `Clipboard`" + This page documents the macOS-specific behavior. The public API is `io.github.kdroidfilter.nucleus.clipboard.Clipboard` — same on every platform. See [Clipboard (Common)](clipboard-common.md) for the full surface. + +## Installation + +```kotlin +dependencies { + implementation("io.github.kdroidfilter:nucleus.clipboard-common:") + implementation("io.github.kdroidfilter:nucleus.clipboard-macos:") +} +``` + +Depends on `clipboard-common` and `core-runtime` (pulled in transitively). The backend registers itself via `META-INF/services/io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardBackend` — adding the dependency is enough, no manual wiring. + +## Format mapping + +The backend publishes and reads UTI-typed items on `NSPasteboard.generalPasteboard`. Writes use a single `NSPasteboardItem` carrying every non-null representation so consumers can pick the richest format they understand. + +| Common API | UTI on read (priority) | UTI on write | +|---|---|---| +| `readText` / `writeText` | `public.utf8-plain-text` → `NSPasteboardTypeString` | `public.utf8-plain-text` | +| `readHtml` / `writeHtml` | `public.html` (UTF-8 BOM stripped) | `public.html` | +| `readRtf` / `writeRtf` | `public.rtf` | `public.rtf` | +| `readImageBytes` / `writeImage` | `public.png` → `public.tiff` (native TIFF → PNG transcode) | `public.png` **and** `public.tiff` on the same item | +| `readFiles` / `writeFiles` | `public.file-url` NSURLs → legacy `NSFilenamesPboardType` | `public.file-url` NSURLs | + +### Why PNG **and** TIFF on write? + +Web and Chromium-based apps (Slack, Discord, Figma, Electron) only understand `public.png`. Native AppKit apps (Pages, Keynote, Preview) prefer `public.tiff`. Publishing both on the same item keeps both paths happy without forcing callers to know the difference. + +### Why strip HTML BOMs? + +Firefox writes an UTF-16 BOM, Chromium writes an UTF-8 BOM on their `public.html` payloads. The backend strips the leading `\uFEFF` on read so callers get clean HTML regardless of source. + +## Change watcher + +macOS has no native push notification for pasteboard changes. The watcher polls `NSPasteboard.changeCount`, a cheap Mach IPC call exposed by the JNI bridge: + +- Default poll interval: **250 ms** (configurable via `Clipboard.watch(pollInterval)`). +- Typical cost: ~0.1 % CPU. +- Events carry only `formats` (derived from `-types`) and `changeCount` — **no content bytes are read** during polling. + +!!! tip "Self-writes bump changeCount" + Every `Clipboard.writeXxx` increments `changeCount`, which means the watcher will observe your own writes. Deduplicate with the counter if that matters for your use case. + +## macOS 15.4+ pasteboard privacy + +macOS 15.4 introduced a developer-preview pasteboard-privacy prompt. The backend is designed to stay outside its scope by default: + +- **Watcher & `availableFormats()`** — use `changeCount` and `-types`, both of which return metadata only. **No prompt.** +- **`readXxx` methods** — read actual bytes; will trigger the prompt when `defaults write EnablePasteboardPrivacyDeveloperPreview -bool yes` is active, or on macOS 16 when the prompt is enabled by default. +- **`Clipboard.setAccessBehavior(...)`** — maps to `NSPasteboard.accessBehavior` (macOS 15.4+, guarded by `respondsToSelector:`). Older macOS versions silently ignore the call. + +```kotlin +// At startup — opt the app into "ask every time" policy on macOS 15.4+. +Clipboard.setAccessBehavior(AccessBehavior.AskEveryTime) +``` + +## Concealed / transient content + +macOS uses community-defined marker UTIs (documented at [nspasteboard.org](https://nspasteboard.org)) to flag sensitive clipboard items. **V1 does not emit these markers automatically** — a `writeConcealedText` helper is planned for V2. Until then, apps that handle secrets should fall back to the platform-specific `notification-macos` patterns or consider clearing the clipboard after a delay. + +## Thread safety + +`NSPasteboard` is thread-safe on any thread — the backend runs all I/O on `Dispatchers.IO`. No main-thread hop is required. `NSPasteboardItemDataProvider` (delayed rendering) is planned for V2 and will run on the main thread when it lands. + +## Native Library + +Ships pre-built dylibs for both macOS architectures: + +- `libnucleus_clipboard.dylib` — linked against `AppKit.framework` +- Minimum deployment target: **macOS 10.13** +- Exports 11 `Java_...` symbols (read / write / watch / clear / access behavior) +- `NSPasteboard.accessBehavior` is resolved via `respondsToSelector:` — calling `setAccessBehavior` on macOS < 15.4 is a no-op + +`isAvailable` returns `false` on non-macOS platforms and all methods degrade to no-ops — the façade falls through to `NoOpBackend`. + +## ProGuard + +Auto-injected by the Nucleus Gradle plugin. If you override `configurationFiles`, add: + +```proguard +-keep class io.github.kdroidfilter.nucleus.clipboard.macos.NativeMacClipboardBridge { + native ; +} + +# Preserve the ServiceLoader entry +-keep class io.github.kdroidfilter.nucleus.clipboard.macos.MacClipboardBackend +``` + +## GraalVM + +Reachability metadata is included in the JAR at +`META-INF/native-image/io.github.kdroidfilter/nucleus.clipboard-macos/reachability-metadata.json` +and auto-discovered. The `META-INF/services/` entry is picked up by GraalVM automatically. + +No additional configuration is needed when building with `runGraalvmNative`. + +## Troubleshooting + +**`Clipboard.isAvailable` returns `false` on macOS** + +- Confirm the `clipboard-macos` dependency is on the runtime classpath (it is not pulled in by `clipboard-common`). +- Enable fine-grained JUL logging on `BackendFactory` — the selected backend and any probe failures are logged at `FINE`. + +**Images copied from Safari / Preview paste as transparent black rectangles** + +This is a known macOS quirk when the source emits a malformed TIFF alpha channel. Re-copying from the source often fixes it. Chromium sanitizes `DIBv5` writes on Windows for the same reason — a similar sanitizer may be added in a later release. + +**`readFiles()` returns an empty list even though the Finder shows items** + +Some Finder operations copy aliases that resolve to `x-coredata://` URIs rather than `file://`. These are not returned by `readFiles()`; call `availableFormats()` to check that `ClipboardFormat.Files` is advertised before falling back to an error message. diff --git a/docs/runtime/index.md b/docs/runtime/index.md index 16c60af6e..b99c2cad5 100644 --- a/docs/runtime/index.md +++ b/docs/runtime/index.md @@ -16,6 +16,8 @@ Nucleus provides runtime libraries for use in your application code. All are pub | Notification (Linux) | `io.github.kdroidfilter:nucleus.notification-linux` | Freedesktop Desktop Notifications API via JNI (D-Bus) | | Launcher (Linux) | `io.github.kdroidfilter:nucleus.launcher-linux` | Unity Launcher API — badge, progress, urgency, quicklist via JNI (D-Bus) | | Launcher (macOS) | `io.github.kdroidfilter:nucleus.launcher-macos` | macOS dock context menu — custom items, submenus, click callbacks via JNI | +| Clipboard (Common) | `io.github.kdroidfilter:nucleus.clipboard-common` | Cross-platform clipboard façade — text, HTML, RTF, images, file lists + change watcher (`Flow`) | +| Clipboard (macOS) | `io.github.kdroidfilter:nucleus.clipboard-macos` | macOS NSPasteboard backend for the clipboard API via JNI | | Media Control | `io.github.kdroidfilter:nucleus.media-control` | OS media controls (play/pause, metadata, seek) — MPRIS D-Bus on Linux, MPNowPlayingInfoCenter on macOS, SystemMediaTransportControls on Windows via JNI | | Menu (macOS) | `io.github.kdroidfilter:nucleus.menu-macos` | Complete NSMenu mapping — application menu bar, items, badges, delegates, SF Symbols via JNI | | SF Symbols | `io.github.kdroidfilter:nucleus.sf-symbols` | Type-safe Apple SF Symbols constants (6 195 symbols, 21 categories) | @@ -50,6 +52,8 @@ dependencies { implementation("io.github.kdroidfilter:nucleus.notification-linux:") implementation("io.github.kdroidfilter:nucleus.launcher-linux:") implementation("io.github.kdroidfilter:nucleus.launcher-macos:") + implementation("io.github.kdroidfilter:nucleus.clipboard-common:") + implementation("io.github.kdroidfilter:nucleus.clipboard-macos:") implementation("io.github.kdroidfilter:nucleus.media-control:") implementation("io.github.kdroidfilter:nucleus.menu-macos:") implementation("io.github.kdroidfilter:nucleus.sf-symbols:") @@ -77,7 +81,7 @@ dependencies { When ProGuard is enabled in a release build, the Nucleus Gradle plugin **automatically includes** the required rules for all Nucleus runtime libraries (`default-compose-desktop-rules.pro`). No manual configuration is needed. -Libraries that use JNI (`decorated-window`, `darkmode-detector`, `system-color`, `energy-manager`, `native-ssl`, `notification-macos`, `notification-windows`, `notification-linux`, `launcher-windows`, `launcher-linux`) require `-keep` rules for their native bridge classes — these are handled by the plugin automatically. +Libraries that use JNI (`decorated-window`, `darkmode-detector`, `system-color`, `energy-manager`, `native-ssl`, `notification-macos`, `notification-windows`, `notification-linux`, `launcher-windows`, `launcher-linux`, `clipboard-macos`) require `-keep` rules for their native bridge classes — these are handled by the plugin automatically. ### Overriding the ProGuard configuration diff --git a/example/build.gradle.kts b/example/build.gradle.kts index 45369c360..1945cf4b8 100644 --- a/example/build.gradle.kts +++ b/example/build.gradle.kts @@ -40,6 +40,8 @@ dependencies { implementation(project(":launcher-macos")) implementation(project(":global-hotkey")) implementation(project(":menu-macos")) + implementation(project(":clipboard-common")) + implementation(project(":clipboard-macos")) implementation(project(":sf-symbols")) implementation(project(":media-control")) implementation(libs.coroutines.swing) diff --git a/example/src/main/kotlin/com/example/demo/ClipboardScreen.kt b/example/src/main/kotlin/com/example/demo/ClipboardScreen.kt new file mode 100644 index 000000000..707337ba3 --- /dev/null +++ b/example/src/main/kotlin/com/example/demo/ClipboardScreen.kt @@ -0,0 +1,348 @@ +package com.example.demo + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import io.github.kdroidfilter.nucleus.clipboard.Clipboard +import io.github.kdroidfilter.nucleus.clipboard.ClipboardEvent +import io.github.kdroidfilter.nucleus.clipboard.ClipboardFormat +import kotlinx.coroutines.launch +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import org.jetbrains.skia.Image as SkiaImage + +private const val EVENT_LOG_MAX = 40 + +@Suppress("FunctionNaming", "LongMethod", "CyclomaticComplexMethod") +@Composable +fun ClipboardScreen() { + val scope = rememberCoroutineScope() + val events = remember { mutableStateListOf() } + + fun log(msg: String) { + val ts = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + events.add(0, "[$ts] $msg") + while (events.size > EVENT_LOG_MAX) events.removeAt(events.lastIndex) + } + + var currentFormats by remember { mutableStateOf>(emptySet()) } + var currentChangeCount by remember { mutableStateOf(0L) } + var lastText by remember { mutableStateOf(null) } + var lastHtml by remember { mutableStateOf(null) } + var lastRtf by remember { mutableStateOf(null) } + var lastImage by remember { mutableStateOf(null) } + var lastFiles by remember { mutableStateOf>(emptyList()) } + + var textToCopy by remember { mutableStateOf("Hello from Nucleus") } + var htmlToCopy by remember { + mutableStateOf("Hello from Nucleus — rich clipboard write") + } + + // Watcher — cold Flow, stopped when the screen leaves the composition. + LaunchedEffect(Unit) { + Clipboard.watch().collect { event: ClipboardEvent -> + currentFormats = event.formats + currentChangeCount = event.changeCount + log("change #${event.changeCount} formats=${event.formats}") + } + } + + LaunchedEffect(Unit) { + currentFormats = Clipboard.availableFormats() + } + + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text("Clipboard", style = MaterialTheme.typography.headlineSmall) + Text( + "Backend: ${Clipboard.backendName} · available: ${Clipboard.isAvailable}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + WatcherCard(currentFormats, currentChangeCount) + WriteCard( + textToCopy = textToCopy, + onTextChange = { textToCopy = it }, + htmlToCopy = htmlToCopy, + onHtmlChange = { htmlToCopy = it }, + onWriteText = { + scope.launch { + val ok = Clipboard.writeText(textToCopy) + log(if (ok) "wrote text (${textToCopy.length} chars)" else "writeText FAILED") + } + }, + onWriteHtml = { + scope.launch { + val ok = Clipboard.writeHtml(htmlToCopy, plainTextFallback = textToCopy) + log(if (ok) "wrote html + text fallback" else "writeHtml FAILED") + } + }, + onWriteRich = { + scope.launch { + val ok = + Clipboard.write { + html = htmlToCopy + text = textToCopy + rtf = """{\rtf1\ansi Rich \b $textToCopy\b0 .}""" + } + log(if (ok) "wrote multi-format (html+rtf+text)" else "write{} FAILED") + } + }, + onClear = { + scope.launch { + val ok = Clipboard.clear() + log(if (ok) "cleared" else "clear FAILED") + } + }, + ) + ReadCard( + lastText = lastText, + lastHtml = lastHtml, + lastRtf = lastRtf, + lastImage = lastImage, + lastFiles = lastFiles, + onReadText = { + scope.launch { + lastText = Clipboard.readText() + log("read text: ${lastText?.take(80) ?: "(none)"}") + } + }, + onReadHtml = { + scope.launch { + lastHtml = Clipboard.readHtml() + log("read html: ${lastHtml?.take(80) ?: "(none)"}") + } + }, + onReadRtf = { + scope.launch { + lastRtf = Clipboard.readRtf() + log("read rtf: ${lastRtf?.take(80) ?: "(none)"}") + } + }, + onReadImage = { + scope.launch { + val bytes = Clipboard.readImageBytes() + lastImage = + bytes?.let { + runCatching { + SkiaImage + .makeFromEncoded( + it, + ).toComposeImageBitmap() + }.getOrNull() + } + log("read image: ${bytes?.size ?: 0} bytes (png)") + } + }, + onReadFiles = { + scope.launch { + val list = Clipboard.readFiles().map { it.toString() } + lastFiles = list + log("read files: ${list.size}") + } + }, + ) + EventLogCard(events) + } + } +} + +@Composable +private fun WatcherCard( + formats: Set, + changeCount: Long, +) { + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Watcher", style = MaterialTheme.typography.titleMedium) + Text( + "changeCount: $changeCount", + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + ClipboardFormat.entries.forEach { fmt -> + FilterChip( + selected = fmt in formats, + onClick = {}, + label = { Text(fmt.name) }, + ) + } + } + Text( + "Copy anything anywhere (⌘C) to see events appear below.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun WriteCard( + textToCopy: String, + onTextChange: (String) -> Unit, + htmlToCopy: String, + onHtmlChange: (String) -> Unit, + onWriteText: () -> Unit, + onWriteHtml: () -> Unit, + onWriteRich: () -> Unit, + onClear: () -> Unit, +) { + Card { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Write", style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + value = textToCopy, + onValueChange = onTextChange, + label = { Text("Plain text") }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = htmlToCopy, + onValueChange = onHtmlChange, + label = { Text("HTML fragment") }, + modifier = Modifier.fillMaxWidth(), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onWriteText) { Text("Write text") } + Button(onClick = onWriteHtml) { Text("Write HTML + text") } + Button(onClick = onWriteRich) { Text("Write {} multi-format") } + OutlinedButton(onClick = onClear) { Text("Clear") } + } + } + } +} + +@Suppress("LongParameterList") +@Composable +private fun ReadCard( + lastText: String?, + lastHtml: String?, + lastRtf: String?, + lastImage: ImageBitmap?, + lastFiles: List, + onReadText: () -> Unit, + onReadHtml: () -> Unit, + onReadRtf: () -> Unit, + onReadImage: () -> Unit, + onReadFiles: () -> Unit, +) { + Card { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Read", style = MaterialTheme.typography.titleMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onReadText) { Text("Text") } + Button(onClick = onReadHtml) { Text("HTML") } + Button(onClick = onReadRtf) { Text("RTF") } + Button(onClick = onReadImage) { Text("Image") } + Button(onClick = onReadFiles) { Text("Files") } + } + ReadRow("Text", lastText) + ReadRow("HTML", lastHtml) + ReadRow("RTF", lastRtf) + if (lastImage != null) { + Text("Image", style = MaterialTheme.typography.labelMedium) + Image( + painter = BitmapPainter(lastImage), + contentDescription = "Clipboard image", + modifier = Modifier.widthIn(max = 320.dp).heightIn(max = 200.dp), + ) + } + if (lastFiles.isNotEmpty()) { + Text("Files", style = MaterialTheme.typography.labelMedium) + Column { + lastFiles.forEach { Text(it, style = MaterialTheme.typography.bodySmall) } + } + } + } + } +} + +@Composable +private fun ReadRow( + label: String, + value: String?, +) { + if (value == null) return + Column { + Text(label, style = MaterialTheme.typography.labelMedium) + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 120.dp), + ) { + Text( + value, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + } + } +} + +@Composable +private fun EventLogCard(events: List) { + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Event log", style = MaterialTheme.typography.titleMedium) + if (events.isEmpty()) { + Text( + "No events yet. Copy something to populate.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + HorizontalDivider() + Spacer(Modifier.height(4.dp)) + events.take(EVENT_LOG_MAX).forEach { + Text(it, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace) + } + } + } + } +} diff --git a/example/src/main/kotlin/com/example/demo/GroupTabs.kt b/example/src/main/kotlin/com/example/demo/GroupTabs.kt new file mode 100644 index 000000000..b8a14b712 --- /dev/null +++ b/example/src/main/kotlin/com/example/demo/GroupTabs.kt @@ -0,0 +1,243 @@ +package com.example.demo + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties + +internal data class TabGroup( + val name: String, + val children: List, +) + +private val TAB_HEIGHT = 28.dp + +/** + * Parent tabs with per-tab dropdowns for child selection. Single-child groups + * bypass the dropdown and select the child directly. Visuals match + * [DraggableTabs] — same chip metrics, same hover/selection palette, no + * elevation or heavyweight Material scaffolding on the popup. + */ +@Suppress("FunctionNaming") +@Composable +internal fun GroupDropdownTabs( + groups: List, + selectedTab: String, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.height(TAB_HEIGHT), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + for (group in groups) { + val isSelected = selectedTab in group.children + val isSingle = group.children.size == 1 + var expanded by remember { mutableStateOf(false) } + + Box { + GroupTabChip( + label = group.name, + isSelected = isSelected, + onClick = { + if (isSingle) { + onSelect(group.children.single()) + } else { + expanded = !expanded + } + }, + ) + + if (expanded) { + val offsetY = with(LocalDensity.current) { (TAB_HEIGHT + 4.dp).roundToPx() } + Popup( + alignment = Alignment.TopStart, + offset = IntOffset(0, offsetY), + onDismissRequest = { expanded = false }, + properties = PopupProperties(focusable = true), + ) { + TabDropdownSurface { + for (child in group.children) { + DropdownItem( + label = child, + isSelected = child == selectedTab, + onClick = { + onSelect(child) + expanded = false + }, + ) + } + } + } + } + } + } + } +} + +@Suppress("FunctionNaming") +@Composable +private fun GroupTabChip( + label: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + + val bgColor by animateColorAsState( + when { + isSelected -> MaterialTheme.colorScheme.surfaceContainerHigh + isHovered -> MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.5f) + else -> Color.Transparent + }, + spring(stiffness = Spring.StiffnessMediumLow), + ) + val textColor by animateColorAsState( + when { + isSelected -> MaterialTheme.colorScheme.onSurface + isHovered -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + val indicatorAlpha by animateFloatAsState( + if (isSelected) 1f else 0f, + spring(stiffness = Spring.StiffnessMediumLow), + ) + val indicatorColor = MaterialTheme.colorScheme.primary + + Box( + modifier = + modifier + .clip(RoundedCornerShape(6.dp)) + .background(bgColor) + .hoverable(hoverInteraction) + .clickable { onClick() } + .drawBehind { + if (indicatorAlpha > 0f) { + val h = 2.dp.toPx() + drawRoundRect( + color = indicatorColor.copy(alpha = indicatorAlpha), + topLeft = Offset(4.dp.toPx(), size.height - h), + size = Size(size.width - 8.dp.toPx(), h), + cornerRadius = CornerRadius(h / 2), + ) + } + }.padding(horizontal = 12.dp, vertical = 4.dp), + contentAlignment = Alignment.Center, + ) { + Text( + label, + style = MaterialTheme.typography.labelMedium, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Suppress("FunctionNaming") +@Composable +private fun TabDropdownSurface(content: @Composable () -> Unit) { + Column( + modifier = + Modifier + .width(IntrinsicSize.Max) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f), + shape = RoundedCornerShape(8.dp), + ).padding(4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + content() + } +} + +@Suppress("FunctionNaming") +@Composable +private fun DropdownItem( + label: String, + isSelected: Boolean, + onClick: () -> Unit, +) { + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + + val bgColor by animateColorAsState( + when { + isSelected -> MaterialTheme.colorScheme.surfaceContainerHigh + isHovered -> MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.5f) + else -> Color.Transparent + }, + spring(stiffness = Spring.StiffnessMediumLow), + ) + val textColor by animateColorAsState( + when { + isSelected -> MaterialTheme.colorScheme.onSurface + isHovered -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f) + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + + Box( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background(bgColor) + .hoverable(hoverInteraction) + .clickable { onClick() } + .padding(horizontal = 10.dp, vertical = 6.dp), + contentAlignment = Alignment.CenterStart, + ) { + Text( + label, + style = MaterialTheme.typography.labelMedium, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/example/src/main/kotlin/com/example/demo/Main.kt b/example/src/main/kotlin/com/example/demo/Main.kt index 35ac7eb1f..f6e375861 100644 --- a/example/src/main/kotlin/com/example/demo/Main.kt +++ b/example/src/main/kotlin/com/example/demo/Main.kt @@ -189,29 +189,45 @@ fun main(args: Array) { CompositionLocalProvider( LocalLayoutDirection provides if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr, ) { - val tabs = + val tabGroups = buildList { - addAll(listOf("Nucleus", "Gallery", "Taskbar")) - add("Notifications (Common)") - if (Platform.Current == Platform.MacOS || - Platform.Current == Platform.Linux || - Platform.Current == Platform.Windows - ) { - add("Notifications") - } - if (Platform.Current == Platform.Windows || - Platform.Current == Platform.Linux || - Platform.Current == Platform.MacOS - ) { - add("Launcher") + add(TabGroup("Home", listOf("Nucleus"))) + add(TabGroup("UI", listOf("Gallery", "Taskbar"))) + + val notifChildren = + buildList { + add("Notifications (Common)") + if (Platform.Current == Platform.MacOS || + Platform.Current == Platform.Linux || + Platform.Current == Platform.Windows + ) { + add("Notifications") + } + } + add(TabGroup("Notifications", notifChildren)) + + val launcherChildren = + buildList { + if (Platform.Current == Platform.Windows || + Platform.Current == Platform.Linux || + Platform.Current == Platform.MacOS + ) { + add("Launcher") + } + if (Platform.Current == Platform.MacOS) { + add("Menu") + } + } + if (launcherChildren.isNotEmpty()) { + add(TabGroup("Launcher", launcherChildren)) } - add("Media Control") - add("Auto-Launch") - add("Hotkeys") - if (Platform.Current == Platform.MacOS) { - add("Menu") - } + add( + TabGroup( + "System", + listOf("Media Control", "Auto-Launch", "Hotkeys", "Clipboard"), + ), + ) } var selectedTab by remember { mutableStateOf("Nucleus") } @@ -274,11 +290,10 @@ fun main(args: Array) { modifier = Modifier.align(titleBarAlignment), onClick = { isRtl = !isRtl }, ) - DraggableTabs( - tabs = tabs, + GroupDropdownTabs( + groups = tabGroups, selectedTab = selectedTab, onSelect = { selectedTab = it }, - onReorder = { _, _ -> }, modifier = Modifier.align(Alignment.CenterHorizontally), ) } @@ -358,6 +373,7 @@ fun main(args: Array) { "Auto-Launch" -> AutoLaunchScreen() "Hotkeys" -> GlobalHotKeyScreen() "Menu" -> MacOsMenuScreen() + "Clipboard" -> ClipboardScreen() } if (showInfoDialog) { diff --git a/mkdocs.yml b/mkdocs.yml index c6edd2c0b..34f680414 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -97,6 +97,9 @@ nav: - macOS: runtime/launcher-macos.md - Windows: runtime/launcher-windows.md - Linux: runtime/launcher-linux.md + - Clipboard: + - Common: runtime/clipboard-common.md + - macOS: runtime/clipboard-macos.md - Media Control: runtime/media-control.md - System: - Dark Mode Detector: runtime/darkmode-detector.md diff --git a/settings.gradle.kts b/settings.gradle.kts index 3f5c94bd4..475a5671f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -62,6 +62,8 @@ include(":global-hotkey") include(":media-control") include(":launcher-macos") include(":menu-macos") +include(":clipboard-common") +include(":clipboard-macos") include(":freedesktop-icons") include(":sf-symbols") include(":system-info") From 60635b03bf8d8d938cc165277d3a91b63d941435 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 19 Apr 2026 17:16:37 +0300 Subject: [PATCH 2/8] feat(clipboard): expose macOS 15.4+ access-behavior getter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clipboard.accessBehavior: AccessBehavior? reads NSPasteboard.accessBehavior, null when the host OS has no privacy model. - Clipboard.isAccessBehaviorSupported gates UI based on respondsToSelector: probing for both getter and setter. - Native nativeGetAccessBehavior + nativeIsAccessBehaviorSupported in the JNI bridge; both guarded via NSInvocation + respondsToSelector. - Example Clipboard tab: new "Privacy — macOS 15.4+" card showing the current policy with chips for the three values; disabled chips + explanatory copy on older macOS / other OSes. - Docs updated (runtime/clipboard-common.md, runtime/clipboard-macos.md). --- .../nucleus/clipboard/Clipboard.kt | 9 ++++ .../clipboard/internal/ClipboardBackend.kt | 12 +++++ .../nucleus/clipboard/internal/NoOpBackend.kt | 5 ++ .../clipboard/macos/MacClipboardBackend.kt | 9 ++++ .../macos/NativeMacClipboardBridge.kt | 12 +++++ .../src/main/native/macos/nucleus_clipboard.m | 31 ++++++++++++ docs/runtime/clipboard-common.md | 7 ++- docs/runtime/clipboard-macos.md | 7 ++- .../com/example/demo/ClipboardScreen.kt | 48 +++++++++++++++++++ 9 files changed, 138 insertions(+), 2 deletions(-) diff --git a/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/Clipboard.kt b/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/Clipboard.kt index b5eba3b97..cf2c9a68e 100644 --- a/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/Clipboard.kt +++ b/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/Clipboard.kt @@ -119,6 +119,15 @@ object Clipboard { /** Sets the platform privacy policy for background reads. No-op outside macOS 15.4+. */ fun setAccessBehavior(behavior: AccessBehavior) = backend.setAccessBehavior(behavior) + /** + * Currently effective privacy policy, or `null` when the host OS does not + * expose one (macOS < 15.4, Windows, Linux — treat as unrestricted). + */ + val accessBehavior: AccessBehavior? get() = backend.accessBehavior() + + /** True when [setAccessBehavior] and [accessBehavior] are honored (macOS 15.4+). */ + val isAccessBehaviorSupported: Boolean get() = backend.isAccessBehaviorSupported() + /** * Cold [Flow] that emits whenever the clipboard contents change. * diff --git a/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/internal/ClipboardBackend.kt b/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/internal/ClipboardBackend.kt index 8325d1c45..638c379ee 100644 --- a/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/internal/ClipboardBackend.kt +++ b/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/internal/ClipboardBackend.kt @@ -12,6 +12,7 @@ import java.nio.file.Path * [isAvailable] returns `false`). The facade in [io.github.kdroidfilter.nucleus.clipboard.Clipboard] * picks the first available backend and delegates. */ +@Suppress("TooManyFunctions") interface ClipboardBackend { /** Human-readable backend name, for logging. */ val name: String @@ -47,4 +48,15 @@ interface ClipboardBackend { /** Sets the platform privacy policy (macOS 15.4+). No-op elsewhere. */ fun setAccessBehavior(behavior: AccessBehavior) + + /** + * Reads the currently effective privacy policy. + * + * Returns `null` when the host OS has no such concept (macOS < 15.4, + * Windows, Linux) — callers should interpret this as "unrestricted access". + */ + fun accessBehavior(): AccessBehavior? + + /** True when [setAccessBehavior] and [accessBehavior] are honored by the backend. */ + fun isAccessBehaviorSupported(): Boolean } diff --git a/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/internal/NoOpBackend.kt b/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/internal/NoOpBackend.kt index 181359f71..1365b8200 100644 --- a/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/internal/NoOpBackend.kt +++ b/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/internal/NoOpBackend.kt @@ -4,6 +4,7 @@ import io.github.kdroidfilter.nucleus.clipboard.AccessBehavior import io.github.kdroidfilter.nucleus.clipboard.ClipboardFormat import java.nio.file.Path +@Suppress("TooManyFunctions") internal object NoOpBackend : ClipboardBackend { override val name: String = "no-op" @@ -28,4 +29,8 @@ internal object NoOpBackend : ClipboardBackend { override fun changeCount(): Long = 0L override fun setAccessBehavior(behavior: AccessBehavior) = Unit + + override fun accessBehavior(): AccessBehavior? = null + + override fun isAccessBehaviorSupported(): Boolean = false } diff --git a/clipboard-macos/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/macos/MacClipboardBackend.kt b/clipboard-macos/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/macos/MacClipboardBackend.kt index 1004f5727..9e9a756c2 100644 --- a/clipboard-macos/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/macos/MacClipboardBackend.kt +++ b/clipboard-macos/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/macos/MacClipboardBackend.kt @@ -79,6 +79,15 @@ class MacClipboardBackend : ClipboardBackend { NativeMacClipboardBridge.nativeSetAccessBehavior(behavior.ordinal) } + override fun accessBehavior(): AccessBehavior? { + if (!NativeMacClipboardBridge.isLoaded) return null + val raw = NativeMacClipboardBridge.nativeGetAccessBehavior() + return AccessBehavior.entries.getOrNull(raw) + } + + override fun isAccessBehaviorSupported(): Boolean = + NativeMacClipboardBridge.isLoaded && NativeMacClipboardBridge.nativeIsAccessBehaviorSupported() + /** * Strips a leading UTF-8 BOM (`\uFEFF`) that Firefox and some web apps emit on * `public.html` payloads. Keeps the rest untouched. diff --git a/clipboard-macos/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/macos/NativeMacClipboardBridge.kt b/clipboard-macos/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/macos/NativeMacClipboardBridge.kt index 21862a2b5..9ac9bad4c 100644 --- a/clipboard-macos/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/macos/NativeMacClipboardBridge.kt +++ b/clipboard-macos/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/macos/NativeMacClipboardBridge.kt @@ -9,6 +9,7 @@ private const val LIBRARY_NAME = "nucleus_clipboard" * pasteboard and are safe to call from any thread (NSPasteboard is thread-safe, * and the ObjC side wraps each entry in an autorelease pool). */ +@Suppress("TooManyFunctions") internal object NativeMacClipboardBridge { private val loaded = NativeLibraryLoader.load(LIBRARY_NAME, NativeMacClipboardBridge::class.java) @@ -62,4 +63,15 @@ internal object NativeMacClipboardBridge { */ @JvmStatic external fun nativeSetAccessBehavior(value: Int) + + /** + * Reads `NSPasteboard.accessBehavior`. Returns the raw enum value on + * macOS 15.4+, or `-1` when the property is not exposed by the runtime. + */ + @JvmStatic + external fun nativeGetAccessBehavior(): Int + + /** True when `NSPasteboard` responds to the `accessBehavior` selectors. */ + @JvmStatic + external fun nativeIsAccessBehaviorSupported(): Boolean } diff --git a/clipboard-macos/src/main/native/macos/nucleus_clipboard.m b/clipboard-macos/src/main/native/macos/nucleus_clipboard.m index 5b0350f8c..083bf4fed 100644 --- a/clipboard-macos/src/main/native/macos/nucleus_clipboard.m +++ b/clipboard-macos/src/main/native/macos/nucleus_clipboard.m @@ -323,3 +323,34 @@ static jobjectArray toJStringArray(JNIEnv *env, NSArray *items) { [inv invoke]; } } + +JNIEXPORT jint JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_macos_NativeMacClipboardBridge_nativeGetAccessBehavior( + JNIEnv *env, jclass cls) { + (void)env; (void)cls; + @autoreleasepool { + NSPasteboard *pb = [NSPasteboard generalPasteboard]; + SEL sel = NSSelectorFromString(@"accessBehavior"); + if (![pb respondsToSelector:sel]) return -1; + NSMethodSignature *sig = [pb methodSignatureForSelector:sel]; + NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig]; + [inv setSelector:sel]; + [inv setTarget:pb]; + [inv invoke]; + NSInteger result = 0; + [inv getReturnValue:&result]; + return (jint)result; + } +} + +JNIEXPORT jboolean JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_macos_NativeMacClipboardBridge_nativeIsAccessBehaviorSupported( + JNIEnv *env, jclass cls) { + (void)env; (void)cls; + @autoreleasepool { + NSPasteboard *pb = [NSPasteboard generalPasteboard]; + BOOL hasGetter = [pb respondsToSelector:NSSelectorFromString(@"accessBehavior")]; + BOOL hasSetter = [pb respondsToSelector:NSSelectorFromString(@"setAccessBehavior:")]; + return (hasGetter && hasSetter) ? JNI_TRUE : JNI_FALSE; + } +} diff --git a/docs/runtime/clipboard-common.md b/docs/runtime/clipboard-common.md index 8c7085e21..26dc1d594 100644 --- a/docs/runtime/clipboard-common.md +++ b/docs/runtime/clipboard-common.md @@ -54,6 +54,8 @@ Singleton façade. All suspending methods may be called from any coroutine conte | `isAvailable: Boolean` | `true` when a platform backend is loaded and operational. `false` on unsupported platforms or when the native library failed to load. | | `backendName: String` | Backend name for diagnostics (e.g. `"macOS NSPasteboard"` or `"no-op"`). | | `setAccessBehavior(behavior)` | Applies a privacy policy for background reads. Maps to `NSPasteboard.accessBehavior` on macOS 15.4+, no-op elsewhere. | +| `accessBehavior: AccessBehavior?` | Currently effective policy, or `null` when the host OS has no such concept (treat as unrestricted). | +| `isAccessBehaviorSupported: Boolean` | `true` when the backend honors `setAccessBehavior` / `accessBehavior` (macOS 15.4+). | #### Read @@ -157,7 +159,10 @@ enum class AccessBehavior { AlwaysAllow, AskEveryTime, AlwaysDeny } Platform privacy policy for background reads. Maps 1:1 to `NSPasteboard.AccessBehavior` on macOS 15.4+. No-op on platforms without a privacy model. Call at startup: ```kotlin -Clipboard.setAccessBehavior(AccessBehavior.AskEveryTime) +if (Clipboard.isAccessBehaviorSupported) { + Clipboard.setAccessBehavior(AccessBehavior.AskEveryTime) +} +val effective: AccessBehavior? = Clipboard.accessBehavior // null on macOS < 15.4 ``` ## Sensitive-content note diff --git a/docs/runtime/clipboard-macos.md b/docs/runtime/clipboard-macos.md index 13f8c92b8..ced110f8b 100644 --- a/docs/runtime/clipboard-macos.md +++ b/docs/runtime/clipboard-macos.md @@ -54,10 +54,15 @@ macOS 15.4 introduced a developer-preview pasteboard-privacy prompt. The backend - **Watcher & `availableFormats()`** — use `changeCount` and `-types`, both of which return metadata only. **No prompt.** - **`readXxx` methods** — read actual bytes; will trigger the prompt when `defaults write EnablePasteboardPrivacyDeveloperPreview -bool yes` is active, or on macOS 16 when the prompt is enabled by default. - **`Clipboard.setAccessBehavior(...)`** — maps to `NSPasteboard.accessBehavior` (macOS 15.4+, guarded by `respondsToSelector:`). Older macOS versions silently ignore the call. +- **`Clipboard.accessBehavior`** — reads the currently effective policy. Returns `null` when the runtime is older than macOS 15.4 (treat as unrestricted). +- **`Clipboard.isAccessBehaviorSupported`** — `true` when both getter and setter selectors respond on `NSPasteboard`. Use this to gate UI that lets the user pick a policy. ```kotlin // At startup — opt the app into "ask every time" policy on macOS 15.4+. -Clipboard.setAccessBehavior(AccessBehavior.AskEveryTime) +if (Clipboard.isAccessBehaviorSupported) { + Clipboard.setAccessBehavior(AccessBehavior.AskEveryTime) + println("Effective policy: ${Clipboard.accessBehavior}") +} ``` ## Concealed / transient content diff --git a/example/src/main/kotlin/com/example/demo/ClipboardScreen.kt b/example/src/main/kotlin/com/example/demo/ClipboardScreen.kt index 707337ba3..1f81ee80a 100644 --- a/example/src/main/kotlin/com/example/demo/ClipboardScreen.kt +++ b/example/src/main/kotlin/com/example/demo/ClipboardScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp +import io.github.kdroidfilter.nucleus.clipboard.AccessBehavior import io.github.kdroidfilter.nucleus.clipboard.Clipboard import io.github.kdroidfilter.nucleus.clipboard.ClipboardEvent import io.github.kdroidfilter.nucleus.clipboard.ClipboardFormat @@ -103,6 +104,12 @@ fun ClipboardScreen() { ) WatcherCard(currentFormats, currentChangeCount) + AccessBehaviorCard( + onChange = { behavior -> + Clipboard.setAccessBehavior(behavior) + log("access behavior → $behavior") + }, + ) WriteCard( textToCopy = textToCopy, onTextChange = { textToCopy = it }, @@ -221,6 +228,47 @@ private fun WatcherCard( } } +@Composable +private fun AccessBehaviorCard(onChange: (AccessBehavior) -> Unit) { + val supported = Clipboard.isAccessBehaviorSupported + var current by remember { mutableStateOf(Clipboard.accessBehavior) } + + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Privacy — macOS 15.4+", style = MaterialTheme.typography.titleMedium) + Text( + if (supported) { + "current: ${current?.name ?: "(unknown)"}" + } else { + "Not supported on this OS / runtime — reads are unrestricted." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + ) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + AccessBehavior.entries.forEach { behavior -> + FilterChip( + enabled = supported, + selected = current == behavior, + onClick = { + onChange(behavior) + current = Clipboard.accessBehavior + }, + label = { Text(behavior.name) }, + ) + } + } + Text( + "Watcher and availableFormats() only read metadata " + + "(changeCount + types) — they never trigger the pasteboard-privacy prompt.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + @Composable private fun WriteCard( textToCopy: String, From 1129d6bb54ea30f58be15af0c96dc6eb46a5db24 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 19 Apr 2026 17:39:00 +0300 Subject: [PATCH 3/8] fix(clipboard): detect macOS 15.4+ via NSProcessInfo, not respondsToSelector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The respondsToSelector: probe for -accessBehavior / -setAccessBehavior: was returning NO on macOS Tahoe under the unsigned JVM, causing the example's Privacy card to claim the API was unsupported even though the runtime does expose it. Switch to [NSProcessInfo isOperatingSystemAtLeastVersion:] for the support flag — more reliable, independent of ObjC metadata visibility. The selector probes on get/set remain as a safety net. --- .../src/main/native/macos/nucleus_clipboard.m | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/clipboard-macos/src/main/native/macos/nucleus_clipboard.m b/clipboard-macos/src/main/native/macos/nucleus_clipboard.m index 083bf4fed..16e57d4ee 100644 --- a/clipboard-macos/src/main/native/macos/nucleus_clipboard.m +++ b/clipboard-macos/src/main/native/macos/nucleus_clipboard.m @@ -18,6 +18,11 @@ static JavaVM *g_jvm = NULL; +static BOOL isMacOS_15_4_or_later(void) { + NSOperatingSystemVersion v = { .majorVersion = 15, .minorVersion = 4, .patchVersion = 0 }; + return [[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:v]; +} + JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) { (void)reserved; g_jvm = vm; @@ -348,9 +353,6 @@ static jobjectArray toJStringArray(JNIEnv *env, NSArray *items) { JNIEnv *env, jclass cls) { (void)env; (void)cls; @autoreleasepool { - NSPasteboard *pb = [NSPasteboard generalPasteboard]; - BOOL hasGetter = [pb respondsToSelector:NSSelectorFromString(@"accessBehavior")]; - BOOL hasSetter = [pb respondsToSelector:NSSelectorFromString(@"setAccessBehavior:")]; - return (hasGetter && hasSetter) ? JNI_TRUE : JNI_FALSE; + return isMacOS_15_4_or_later() ? JNI_TRUE : JNI_FALSE; } } From a33c442adaffcda4ef8d1a38cf4bb1a718f9849a Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 19 Apr 2026 19:41:17 +0300 Subject: [PATCH 4/8] 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. --- clipboard-linux/build.gradle.kts | 101 ++ .../clipboard/linux/LinuxClipboardBackend.kt | 205 +++ .../linux/NativeX11ClipboardBridge.kt | 80 ++ .../linux/WaylandClipboardDelegate.kt | 292 ++++ .../src/main/native/linux/build.sh | 77 + .../native/linux/nucleus_clipboard_linux.c | 1279 +++++++++++++++++ .../reachability-metadata.json | 8 + ...ucleus.clipboard.internal.ClipboardBackend | 1 + .../clipboard/linux/X11RoundTripSmokeTest.kt | 44 + example/build.gradle.kts | 1 + settings.gradle.kts | 1 + 11 files changed, 2089 insertions(+) create mode 100644 clipboard-linux/build.gradle.kts create mode 100644 clipboard-linux/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/LinuxClipboardBackend.kt create mode 100644 clipboard-linux/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/NativeX11ClipboardBridge.kt create mode 100644 clipboard-linux/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/WaylandClipboardDelegate.kt create mode 100755 clipboard-linux/src/main/native/linux/build.sh create mode 100644 clipboard-linux/src/main/native/linux/nucleus_clipboard_linux.c create mode 100644 clipboard-linux/src/main/resources/META-INF/native-image/io.github.kdroidfilter/nucleus.clipboard-linux/reachability-metadata.json create mode 100644 clipboard-linux/src/main/resources/META-INF/services/io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardBackend create mode 100644 clipboard-linux/src/test/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/X11RoundTripSmokeTest.kt diff --git a/clipboard-linux/build.gradle.kts b/clipboard-linux/build.gradle.kts new file mode 100644 index 000000000..054d64354 --- /dev/null +++ b/clipboard-linux/build.gradle.kts @@ -0,0 +1,101 @@ +import org.apache.tools.ant.taskdefs.condition.Os +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + kotlin("jvm") + alias(libs.plugins.vanniktechMavenPublish) +} + +val publishVersion = + providers + .environmentVariable("GITHUB_REF") + .orNull + ?.removePrefix("refs/tags/v") + ?: "1.0.0" + +dependencies { + implementation(project(":core-runtime")) + implementation(project(":clipboard-common")) + implementation(libs.coroutines.core) + testImplementation(kotlin("test")) +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } +} + +val nativeResourceDir = layout.projectDirectory.dir("src/main/resources/nucleus/native") + +val buildNativeLinux by tasks.registering(Exec::class) { + description = "Compiles the C JNI bridge into a Linux shared library" + group = "build" + val hasPrebuilt = + nativeResourceDir + .dir("linux-x64") + .file("libnucleus_clipboard_linux.so") + .asFile + .exists() + enabled = Os.isFamily(Os.FAMILY_UNIX) && !Os.isFamily(Os.FAMILY_MAC) && !hasPrebuilt + + val nativeDir = layout.projectDirectory.dir("src/main/native/linux") + inputs.dir(nativeDir) + outputs.dir(nativeResourceDir) + workingDir(nativeDir) + commandLine("bash", "build.sh") +} + +tasks.processResources { + dependsOn(buildNativeLinux) +} + +tasks.configureEach { + if (name == "sourcesJar") { + dependsOn(buildNativeLinux) + } +} + +mavenPublishing { + coordinates("io.github.kdroidfilter", "nucleus.clipboard-linux", publishVersion) + + pom { + name.set("Nucleus Clipboard Linux") + description.set( + "Linux clipboard backend for Nucleus — XCB + XFixes on X11, " + + "wl-clipboard delegation on Wayland.", + ) + url.set("https://github.com/kdroidFilter/Nucleus") + + licenses { + license { + name.set("MIT License") + url.set("https://opensource.org/licenses/MIT") + } + } + + developers { + developer { + id.set("kdroidfilter") + name.set("kdroidFilter") + url.set("https://github.com/kdroidFilter") + } + } + + scm { + url.set("https://github.com/kdroidFilter/Nucleus") + connection.set("scm:git:git://github.com/kdroidFilter/Nucleus.git") + developerConnection.set("scm:git:ssh://git@github.com/kdroidFilter/Nucleus.git") + } + } + + publishToMavenCentral() + if (project.hasProperty("signingInMemoryKey")) { + signAllPublications() + } +} diff --git a/clipboard-linux/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/LinuxClipboardBackend.kt b/clipboard-linux/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/LinuxClipboardBackend.kt new file mode 100644 index 000000000..b9bd1392c --- /dev/null +++ b/clipboard-linux/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/LinuxClipboardBackend.kt @@ -0,0 +1,205 @@ +package io.github.kdroidfilter.nucleus.clipboard.linux + +import io.github.kdroidfilter.nucleus.clipboard.AccessBehavior +import io.github.kdroidfilter.nucleus.clipboard.ClipboardFormat +import io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardBackend +import io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardWritePayload +import io.github.kdroidfilter.nucleus.core.runtime.Platform +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.nio.file.Path +import java.nio.file.Paths + +/** + * Linux [ClipboardBackend] with a two-backend strategy: + * + * - **X11** — native XCB + XFixes via JNI (`libnucleus_clipboard_linux`). + * Supports the full format matrix (text, HTML, RTF, image/png, files) and + * delivers change events through XFixes selection notifications. + * - **Wayland** — delegates to `wl-copy` / `wl-paste` from the `wl-clipboard` + * project. This already handles the three protocol variants + * (`ext-data-control-v1`, `zwlr_data_control_v1`, and focus-stealing core) + * plus GNOME quirks that a native implementation would have to replicate. + * + * Selection heuristic: + * - Wayland (`WAYLAND_DISPLAY` or `XDG_SESSION_TYPE=wayland`) → Wayland delegate + * if `wl-copy`/`wl-paste` are on `PATH`, otherwise fall back to the X11 + * native path (Xwayland bridges CLIPBOARD for X11 clients). + * - X11 → native XCB backend. + */ +@Suppress("TooManyFunctions") +class LinuxClipboardBackend : ClipboardBackend { + private enum class Mode { X11, WAYLAND, UNAVAILABLE } + + private val wayland = WaylandClipboardDelegate() + + private val mode: Mode by lazy { resolveMode() } + + override val name: String + get() = + when (mode) { + Mode.X11 -> "Linux X11 (XCB+XFixes)" + Mode.WAYLAND -> "Linux Wayland (wl-clipboard)" + Mode.UNAVAILABLE -> "Linux unavailable" + } + + override fun isAvailable(): Boolean = mode != Mode.UNAVAILABLE + + private fun resolveMode(): Mode { + if (Platform.Current != Platform.Linux) return Mode.UNAVAILABLE + if (Platform.isWayland && wayland.isAvailable) { + wayland.startWatcher() + return Mode.WAYLAND + } + if (NativeX11ClipboardBridge.isLoaded && NativeX11ClipboardBridge.nativeInit()) { + return Mode.X11 + } + // Wayland session without wl-clipboard but with Xwayland available + // (rare on modern distros — wl-clipboard is almost always shipped). + return Mode.UNAVAILABLE + } + + // --- Reads --- + + override suspend fun readText(): String? = + withContext(Dispatchers.IO) { + when (mode) { + Mode.X11 -> NativeX11ClipboardBridge.nativeReadText() + Mode.WAYLAND -> wayland.readText() + Mode.UNAVAILABLE -> null + } + } + + override suspend fun readHtml(): String? = + withContext(Dispatchers.IO) { + when (mode) { + Mode.X11 -> NativeX11ClipboardBridge.nativeReadHtml() + Mode.WAYLAND -> wayland.readHtml() + Mode.UNAVAILABLE -> null + }?.stripBom() + } + + override suspend fun readRtf(): String? = + withContext(Dispatchers.IO) { + when (mode) { + Mode.X11 -> NativeX11ClipboardBridge.nativeReadRtf() + Mode.WAYLAND -> wayland.readRtf() + Mode.UNAVAILABLE -> null + } + } + + override suspend fun readImagePng(): ByteArray? = + withContext(Dispatchers.IO) { + when (mode) { + Mode.X11 -> NativeX11ClipboardBridge.nativeReadImagePng() + Mode.WAYLAND -> wayland.readImagePng() + Mode.UNAVAILABLE -> null + } + } + + override suspend fun readFiles(): List = + withContext(Dispatchers.IO) { + when (mode) { + Mode.X11 -> { + val entries = NativeX11ClipboardBridge.nativeReadFilePaths() + // First entry carries the copy/cut marker ("c" or "m"); we + // currently don't expose cut-vs-copy in the public API so + // it is discarded. + entries + .drop(1) + .map(Paths::get) + } + Mode.WAYLAND -> wayland.readFiles() + Mode.UNAVAILABLE -> emptyList() + } + } + + override suspend fun availableFormats(): Set = + withContext(Dispatchers.IO) { + val targets: List = + when (mode) { + Mode.X11 -> NativeX11ClipboardBridge.nativeAvailableTargets().toList() + Mode.WAYLAND -> wayland.listTypes() + Mode.UNAVAILABLE -> emptyList() + } + targets.toClipboardFormats() + } + + // --- Writes --- + + override suspend fun write(payload: ClipboardWritePayload): Boolean = + withContext(Dispatchers.IO) { + when (mode) { + Mode.X11 -> { + val paths = payload.files?.map { it.toAbsolutePath().toString() }?.toTypedArray() + NativeX11ClipboardBridge.nativeWrite( + payload.text, + payload.html, + payload.rtf, + payload.imagePng, + paths, + false, + ) + } + Mode.WAYLAND -> + wayland.write( + text = payload.text, + html = payload.html, + rtf = payload.rtf, + imagePng = payload.imagePng, + files = payload.files, + ) + Mode.UNAVAILABLE -> false + } + } + + override suspend fun clear(): Boolean = + withContext(Dispatchers.IO) { + when (mode) { + Mode.X11 -> NativeX11ClipboardBridge.nativeClear() + Mode.WAYLAND -> wayland.clear() + Mode.UNAVAILABLE -> false + } + } + + override fun changeCount(): Long = + when (mode) { + Mode.X11 -> NativeX11ClipboardBridge.nativeChangeCount() + Mode.WAYLAND -> wayland.changeCount() + Mode.UNAVAILABLE -> 0L + } + + // Linux has no OS-level pasteboard privacy toggle — callers are unrestricted. + override fun setAccessBehavior(behavior: AccessBehavior) = Unit + + override fun accessBehavior(): AccessBehavior? = null + + override fun isAccessBehaviorSupported(): Boolean = false + + private fun String.stripBom(): String = + when { + isEmpty() -> this + this[0] == '\uFEFF' -> substring(1) + else -> this + } + + private fun List.toClipboardFormats(): Set = + buildSet { + for (t in this@toClipboardFormats) { + when { + t.equals("UTF8_STRING", ignoreCase = true) || + t.equals("STRING", ignoreCase = true) || + t.equals("TEXT", ignoreCase = true) || + t.equals("COMPOUND_TEXT", ignoreCase = true) || + t.startsWith("text/plain", ignoreCase = true) -> add(ClipboardFormat.Text) + t.equals("text/html", ignoreCase = true) -> add(ClipboardFormat.Html) + t.equals("text/rtf", ignoreCase = true) || + t.equals("application/rtf", ignoreCase = true) -> add(ClipboardFormat.Rtf) + t.startsWith("image/", ignoreCase = true) -> add(ClipboardFormat.Image) + t.equals("text/uri-list", ignoreCase = true) || + t.equals("x-special/gnome-copied-files", ignoreCase = true) || + t.equals("application/x-kde-cutselection", ignoreCase = true) -> add(ClipboardFormat.Files) + } + } + } +} diff --git a/clipboard-linux/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/NativeX11ClipboardBridge.kt b/clipboard-linux/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/NativeX11ClipboardBridge.kt new file mode 100644 index 000000000..d9b16b1ce --- /dev/null +++ b/clipboard-linux/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/NativeX11ClipboardBridge.kt @@ -0,0 +1,80 @@ +package io.github.kdroidfilter.nucleus.clipboard.linux + +import io.github.kdroidfilter.nucleus.core.runtime.NativeLibraryLoader + +private const val LIBRARY_NAME = "nucleus_clipboard_linux" + +/** + * JNI surface over XCB + XFixes for the X11 CLIPBOARD selection. + * + * The native layer opens its own XCB connection, runs a dedicated event thread, + * and owns a single hidden 1×1 InputOnly window used as both owner and requestor. + * All public entry points here are thread-safe; reads block the calling thread + * for up to 2 s while the XCB event thread services the `SelectionNotify` round + * trip. + */ +@Suppress("TooManyFunctions") +internal object NativeX11ClipboardBridge { + private val loaded = NativeLibraryLoader.load(LIBRARY_NAME, NativeX11ClipboardBridge::class.java) + + val isLoaded: Boolean get() = loaded + + /** + * Probes for `libxcb` + `libxcb-xfixes` via `dlopen` and initializes the + * hidden window + event thread. Returns `false` when either library is + * missing or `$DISPLAY` is unset. + */ + @JvmStatic + external fun nativeInit(): Boolean + + /** + * Monotonic counter bumped by the XFixes event handler whenever + * `CLIPBOARD` changes ownership. Cheap — no X round trip. + */ + @JvmStatic + external fun nativeChangeCount(): Long + + /** Target atoms advertised by the current CLIPBOARD owner, as MIME-ish strings. */ + @JvmStatic + external fun nativeAvailableTargets(): Array + + @JvmStatic + external fun nativeReadText(): String? + + @JvmStatic + external fun nativeReadHtml(): String? + + @JvmStatic + external fun nativeReadRtf(): String? + + @JvmStatic + external fun nativeReadImagePng(): ByteArray? + + /** + * Returns a single-char prefix (`"c"` for copy or `"m"` for move/cut) followed + * by absolute POSIX paths decoded from `x-special/gnome-copied-files` when + * available, or `text/uri-list` otherwise. Returns an empty array if no file + * list is present. + */ + @JvmStatic + external fun nativeReadFilePaths(): Array + + /** + * Atomic write — registers the set of offered targets in one shot and takes + * ownership of `CLIPBOARD`. The native side retains the payload for the + * lifetime of the selection. + */ + @JvmStatic + external fun nativeWrite( + text: String?, + html: String?, + rtf: String?, + imagePng: ByteArray?, + paths: Array?, + isCut: Boolean, + ): Boolean + + /** Releases ownership of `CLIPBOARD` (best-effort SAVE_TARGETS handoff). */ + @JvmStatic + external fun nativeClear(): Boolean +} diff --git a/clipboard-linux/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/WaylandClipboardDelegate.kt b/clipboard-linux/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/WaylandClipboardDelegate.kt new file mode 100644 index 000000000..b24710277 --- /dev/null +++ b/clipboard-linux/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/WaylandClipboardDelegate.kt @@ -0,0 +1,292 @@ +package io.github.kdroidfilter.nucleus.clipboard.linux + +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import java.net.URI +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import java.nio.file.Path +import java.nio.file.Paths +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong + +private const val PROCESS_WRITE_TIMEOUT_SECONDS: Long = 3L +private const val STREAM_DRAIN_MS: Long = 200L + +/** + * Wayland backend implemented by delegating to `wl-copy` and `wl-paste` from the + * `wl-clipboard` project. These binaries already cover the three protocol + * variants (`ext-data-control-v1`, `zwlr_data_control_v1`, focus-stealing core) + * and the GNOME-specific fallbacks, so we ride on top of them rather than + * re-implementing the protocol state machines. + * + * `wl-paste --watch` drives the change counter in [startWatcher]. + */ +@Suppress("TooManyFunctions") +internal class WaylandClipboardDelegate { + private val wlCopy: String? = findBinary("wl-copy") + private val wlPaste: String? = findBinary("wl-paste") + + private val counter = AtomicLong(0L) + + @Volatile + private var watcher: Thread? = null + + val isAvailable: Boolean get() = wlCopy != null && wlPaste != null + + fun changeCount(): Long = counter.get() + + fun bumpCounter() { + counter.incrementAndGet() + } + + fun startWatcher() { + val paste = wlPaste ?: return + synchronized(this) { + if (watcher != null) return + val t = + Thread({ runWatcher(paste) }, "nucleus-wl-clipboard-watch").apply { + isDaemon = true + } + watcher = t + t.start() + } + } + + private fun runWatcher(paste: String) { + // `wl-paste --watch CMD` spawns CMD on every selection change. We use + // /bin/echo to emit a known marker line; parsing stdout is enough to + // drive the change counter without a second roundtrip. + val cmd = listOf(paste, "--watch", "echo", "CLIP_CHANGED") + try { + val pb = ProcessBuilder(cmd).redirectErrorStream(true) + val p = pb.start() + p.inputStream.bufferedReader().use { r -> + while (!Thread.currentThread().isInterrupted) { + val line = r.readLine() ?: break + if (line.contains("CLIP_CHANGED")) counter.incrementAndGet() + } + } + p.destroy() + } catch (_: IOException) { + // wl-paste unavailable — counter stays stuck, watcher silently stops. + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + + // --- Reads --- + + fun listTypes(): List { + val paste = wlPaste ?: return emptyList() + val out = runCaptureText(listOf(paste, "--list-types"), timeoutMs = 1500) ?: return emptyList() + return out + .lineSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .toList() + } + + fun readText(): String? { + val paste = wlPaste ?: return null + return runCaptureText(listOf(paste, "-n", "-t", "text/plain;charset=utf-8"), timeoutMs = 2000) + ?: runCaptureText(listOf(paste, "-n", "-t", "text/plain"), timeoutMs = 2000) + ?: runCaptureText(listOf(paste, "-n", "-t", "UTF8_STRING"), timeoutMs = 2000) + } + + fun readHtml(): String? { + val paste = wlPaste ?: return null + return runCaptureText(listOf(paste, "-n", "-t", "text/html"), timeoutMs = 2000) + } + + fun readRtf(): String? { + val paste = wlPaste ?: return null + return runCaptureText(listOf(paste, "-n", "-t", "text/rtf"), timeoutMs = 2000) + ?: runCaptureText(listOf(paste, "-n", "-t", "application/rtf"), timeoutMs = 2000) + } + + fun readImagePng(): ByteArray? { + val paste = wlPaste ?: return null + return runCaptureBytes(listOf(paste, "-n", "-t", "image/png"), timeoutMs = 3000) + } + + fun readFiles(): List { + val paste = wlPaste ?: return emptyList() + val gnome = + runCaptureText(listOf(paste, "-n", "-t", "x-special/gnome-copied-files"), timeoutMs = 1500) + if (gnome != null) { + val lines = gnome.split('\n').map { it.trim() }.filter { it.isNotEmpty() } + // First line is "copy" or "cut"; remaining lines are file:// URIs. + val uris = if (lines.isNotEmpty() && (lines[0] == "copy" || lines[0] == "cut")) lines.drop(1) else lines + return uris.mapNotNull(::uriToPath) + } + val uriList = runCaptureText(listOf(paste, "-n", "-t", "text/uri-list"), timeoutMs = 1500) + if (uriList != null) { + return uriList + .split('\n') + .map { it.trim() } + .filter { it.isNotEmpty() && !it.startsWith("#") } + .mapNotNull(::uriToPath) + } + return emptyList() + } + + // --- Writes --- + + fun write( + text: String?, + html: String?, + rtf: String?, + imagePng: ByteArray?, + files: List?, + ): Boolean { + val copy = wlCopy ?: return false + // wl-copy can only publish one MIME at a time. When multiple + // representations are supplied we pick the richest; callers who + // need true multi-format atomic writes on Wayland should run the + // X11 backend under Xwayland or wait for a native ext-data-control + // implementation. + return when { + !files.isNullOrEmpty() -> writeFiles(copy, files) + imagePng != null -> writeBytes(copy, "image/png", imagePng) + html != null -> writeBytes(copy, "text/html", html.toByteArray(StandardCharsets.UTF_8)) + rtf != null -> writeBytes(copy, "text/rtf", rtf.toByteArray(StandardCharsets.UTF_8)) + text != null -> writeBytes(copy, "text/plain;charset=utf-8", text.toByteArray(StandardCharsets.UTF_8)) + else -> false + }.also { if (it) counter.incrementAndGet() } + } + + fun clear(): Boolean { + val copy = wlCopy ?: return false + val ok = + runSilently(listOf(copy, "--clear"), timeoutMs = 1500) || + runSilently(listOf(copy, "-c"), timeoutMs = 1500) + if (ok) counter.incrementAndGet() + return ok + } + + private fun writeBytes( + copy: String, + mime: String, + bytes: ByteArray, + ): Boolean { + val cmd = listOf(copy, "-t", mime) + return try { + val pb = ProcessBuilder(cmd).redirectErrorStream(true) + val p = pb.start() + p.outputStream.use { it.write(bytes) } + p.waitFor(PROCESS_WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + if (p.isAlive) { + p.destroy() + false + } else { + p.exitValue() == 0 + } + } catch (_: IOException) { + false + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + false + } + } + + private fun writeFiles( + copy: String, + paths: List, + ): Boolean { + val uriList = paths.joinToString(separator = "\r\n") { pathToFileUri(it) } + if (!writeBytes(copy, "text/uri-list", uriList.toByteArray(StandardCharsets.UTF_8))) return false + // Best-effort GNOME side-channel so Nautilus shows "paste" instead of plain text. + val gnomeBody = + buildString { + append("copy\n") + append(paths.joinToString(separator = "\n") { pathToFileUri(it) }) + } + writeBytes(copy, "x-special/gnome-copied-files", gnomeBody.toByteArray(StandardCharsets.UTF_8)) + return true + } + + // --- Helpers --- + + private fun runCaptureBytes( + cmd: List, + timeoutMs: Long, + ): ByteArray? { + return try { + val pb = ProcessBuilder(cmd).redirectErrorStream(false) + val p = pb.start() + val buf = ByteArrayOutputStream() + val t = + Thread { + p.inputStream.use { it.copyTo(buf) } + }.apply { + isDaemon = true + start() + } + if (!p.waitFor(timeoutMs, TimeUnit.MILLISECONDS)) { + p.destroy() + t.join(STREAM_DRAIN_MS) + return null + } + t.join(STREAM_DRAIN_MS) + if (p.exitValue() != 0) return null + val out = buf.toByteArray() + if (out.isEmpty()) null else out + } catch (_: IOException) { + null + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + null + } + } + + private fun runCaptureText( + cmd: List, + timeoutMs: Long, + ): String? = runCaptureBytes(cmd, timeoutMs)?.toString(StandardCharsets.UTF_8) + + private fun runSilently( + cmd: List, + timeoutMs: Long, + ): Boolean = + try { + val p = ProcessBuilder(cmd).redirectErrorStream(true).start() + if (!p.waitFor(timeoutMs, TimeUnit.MILLISECONDS)) { + p.destroy() + false + } else { + p.exitValue() == 0 + } + } catch (_: IOException) { + false + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + false + } + + private fun findBinary(name: String): String? { + val envPath = System.getenv("PATH") ?: return null + for (dir in envPath.split(File.pathSeparatorChar)) { + if (dir.isEmpty()) continue + val candidate = File(dir, name) + if (candidate.canExecute()) return candidate.absolutePath + } + return null + } + + private fun pathToFileUri(path: Path): String = path.toAbsolutePath().toUri().toString() + + private fun uriToPath(uri: String): Path? = + try { + val parsed = URI(uri) + if (parsed.scheme == "file") { + Paths.get(parsed) + } else { + // Some clients emit bare paths in text/uri-list (non-conformant). + Paths.get(URLDecoder.decode(uri, StandardCharsets.UTF_8)) + } + } catch (_: Exception) { + null + } +} diff --git a/clipboard-linux/src/main/native/linux/build.sh b/clipboard-linux/src/main/native/linux/build.sh new file mode 100755 index 000000000..e3f2e460c --- /dev/null +++ b/clipboard-linux/src/main/native/linux/build.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# Compiles nucleus_clipboard_linux.c into a shared library for the current architecture. +# +# The library uses dlopen at runtime to bind libxcb + libxcb-xfixes, so there +# are no link-time X11/XCB dependencies — the .so loads cleanly on headless +# hosts and falls back to the Wayland delegate automatically. +# +# Prerequisites: gcc, JDK (for jni.h). +# Usage: ./build.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SRC="$SCRIPT_DIR/nucleus_clipboard_linux.c" +RESOURCE_DIR="$SCRIPT_DIR/../../resources/nucleus/native" + +# Detect architecture +ARCH=$(uname -m) +case "$ARCH" in + x86_64) RESOURCE_ARCH="linux-x64" ;; + aarch64) RESOURCE_ARCH="linux-aarch64" ;; + *) echo "ERROR: Unsupported architecture: $ARCH" >&2; exit 1 ;; +esac + +OUT_DIR="$RESOURCE_DIR/$RESOURCE_ARCH" + +# Detect JAVA_HOME for JNI headers +if [ -z "${JAVA_HOME:-}" ]; then + for candidate in /usr/lib/jvm/java-21-openjdk-amd64 /usr/lib/jvm/java-21-openjdk-arm64 \ + /usr/lib/jvm/java-17-openjdk-amd64 /usr/lib/jvm/java-17-openjdk-arm64 \ + /usr/lib/jvm/java /usr/lib/jvm/default-java; do + if [ -d "$candidate/include" ]; then + JAVA_HOME="$candidate" + break + fi + done +fi +if [ -z "${JAVA_HOME:-}" ]; then + echo "ERROR: JAVA_HOME not set and no JDK found in common locations." >&2 + exit 1 +fi + +JNI_INCLUDE="$JAVA_HOME/include" +JNI_INCLUDE_LINUX="$JAVA_HOME/include/linux" + +if [ ! -d "$JNI_INCLUDE" ]; then + echo "ERROR: JNI headers not found at $JNI_INCLUDE" >&2 + exit 1 +fi + +mkdir -p "$OUT_DIR" + +echo "Compiling for $ARCH ($RESOURCE_ARCH)..." + +gcc -shared -fPIC \ + -I"$JNI_INCLUDE" -I"$JNI_INCLUDE_LINUX" \ + -O2 \ + -fvisibility=hidden \ + -ffunction-sections \ + -fdata-sections \ + -Wl,--gc-sections \ + -Wl,-s \ + -pthread \ + -ldl \ + -o "$OUT_DIR/libnucleus_clipboard_linux.so" \ + "$SRC" + +echo "Built Linux shared library:" +ls -lh "$OUT_DIR/libnucleus_clipboard_linux.so" + +# Clear NativeLibraryLoader cache so the new .so is picked up at next run +CACHE_BASE="${XDG_CACHE_HOME:-$HOME/.cache}/nucleus/native" +CACHED="$CACHE_BASE/$RESOURCE_ARCH/libnucleus_clipboard_linux.so" +if [ -f "$CACHED" ]; then + rm -f "$CACHED" + echo "Cleared cache: $CACHED" +fi diff --git a/clipboard-linux/src/main/native/linux/nucleus_clipboard_linux.c b/clipboard-linux/src/main/native/linux/nucleus_clipboard_linux.c new file mode 100644 index 000000000..30da1f251 --- /dev/null +++ b/clipboard-linux/src/main/native/linux/nucleus_clipboard_linux.c @@ -0,0 +1,1279 @@ +/* + * JNI bridge for the X11 CLIPBOARD selection via XCB + XFixes. + * + * XCB and XFixes are resolved lazily through dlopen at init time — the .so + * therefore loads cleanly on headless / Wayland-only hosts where the higher + * layer falls back to wl-clipboard. + * + * Design: + * - one xcb_connection_t shared between the JNI threads and a dedicated + * event thread (XCB guarantees thread-safe access); + * - one hidden 1x1 InputOnly window used both as selection owner and + * requestor; + * - owner-side writes publish a flat list of targets (single chunk, no + * INCR — BIG-REQUESTS gives ~16 MB which is plenty in practice); + * - requestor-side reads implement the INCR protocol so large payloads + * from Chromium/Firefox come through intact; + * - XFixes selection notifications bump a monotonic counter which the + * Kotlin watcher polls. + * + * Threading: + * The native side holds one global mutex. Payload and read-request state + * are protected by it; pthread condvars signal JNI threads when the event + * thread has observed the reply they are waiting for. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* --------------------------------------------------------------------- */ +/* XCB + XFixes types & constants (copied locally to avoid link-time dep) */ +/* --------------------------------------------------------------------- */ + +typedef uint32_t xcb_window_t; +typedef uint32_t xcb_atom_t; +typedef uint32_t xcb_timestamp_t; +typedef uint32_t xcb_colormap_t; +typedef uint32_t xcb_visualid_t; + +typedef struct xcb_connection_t xcb_connection_t; + +typedef struct { + unsigned int sequence; +} xcb_void_cookie_t; + +typedef struct { + unsigned int sequence; +} xcb_get_property_cookie_t; + +typedef struct { + unsigned int sequence; +} xcb_intern_atom_cookie_t; + +typedef struct { + uint8_t response_type; + uint8_t pad0; + uint16_t sequence; + uint32_t pad[7]; + uint32_t full_sequence; +} xcb_generic_event_t; + +typedef struct { + uint8_t response_type; + uint8_t error_code; + uint16_t sequence; + uint32_t resource_id; + uint16_t minor_code; + uint8_t major_code; + uint8_t pad0; + uint32_t pad[5]; + uint32_t full_sequence; +} xcb_generic_error_t; + +typedef struct { + uint8_t response_type; + uint8_t pad0; + uint16_t sequence; + uint32_t length; + xcb_atom_t type; + uint8_t format; + uint8_t pad1[3]; + uint32_t bytes_after; + uint32_t value_len; + uint8_t pad2[12]; +} xcb_get_property_reply_t; + +typedef struct { + uint8_t response_type; + uint8_t pad0; + uint16_t sequence; + uint32_t length; + xcb_atom_t atom; +} xcb_intern_atom_reply_t; + +typedef struct { + uint8_t response_type; + uint8_t pad0; + uint16_t sequence; + xcb_timestamp_t time; + xcb_window_t owner; + xcb_window_t requestor; + xcb_atom_t selection; + xcb_atom_t target; + xcb_atom_t property; +} xcb_selection_request_event_t; + +typedef struct { + uint8_t response_type; + uint8_t pad0; + uint16_t sequence; + xcb_timestamp_t time; + xcb_window_t requestor; + xcb_atom_t selection; + xcb_atom_t target; + xcb_atom_t property; +} xcb_selection_notify_event_t; + +typedef struct { + uint8_t response_type; + uint8_t pad0; + uint16_t sequence; + xcb_timestamp_t time; + xcb_window_t owner; + xcb_atom_t selection; +} xcb_selection_clear_event_t; + +typedef struct { + uint8_t response_type; + uint8_t pad0; + uint16_t sequence; + xcb_window_t window; + xcb_atom_t atom; + xcb_timestamp_t time; + uint8_t state; + uint8_t pad1[3]; +} xcb_property_notify_event_t; + +typedef struct { + uint8_t response_type; + uint8_t extension; + uint16_t sequence; + uint32_t length; + uint16_t event_type; + uint8_t pad0[2]; + xcb_window_t window; + xcb_atom_t selection; + xcb_timestamp_t timestamp; + xcb_timestamp_t selection_timestamp; + uint8_t pad1[8]; +} xcb_xfixes_selection_notify_event_t; + +typedef struct { + unsigned int sequence; +} xcb_query_extension_cookie_t; + +typedef struct { + uint8_t response_type; + uint8_t pad0; + uint16_t sequence; + uint32_t length; + uint8_t present; + uint8_t major_opcode; + uint8_t first_event; + uint8_t first_error; +} xcb_query_extension_reply_t; + +typedef struct { + uint32_t root; + uint32_t default_colormap; + uint32_t white_pixel; + uint32_t black_pixel; + uint32_t current_input_masks; + uint16_t width_in_pixels; + uint16_t height_in_pixels; + uint16_t width_in_millimeters; + uint16_t height_in_millimeters; + uint16_t min_installed_maps; + uint16_t max_installed_maps; + xcb_visualid_t root_visual; + uint8_t backing_stores; + uint8_t save_unders; + uint8_t root_depth; + uint8_t allowed_depths_len; +} xcb_screen_t; + +typedef struct { + const xcb_screen_t *data; + int rem; + int index; +} xcb_screen_iterator_t; + +typedef struct xcb_setup_t xcb_setup_t; + +#define XCB_COPY_FROM_PARENT 0L +#define XCB_WINDOW_CLASS_INPUT_ONLY 2 +#define XCB_CW_EVENT_MASK 2048 +#define XCB_EVENT_MASK_PROPERTY_CHANGE 4194304 +#define XCB_ATOM_NONE 0 +#define XCB_ATOM_ATOM 4 +#define XCB_ATOM_INTEGER 19 +#define XCB_ATOM_STRING 31 +#define XCB_CURRENT_TIME 0L +#define XCB_PROP_MODE_REPLACE 0 +#define XCB_PROP_MODE_APPEND 2 +#define XCB_SEND_EVENT_DEST_POINTER_WINDOW 0 +#define XCB_EVENT_MASK_NO_EVENT 0 + +#define XCB_PROPERTY_NOTIFY 28 +#define XCB_SELECTION_CLEAR 29 +#define XCB_SELECTION_REQUEST 30 +#define XCB_SELECTION_NOTIFY 31 + +#define XCB_XFIXES_SET_SELECTION_OWNER_NOTIFY_MASK 1 +#define XCB_XFIXES_SELECTION_WINDOW_DESTROY_NOTIFY_MASK 2 +#define XCB_XFIXES_SELECTION_CLIENT_CLOSE_NOTIFY_MASK 4 + +/* --------------------------------------------------------------------- */ +/* dlopen'd symbols */ +/* --------------------------------------------------------------------- */ + +static void *g_xcb_handle = NULL; +static void *g_xfixes_handle = NULL; + +static xcb_connection_t *(*p_xcb_connect)(const char *, int *); +static int (*p_xcb_connection_has_error)(xcb_connection_t *); +static int (*p_xcb_disconnect_set)(xcb_connection_t *); /* alias */ +static void (*p_xcb_disconnect)(xcb_connection_t *); +static const xcb_setup_t *(*p_xcb_get_setup)(xcb_connection_t *); +static xcb_screen_iterator_t (*p_xcb_setup_roots_iterator)(const xcb_setup_t *); +static uint32_t (*p_xcb_generate_id)(xcb_connection_t *); +static int (*p_xcb_flush)(xcb_connection_t *); +static xcb_generic_event_t *(*p_xcb_wait_for_event)(xcb_connection_t *); +static xcb_generic_event_t *(*p_xcb_poll_for_event)(xcb_connection_t *); +static int (*p_xcb_get_file_descriptor)(xcb_connection_t *); + +static xcb_void_cookie_t (*p_xcb_create_window)( + xcb_connection_t *, uint8_t, xcb_window_t, xcb_window_t, + int16_t, int16_t, uint16_t, uint16_t, uint16_t, uint16_t, + xcb_visualid_t, uint32_t, const void *); +static xcb_void_cookie_t (*p_xcb_destroy_window)(xcb_connection_t *, xcb_window_t); + +static xcb_intern_atom_cookie_t (*p_xcb_intern_atom)(xcb_connection_t *, uint8_t, uint16_t, const char *); +static xcb_intern_atom_reply_t *(*p_xcb_intern_atom_reply)( + xcb_connection_t *, xcb_intern_atom_cookie_t, xcb_generic_error_t **); + +static xcb_void_cookie_t (*p_xcb_change_property)( + xcb_connection_t *, uint8_t, xcb_window_t, xcb_atom_t, xcb_atom_t, + uint8_t, uint32_t, const void *); +static xcb_void_cookie_t (*p_xcb_delete_property)(xcb_connection_t *, xcb_window_t, xcb_atom_t); + +static xcb_get_property_cookie_t (*p_xcb_get_property)( + xcb_connection_t *, uint8_t, xcb_window_t, xcb_atom_t, xcb_atom_t, + uint32_t, uint32_t); +static xcb_get_property_reply_t *(*p_xcb_get_property_reply)( + xcb_connection_t *, xcb_get_property_cookie_t, xcb_generic_error_t **); +static void *(*p_xcb_get_property_value)(const xcb_get_property_reply_t *); +static int (*p_xcb_get_property_value_length)(const xcb_get_property_reply_t *); + +static xcb_void_cookie_t (*p_xcb_set_selection_owner)( + xcb_connection_t *, xcb_window_t, xcb_atom_t, xcb_timestamp_t); +static xcb_void_cookie_t (*p_xcb_convert_selection)( + xcb_connection_t *, xcb_window_t, xcb_atom_t, xcb_atom_t, xcb_atom_t, xcb_timestamp_t); + +static xcb_void_cookie_t (*p_xcb_send_event)( + xcb_connection_t *, uint8_t, xcb_window_t, uint32_t, const char *); + +static xcb_query_extension_cookie_t (*p_xcb_query_extension)( + xcb_connection_t *, uint16_t, const char *); +static xcb_query_extension_reply_t *(*p_xcb_query_extension_reply)( + xcb_connection_t *, xcb_query_extension_cookie_t, xcb_generic_error_t **); + +/* XFixes */ +static xcb_void_cookie_t (*p_xcb_xfixes_query_version)(xcb_connection_t *, uint32_t, uint32_t); +static xcb_void_cookie_t (*p_xcb_xfixes_select_selection_input_checked)( + xcb_connection_t *, xcb_window_t, xcb_atom_t, uint32_t); + +/* --------------------------------------------------------------------- */ +/* Globals */ +/* --------------------------------------------------------------------- */ + +#define PROP_NAME "NUCLEUS_CLIPBOARD_PROP" + +typedef enum { + OFFER_TEXT = 0, + OFFER_HTML, + OFFER_RTF, + OFFER_IMAGE_PNG, + OFFER_URI_LIST, + OFFER_GNOME_URIS, + OFFER_KDE_CUT, + OFFER__COUNT +} offer_kind_t; + +static bool g_inited = false; +static xcb_connection_t *g_conn = NULL; +static xcb_window_t g_window = 0; +static xcb_screen_t *g_screen = NULL; + +static uint8_t g_xfixes_first_event = 0; + +/* Interned atoms. */ +static xcb_atom_t a_CLIPBOARD, a_TARGETS, a_MULTIPLE, a_TIMESTAMP, a_SAVE_TARGETS, + a_INCR, a_ATOM_PAIR, a_UTF8_STRING, a_STRING, a_TEXT, a_COMPOUND_TEXT, + a_text_plain, a_text_plain_utf8, a_text_html, a_text_rtf, a_application_rtf, + a_image_png, a_text_uri_list, a_x_special_gnome_copied_files, + a_application_x_kde_cutselection, a_clipboard_manager, a_prop; + +/* Mutex protecting everything below. */ +static pthread_mutex_t g_mutex = PTHREAD_MUTEX_INITIALIZER; + +/* Owner-side payload. Bytes are owned by us, freed on replacement / clear. */ +typedef struct { + uint8_t *data; + size_t len; + bool set; +} payload_t; + +static payload_t g_offers[OFFER__COUNT]; +static bool g_offer_is_cut = false; +static xcb_timestamp_t g_own_ts = 0; + +/* Requestor-side pending read. */ +typedef struct { + bool waiting; /* JNI thread is waiting */ + bool done; /* event thread marked ready */ + xcb_atom_t target; /* which target we requested */ + + bool incr; /* current read is an INCR transfer */ + bool incr_chunk_ready; /* event thread saw PropertyNotify=NewValue */ + bool incr_done; /* zero-length chunk seen */ + + uint8_t *buf; + size_t len; + size_t cap; +} pending_read_t; + +static pending_read_t g_read; +static pthread_cond_t g_read_cond = PTHREAD_COND_INITIALIZER; + +/* Change counter (XFixes). */ +static atomic_long g_change_count = 0; + +/* Event thread. */ +static pthread_t g_ev_thread; +static bool g_ev_running = false; + +/* --------------------------------------------------------------------- */ +/* dl helpers */ +/* --------------------------------------------------------------------- */ + +#define LOAD(handle, sym) \ + do { \ + *(void **)(&p_##sym) = dlsym((handle), #sym); \ + if (!p_##sym) { \ + fprintf(stderr, "nucleus_clipboard_linux: missing "#sym"\n"); \ + return false; \ + } \ + } while (0) + +static bool load_xcb_symbols(void) { + g_xcb_handle = dlopen("libxcb.so.1", RTLD_NOW | RTLD_GLOBAL); + if (!g_xcb_handle) return false; + + LOAD(g_xcb_handle, xcb_connect); + LOAD(g_xcb_handle, xcb_connection_has_error); + LOAD(g_xcb_handle, xcb_disconnect); + LOAD(g_xcb_handle, xcb_get_setup); + LOAD(g_xcb_handle, xcb_setup_roots_iterator); + LOAD(g_xcb_handle, xcb_generate_id); + LOAD(g_xcb_handle, xcb_flush); + LOAD(g_xcb_handle, xcb_wait_for_event); + LOAD(g_xcb_handle, xcb_poll_for_event); + LOAD(g_xcb_handle, xcb_get_file_descriptor); + LOAD(g_xcb_handle, xcb_create_window); + LOAD(g_xcb_handle, xcb_destroy_window); + LOAD(g_xcb_handle, xcb_intern_atom); + LOAD(g_xcb_handle, xcb_intern_atom_reply); + LOAD(g_xcb_handle, xcb_change_property); + LOAD(g_xcb_handle, xcb_delete_property); + LOAD(g_xcb_handle, xcb_get_property); + LOAD(g_xcb_handle, xcb_get_property_reply); + LOAD(g_xcb_handle, xcb_get_property_value); + LOAD(g_xcb_handle, xcb_get_property_value_length); + LOAD(g_xcb_handle, xcb_set_selection_owner); + LOAD(g_xcb_handle, xcb_convert_selection); + LOAD(g_xcb_handle, xcb_send_event); + LOAD(g_xcb_handle, xcb_query_extension); + LOAD(g_xcb_handle, xcb_query_extension_reply); + return true; +} + +static bool load_xfixes_symbols(void) { + g_xfixes_handle = dlopen("libxcb-xfixes.so.0", RTLD_NOW | RTLD_GLOBAL); + if (!g_xfixes_handle) return false; + LOAD(g_xfixes_handle, xcb_xfixes_query_version); + LOAD(g_xfixes_handle, xcb_xfixes_select_selection_input_checked); + return true; +} + +/* --------------------------------------------------------------------- */ +/* Atom interning */ +/* --------------------------------------------------------------------- */ + +static xcb_atom_t intern(const char *name, bool only_if_exists) { + xcb_intern_atom_cookie_t c = p_xcb_intern_atom(g_conn, only_if_exists ? 1 : 0, + (uint16_t)strlen(name), name); + xcb_intern_atom_reply_t *r = p_xcb_intern_atom_reply(g_conn, c, NULL); + if (!r) return XCB_ATOM_NONE; + xcb_atom_t a = r->atom; + free(r); + return a; +} + +static void intern_all_atoms(void) { + a_CLIPBOARD = intern("CLIPBOARD", false); + a_TARGETS = intern("TARGETS", false); + a_MULTIPLE = intern("MULTIPLE", false); + a_TIMESTAMP = intern("TIMESTAMP", false); + a_SAVE_TARGETS = intern("SAVE_TARGETS", false); + a_INCR = intern("INCR", false); + a_ATOM_PAIR = intern("ATOM_PAIR", false); + a_UTF8_STRING = intern("UTF8_STRING", false); + a_STRING = intern("STRING", false); + a_TEXT = intern("TEXT", false); + a_COMPOUND_TEXT = intern("COMPOUND_TEXT", false); + a_text_plain = intern("text/plain", false); + a_text_plain_utf8 = intern("text/plain;charset=utf-8", false); + a_text_html = intern("text/html", false); + a_text_rtf = intern("text/rtf", false); + a_application_rtf = intern("application/rtf", false); + a_image_png = intern("image/png", false); + a_text_uri_list = intern("text/uri-list", false); + a_x_special_gnome_copied_files = intern("x-special/gnome-copied-files", false); + a_application_x_kde_cutselection = intern("application/x-kde-cutselection", false); + a_clipboard_manager = intern("CLIPBOARD_MANAGER", false); + a_prop = intern(PROP_NAME, false); +} + +/* --------------------------------------------------------------------- */ +/* Owner-side: serve SelectionRequest */ +/* --------------------------------------------------------------------- */ + +static void free_offers_locked(void) { + for (int i = 0; i < OFFER__COUNT; i++) { + free(g_offers[i].data); + g_offers[i].data = NULL; + g_offers[i].len = 0; + g_offers[i].set = false; + } + g_offer_is_cut = false; +} + +/* Returns the offer index for a given target atom, or -1 if we don't serve it. */ +static int target_to_offer(xcb_atom_t target) { + if (target == a_UTF8_STRING || target == a_text_plain_utf8 || + target == a_STRING || target == a_TEXT || target == a_text_plain) return OFFER_TEXT; + if (target == a_text_html) return OFFER_HTML; + if (target == a_text_rtf || target == a_application_rtf) return OFFER_RTF; + if (target == a_image_png) return OFFER_IMAGE_PNG; + if (target == a_text_uri_list) return OFFER_URI_LIST; + if (target == a_x_special_gnome_copied_files) return OFFER_GNOME_URIS; + if (target == a_application_x_kde_cutselection) return OFFER_KDE_CUT; + return -1; +} + +/* Build the TARGETS atom list from currently offered formats. */ +static size_t build_targets_list(xcb_atom_t *out, size_t cap) { + size_t n = 0; +#define ADD(a) do { if (n < cap) out[n++] = (a); } while (0) + ADD(a_TARGETS); + ADD(a_TIMESTAMP); + ADD(a_MULTIPLE); + ADD(a_SAVE_TARGETS); + if (g_offers[OFFER_TEXT].set) { + ADD(a_UTF8_STRING); + ADD(a_text_plain_utf8); + ADD(a_STRING); + ADD(a_TEXT); + ADD(a_text_plain); + } + if (g_offers[OFFER_HTML].set) ADD(a_text_html); + if (g_offers[OFFER_RTF].set) { ADD(a_text_rtf); ADD(a_application_rtf); } + if (g_offers[OFFER_IMAGE_PNG].set) ADD(a_image_png); + if (g_offers[OFFER_URI_LIST].set) ADD(a_text_uri_list); + if (g_offers[OFFER_GNOME_URIS].set) ADD(a_x_special_gnome_copied_files); + if (g_offers[OFFER_KDE_CUT].set) ADD(a_application_x_kde_cutselection); +#undef ADD + return n; +} + +static void send_selection_notify(const xcb_selection_request_event_t *req, xcb_atom_t property) { + xcb_selection_notify_event_t ev; + memset(&ev, 0, sizeof(ev)); + ev.response_type = XCB_SELECTION_NOTIFY; + ev.time = req->time; + ev.requestor = req->requestor; + ev.selection = req->selection; + ev.target = req->target; + ev.property = property; + p_xcb_send_event(g_conn, 0, req->requestor, XCB_EVENT_MASK_NO_EVENT, (const char *)&ev); + p_xcb_flush(g_conn); +} + +static void write_property(xcb_window_t win, xcb_atom_t prop, xcb_atom_t type, + uint8_t format, size_t count, const void *data) { + p_xcb_change_property(g_conn, XCB_PROP_MODE_REPLACE, win, prop, type, + format, (uint32_t)count, data); +} + +static void handle_selection_request_locked(const xcb_selection_request_event_t *req) { + xcb_atom_t prop = req->property != XCB_ATOM_NONE ? req->property : req->target; + + if (req->target == a_TARGETS) { + xcb_atom_t buf[24]; + size_t n = build_targets_list(buf, 24); + write_property(req->requestor, prop, XCB_ATOM_ATOM, 32, n, buf); + xcb_selection_notify_event_t ev = {0}; + ev.response_type = XCB_SELECTION_NOTIFY; + ev.time = req->time; ev.requestor = req->requestor; + ev.selection = req->selection; ev.target = req->target; ev.property = prop; + p_xcb_send_event(g_conn, 0, req->requestor, 0, (const char *)&ev); + p_xcb_flush(g_conn); + return; + } + + if (req->target == a_TIMESTAMP) { + uint32_t ts = (uint32_t)g_own_ts; + write_property(req->requestor, prop, XCB_ATOM_INTEGER, 32, 1, &ts); + send_selection_notify(req, prop); + return; + } + + if (req->target == a_SAVE_TARGETS) { + /* Acknowledge but do nothing — we have already published everything. */ + send_selection_notify(req, prop); + return; + } + + int idx = target_to_offer(req->target); + if (idx < 0 || !g_offers[idx].set) { + /* Refuse: property=None. */ + send_selection_notify(req, XCB_ATOM_NONE); + return; + } + + const payload_t *pl = &g_offers[idx]; + /* Single-chunk write. xcb_change_property's length is in units of format/8; + * BIG-REQUESTS lifts the request size to ~16 MB which is sufficient for + * V1. Larger payloads would require owner-side INCR which is not yet + * implemented. */ + write_property(req->requestor, prop, req->target, 8, pl->len, pl->data); + send_selection_notify(req, prop); +} + +/* --------------------------------------------------------------------- */ +/* Requestor-side: reads */ +/* --------------------------------------------------------------------- */ + +static void read_reset_locked(void) { + free(g_read.buf); + memset(&g_read, 0, sizeof(g_read)); +} + +static bool read_append(const void *data, size_t n) { + if (g_read.len + n > g_read.cap) { + size_t nc = g_read.cap ? g_read.cap * 2 : 4096; + while (nc < g_read.len + n) nc *= 2; + uint8_t *nb = realloc(g_read.buf, nc); + if (!nb) return false; + g_read.buf = nb; + g_read.cap = nc; + } + memcpy(g_read.buf + g_read.len, data, n); + g_read.len += n; + return true; +} + +/* Fetch and consume the window property, returning malloc'd bytes (or NULL). + * On INCR start, sets *out_incr = true and returns NULL. */ +static uint8_t *fetch_property(xcb_atom_t prop, bool *out_incr, size_t *out_len) { + *out_incr = false; + *out_len = 0; + xcb_get_property_cookie_t c = + p_xcb_get_property(g_conn, 0, g_window, prop, 0 /* AnyType */, 0, 0x1FFFFFFF); + xcb_get_property_reply_t *r = p_xcb_get_property_reply(g_conn, c, NULL); + if (!r) return NULL; + + if (r->type == a_INCR) { + *out_incr = true; + free(r); + p_xcb_delete_property(g_conn, g_window, prop); + p_xcb_flush(g_conn); + return NULL; + } + + size_t n = (size_t)p_xcb_get_property_value_length(r); + uint8_t *out = NULL; + if (n > 0) { + out = malloc(n); + if (out) memcpy(out, p_xcb_get_property_value(r), n); + } + *out_len = n; + free(r); + p_xcb_delete_property(g_conn, g_window, prop); + p_xcb_flush(g_conn); + return out; +} + +/* Deadline helper. */ +static void add_ms(struct timespec *ts, long ms) { + ts->tv_sec += ms / 1000; + ts->tv_nsec += (ms % 1000) * 1000000L; + if (ts->tv_nsec >= 1000000000L) { ts->tv_sec++; ts->tv_nsec -= 1000000000L; } +} + +/* Perform a full read for the given target (atom). Returns malloc'd bytes or NULL. */ +static uint8_t *read_selection(xcb_atom_t target, size_t *out_len) { + *out_len = 0; + pthread_mutex_lock(&g_mutex); + + if (g_read.waiting) { + /* Another reader is active; serialise. */ + pthread_mutex_unlock(&g_mutex); + return NULL; + } + + read_reset_locked(); + g_read.waiting = true; + g_read.target = target; + + p_xcb_delete_property(g_conn, g_window, a_prop); + p_xcb_convert_selection(g_conn, g_window, a_CLIPBOARD, target, a_prop, XCB_CURRENT_TIME); + p_xcb_flush(g_conn); + + struct timespec deadline; + clock_gettime(CLOCK_REALTIME, &deadline); + add_ms(&deadline, 2000); + + while (!g_read.done) { + int rc = pthread_cond_timedwait(&g_read_cond, &g_mutex, &deadline); + if (rc == ETIMEDOUT) { + g_read.waiting = false; + read_reset_locked(); + pthread_mutex_unlock(&g_mutex); + return NULL; + } + } + + if (g_read.incr) { + /* Collect chunks until we see a zero-length terminator. */ + struct timespec incr_deadline; + clock_gettime(CLOCK_REALTIME, &incr_deadline); + add_ms(&incr_deadline, 10000); + + while (!g_read.incr_done) { + int rc = pthread_cond_timedwait(&g_read_cond, &g_mutex, &incr_deadline); + if (rc == ETIMEDOUT) { + g_read.waiting = false; + read_reset_locked(); + pthread_mutex_unlock(&g_mutex); + return NULL; + } + } + } + + uint8_t *out = g_read.buf; + size_t len = g_read.len; + g_read.buf = NULL; + g_read.len = 0; + g_read.cap = 0; + g_read.waiting = false; + g_read.done = false; + g_read.incr = false; + g_read.incr_chunk_ready = false; + g_read.incr_done = false; + + pthread_mutex_unlock(&g_mutex); + *out_len = len; + return out; +} + +/* --------------------------------------------------------------------- */ +/* Event thread */ +/* --------------------------------------------------------------------- */ + +static void on_selection_notify_locked(const xcb_selection_notify_event_t *ev) { + if (!g_read.waiting || g_read.done) return; + if (ev->property == XCB_ATOM_NONE) { + /* Selection refused: return empty. */ + g_read.done = true; + pthread_cond_broadcast(&g_read_cond); + return; + } + if (ev->target != g_read.target) return; + + bool is_incr = false; size_t n = 0; + uint8_t *bytes = fetch_property(ev->property, &is_incr, &n); + if (is_incr) { + g_read.incr = true; + g_read.done = true; + pthread_cond_broadcast(&g_read_cond); + return; + } + if (bytes && n > 0) { + read_append(bytes, n); + } + free(bytes); + g_read.done = true; + pthread_cond_broadcast(&g_read_cond); +} + +static void on_property_notify_locked(const xcb_property_notify_event_t *ev) { + /* state 0 = NewValue, 1 = Delete. We only care about NewValue on our + * own property during INCR. */ + if (!g_read.waiting || !g_read.incr || g_read.incr_done) return; + if (ev->window != g_window || ev->atom != a_prop || ev->state != 0) return; + + bool is_incr = false; size_t n = 0; + uint8_t *bytes = fetch_property(a_prop, &is_incr, &n); + if (n == 0) { + g_read.incr_done = true; + pthread_cond_broadcast(&g_read_cond); + } else if (bytes) { + read_append(bytes, n); + } + free(bytes); +} + +static void on_selection_request(const xcb_selection_request_event_t *ev) { + pthread_mutex_lock(&g_mutex); + handle_selection_request_locked(ev); + pthread_mutex_unlock(&g_mutex); +} + +static void on_selection_clear(const xcb_selection_clear_event_t *ev) { + (void)ev; + pthread_mutex_lock(&g_mutex); + free_offers_locked(); + pthread_mutex_unlock(&g_mutex); +} + +static void *event_thread_main(void *arg) { + (void)arg; + while (g_ev_running) { + xcb_generic_event_t *ev = p_xcb_wait_for_event(g_conn); + if (!ev) break; + uint8_t rt = ev->response_type & ~0x80; + + if (rt == g_xfixes_first_event) { + /* Any XFixes event we registered for on CLIPBOARD = change. */ + atomic_fetch_add(&g_change_count, 1); + } else { + switch (rt) { + case XCB_SELECTION_REQUEST: + on_selection_request((xcb_selection_request_event_t *)ev); + break; + case XCB_SELECTION_CLEAR: + on_selection_clear((xcb_selection_clear_event_t *)ev); + break; + case XCB_SELECTION_NOTIFY: + pthread_mutex_lock(&g_mutex); + on_selection_notify_locked((xcb_selection_notify_event_t *)ev); + pthread_mutex_unlock(&g_mutex); + break; + case XCB_PROPERTY_NOTIFY: + pthread_mutex_lock(&g_mutex); + on_property_notify_locked((xcb_property_notify_event_t *)ev); + pthread_mutex_unlock(&g_mutex); + break; + default: + break; + } + } + free(ev); + } + return NULL; +} + +/* --------------------------------------------------------------------- */ +/* Init / teardown */ +/* --------------------------------------------------------------------- */ + +static bool setup_xfixes(void) { + xcb_query_extension_cookie_t c = p_xcb_query_extension(g_conn, 6, "XFIXES"); + xcb_query_extension_reply_t *r = p_xcb_query_extension_reply(g_conn, c, NULL); + if (!r) return false; + bool present = r->present != 0; + g_xfixes_first_event = r->first_event; + free(r); + if (!present) return false; + + /* Negotiate at least version 1.0. */ + p_xcb_xfixes_query_version(g_conn, 5, 0); + + uint32_t mask = + XCB_XFIXES_SET_SELECTION_OWNER_NOTIFY_MASK | + XCB_XFIXES_SELECTION_WINDOW_DESTROY_NOTIFY_MASK | + XCB_XFIXES_SELECTION_CLIENT_CLOSE_NOTIFY_MASK; + p_xcb_xfixes_select_selection_input_checked(g_conn, g_window, a_CLIPBOARD, mask); + p_xcb_flush(g_conn); + return true; +} + +static bool do_init(void) { + if (g_inited) return true; + if (getenv("DISPLAY") == NULL) return false; + if (!load_xcb_symbols()) return false; + + int screen_num = 0; + g_conn = p_xcb_connect(NULL, &screen_num); + if (!g_conn || p_xcb_connection_has_error(g_conn)) { + if (g_conn) p_xcb_disconnect(g_conn); + g_conn = NULL; + return false; + } + + const xcb_setup_t *setup = p_xcb_get_setup(g_conn); + xcb_screen_iterator_t it = p_xcb_setup_roots_iterator(setup); + g_screen = (xcb_screen_t *)it.data; + if (!g_screen) return false; + + /* Create hidden 1x1 InputOnly window with PropertyChangeMask. */ + g_window = p_xcb_generate_id(g_conn); + uint32_t values[1] = { XCB_EVENT_MASK_PROPERTY_CHANGE }; + p_xcb_create_window(g_conn, + XCB_COPY_FROM_PARENT, + g_window, + g_screen->root, + 0, 0, 1, 1, 0, + XCB_WINDOW_CLASS_INPUT_ONLY, + XCB_COPY_FROM_PARENT, + XCB_CW_EVENT_MASK, + values); + + intern_all_atoms(); + + if (!load_xfixes_symbols()) { + /* Watcher disabled, but the rest still works. */ + g_xfixes_first_event = 0xFF; + } else { + setup_xfixes(); + } + + g_ev_running = true; + if (pthread_create(&g_ev_thread, NULL, event_thread_main, NULL) != 0) { + g_ev_running = false; + return false; + } + + g_inited = true; + return true; +} + +/* --------------------------------------------------------------------- */ +/* Writes */ +/* --------------------------------------------------------------------- */ + +static void set_offer_locked(offer_kind_t k, const void *bytes, size_t len) { + free(g_offers[k].data); + g_offers[k].data = malloc(len); + if (g_offers[k].data == NULL) { + g_offers[k].set = false; + g_offers[k].len = 0; + return; + } + memcpy(g_offers[k].data, bytes, len); + g_offers[k].len = len; + g_offers[k].set = true; +} + +/* JNI side provides UTF-8 encoded strings already; we just copy. */ +static void offer_string(offer_kind_t k, const char *utf8) { + if (!utf8) return; + set_offer_locked(k, utf8, strlen(utf8)); +} + +/* --------------------------------------------------------------------- */ +/* JNI */ +/* --------------------------------------------------------------------- */ + +JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) { + (void)vm; (void)reserved; + return JNI_VERSION_1_6; +} + +static const char *jstr_to_utf8(JNIEnv *env, jstring s, const char **release_ref) { + *release_ref = NULL; + if (!s) return NULL; + const char *p = (*env)->GetStringUTFChars(env, s, NULL); + *release_ref = p; + return p; +} + +static void jstr_release(JNIEnv *env, jstring s, const char *p) { + if (s && p) (*env)->ReleaseStringUTFChars(env, s, p); +} + +JNIEXPORT jboolean JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_linux_NativeX11ClipboardBridge_nativeInit( + JNIEnv *env, jclass cls) { + (void)env; (void)cls; + return do_init() ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT jlong JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_linux_NativeX11ClipboardBridge_nativeChangeCount( + JNIEnv *env, jclass cls) { + (void)env; (void)cls; + return (jlong)atomic_load(&g_change_count); +} + +/* Return available TARGETS on the current CLIPBOARD owner as a Java String[]. */ +JNIEXPORT jobjectArray JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_linux_NativeX11ClipboardBridge_nativeAvailableTargets( + JNIEnv *env, jclass cls) { + (void)cls; + if (!g_inited) { + jclass sc = (*env)->FindClass(env, "java/lang/String"); + return (*env)->NewObjectArray(env, 0, sc, NULL); + } + + size_t n = 0; + uint8_t *bytes = read_selection(a_TARGETS, &n); + size_t count = bytes ? (n / 4) : 0; + + jclass sc = (*env)->FindClass(env, "java/lang/String"); + jobjectArray out = (*env)->NewObjectArray(env, (jsize)count, sc, NULL); + + for (size_t i = 0; i < count; i++) { + xcb_atom_t a; + memcpy(&a, bytes + i * 4, 4); + /* Resolve atom -> name with get_atom_name via raw XCB. */ + /* Use xcb_get_atom_name for human names; to keep the surface small + * we match against our known set only. */ + const char *name = NULL; + if (a == a_UTF8_STRING) name = "UTF8_STRING"; + else if (a == a_STRING) name = "STRING"; + else if (a == a_TEXT) name = "TEXT"; + else if (a == a_COMPOUND_TEXT) name = "COMPOUND_TEXT"; + else if (a == a_text_plain) name = "text/plain"; + else if (a == a_text_plain_utf8) name = "text/plain;charset=utf-8"; + else if (a == a_text_html) name = "text/html"; + else if (a == a_text_rtf) name = "text/rtf"; + else if (a == a_application_rtf) name = "application/rtf"; + else if (a == a_image_png) name = "image/png"; + else if (a == a_text_uri_list) name = "text/uri-list"; + else if (a == a_x_special_gnome_copied_files) name = "x-special/gnome-copied-files"; + else if (a == a_application_x_kde_cutselection) name = "application/x-kde-cutselection"; + else name = NULL; + + if (name) { + jstring js = (*env)->NewStringUTF(env, name); + (*env)->SetObjectArrayElement(env, out, (jsize)i, js); + (*env)->DeleteLocalRef(env, js); + } + } + free(bytes); + return out; +} + +/* Reads a text target. Falls back through UTF8_STRING → text/plain;charset=utf-8. */ +static jstring read_text_target(JNIEnv *env, xcb_atom_t primary, xcb_atom_t fallback) { + size_t n = 0; + uint8_t *bytes = read_selection(primary, &n); + if ((!bytes || n == 0) && fallback != XCB_ATOM_NONE) { + free(bytes); + bytes = read_selection(fallback, &n); + } + if (!bytes || n == 0) { free(bytes); return NULL; } + + /* Strip trailing NULs and NUL-terminate for NewStringUTF. */ + while (n > 0 && bytes[n - 1] == '\0') n--; + char *z = malloc(n + 1); + if (!z) { free(bytes); return NULL; } + memcpy(z, bytes, n); + z[n] = '\0'; + free(bytes); + jstring out = (*env)->NewStringUTF(env, z); + free(z); + return out; +} + +JNIEXPORT jstring JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_linux_NativeX11ClipboardBridge_nativeReadText( + JNIEnv *env, jclass cls) { + (void)cls; + if (!g_inited) return NULL; + return read_text_target(env, a_UTF8_STRING, a_text_plain_utf8); +} + +JNIEXPORT jstring JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_linux_NativeX11ClipboardBridge_nativeReadHtml( + JNIEnv *env, jclass cls) { + (void)cls; + if (!g_inited) return NULL; + size_t n = 0; + uint8_t *bytes = read_selection(a_text_html, &n); + if (!bytes || n == 0) { free(bytes); return NULL; } + + /* Firefox emits UTF-16 BE BOM; Chromium may emit UTF-8 BOM. The higher + * Kotlin layer strips UTF-8 BOMs. For the UTF-16 case we transcode here + * since NewStringUTF expects modified UTF-8. */ + if (n >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF) { + size_t body = n - 2; + size_t chars = body / 2; + char *utf8 = malloc(chars * 3 + 1); + if (!utf8) { free(bytes); return NULL; } + size_t o = 0; + for (size_t i = 0; i < chars; i++) { + uint16_t cu = ((uint16_t)bytes[2 + i * 2] << 8) | bytes[3 + i * 2]; + if (cu < 0x80) { + utf8[o++] = (char)cu; + } else if (cu < 0x800) { + utf8[o++] = (char)(0xC0 | (cu >> 6)); + utf8[o++] = (char)(0x80 | (cu & 0x3F)); + } else { + utf8[o++] = (char)(0xE0 | (cu >> 12)); + utf8[o++] = (char)(0x80 | ((cu >> 6) & 0x3F)); + utf8[o++] = (char)(0x80 | (cu & 0x3F)); + } + } + utf8[o] = 0; + free(bytes); + jstring js = (*env)->NewStringUTF(env, utf8); + free(utf8); + return js; + } + + while (n > 0 && bytes[n - 1] == '\0') n--; + char *z = malloc(n + 1); + if (!z) { free(bytes); return NULL; } + memcpy(z, bytes, n); + z[n] = '\0'; + free(bytes); + jstring js = (*env)->NewStringUTF(env, z); + free(z); + return js; +} + +JNIEXPORT jstring JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_linux_NativeX11ClipboardBridge_nativeReadRtf( + JNIEnv *env, jclass cls) { + (void)cls; + if (!g_inited) return NULL; + return read_text_target(env, a_text_rtf, a_application_rtf); +} + +JNIEXPORT jbyteArray JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_linux_NativeX11ClipboardBridge_nativeReadImagePng( + JNIEnv *env, jclass cls) { + (void)cls; + if (!g_inited) return NULL; + size_t n = 0; + uint8_t *bytes = read_selection(a_image_png, &n); + if (!bytes || n == 0) { free(bytes); return NULL; } + jbyteArray out = (*env)->NewByteArray(env, (jsize)n); + if (!out) { free(bytes); return NULL; } + (*env)->SetByteArrayRegion(env, out, 0, (jsize)n, (const jbyte *)bytes); + free(bytes); + return out; +} + +/* URL-decodes a file:// URI into an absolute POSIX path. */ +static void append_uri_as_path(const char *uri, size_t len, char ***out, size_t *on, size_t *ocap) { + if (len > 7 && strncmp(uri, "file://", 7) == 0) { + uri += 7; len -= 7; + } + /* Skip host up to first '/'. If missing, treat entire string as path. */ + char *path = malloc(len + 1); + if (!path) return; + size_t j = 0; + for (size_t i = 0; i < len; i++) { + char c = uri[i]; + if (c == '%' && i + 2 < len) { + char h[3] = { uri[i + 1], uri[i + 2], 0 }; + path[j++] = (char)strtol(h, NULL, 16); + i += 2; + } else { + path[j++] = c; + } + } + path[j] = 0; + + if (*on >= *ocap) { + size_t nc = *ocap ? *ocap * 2 : 8; + char **nb = realloc(*out, nc * sizeof(char *)); + if (!nb) { free(path); return; } + *out = nb; *ocap = nc; + } + (*out)[(*on)++] = path; +} + +JNIEXPORT jobjectArray JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_linux_NativeX11ClipboardBridge_nativeReadFilePaths( + JNIEnv *env, jclass cls) { + (void)cls; + jclass sc = (*env)->FindClass(env, "java/lang/String"); + if (!g_inited) return (*env)->NewObjectArray(env, 0, sc, NULL); + + /* Try GNOME side-channel first for copy/cut awareness. */ + size_t n = 0; + uint8_t *bytes = read_selection(a_x_special_gnome_copied_files, &n); + bool is_cut = false; + char **paths = NULL; size_t pn = 0, pc = 0; + + if (bytes && n > 0) { + size_t start = 0; + size_t line_end = 0; + for (size_t i = 0; i <= n; i++) { + if (i == n || bytes[i] == '\n' || bytes[i] == '\r') { + line_end = i; + size_t ll = line_end - start; + if (ll > 0) { + if (pn == 0 && ll == 3 && memcmp(bytes + start, "cut", 3) == 0) { + is_cut = true; + } else if (pn == 0 && ll == 4 && memcmp(bytes + start, "copy", 4) == 0) { + is_cut = false; + } else { + append_uri_as_path((const char *)(bytes + start), ll, &paths, &pn, &pc); + } + } + start = i + 1; + } + } + } else { + free(bytes); bytes = NULL; + bytes = read_selection(a_text_uri_list, &n); + if (bytes && n > 0) { + size_t start = 0; + for (size_t i = 0; i <= n; i++) { + if (i == n || bytes[i] == '\n' || bytes[i] == '\r') { + size_t ll = i - start; + if (ll > 0 && bytes[start] != '#') { + append_uri_as_path((const char *)(bytes + start), ll, &paths, &pn, &pc); + } + start = i + 1; + } + } + } + } + free(bytes); + + /* Pack into a String[] with the copy/cut marker at index 0. */ + jobjectArray out = (*env)->NewObjectArray(env, (jsize)(pn + 1), sc, NULL); + jstring marker = (*env)->NewStringUTF(env, is_cut ? "m" : "c"); + (*env)->SetObjectArrayElement(env, out, 0, marker); + (*env)->DeleteLocalRef(env, marker); + for (size_t i = 0; i < pn; i++) { + jstring js = (*env)->NewStringUTF(env, paths[i]); + (*env)->SetObjectArrayElement(env, out, (jsize)(i + 1), js); + (*env)->DeleteLocalRef(env, js); + free(paths[i]); + } + free(paths); + return out; +} + +JNIEXPORT jboolean JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_linux_NativeX11ClipboardBridge_nativeWrite( + JNIEnv *env, jclass cls, + jstring jText, jstring jHtml, jstring jRtf, + jbyteArray jPng, jobjectArray jPaths, jboolean isCut) { + (void)cls; + if (!g_inited) return JNI_FALSE; + + pthread_mutex_lock(&g_mutex); + free_offers_locked(); + + const char *rel = NULL; + if (jText) { + const char *p = jstr_to_utf8(env, jText, &rel); + if (p) offer_string(OFFER_TEXT, p); + jstr_release(env, jText, rel); + } + if (jHtml) { + const char *p = jstr_to_utf8(env, jHtml, &rel); + if (p) offer_string(OFFER_HTML, p); + jstr_release(env, jHtml, rel); + } + if (jRtf) { + const char *p = jstr_to_utf8(env, jRtf, &rel); + if (p) offer_string(OFFER_RTF, p); + jstr_release(env, jRtf, rel); + } + if (jPng) { + jsize len = (*env)->GetArrayLength(env, jPng); + jbyte *b = (*env)->GetByteArrayElements(env, jPng, NULL); + if (b) { + set_offer_locked(OFFER_IMAGE_PNG, b, (size_t)len); + (*env)->ReleaseByteArrayElements(env, jPng, b, JNI_ABORT); + } + } + if (jPaths) { + jsize pc = (*env)->GetArrayLength(env, jPaths); + if (pc > 0) { + /* Build text/uri-list (CRLF). */ + size_t total = 0; + for (jsize i = 0; i < pc; i++) { + jstring s = (jstring)(*env)->GetObjectArrayElement(env, jPaths, i); + if (!s) continue; + const char *p = (*env)->GetStringUTFChars(env, s, NULL); + if (p) total += 7 + strlen(p) + 2; + if (p) (*env)->ReleaseStringUTFChars(env, s, p); + (*env)->DeleteLocalRef(env, s); + } + char *uri = malloc(total + 1); + char *gnome = malloc(total + 8); + if (uri && gnome) { + size_t uo = 0, go = 0; + /* GNOME side-channel header. */ + if (isCut) { memcpy(gnome + go, "cut\n", 4); go += 4; } + else { memcpy(gnome + go, "copy\n", 5); go += 5; } + for (jsize i = 0; i < pc; i++) { + jstring s = (jstring)(*env)->GetObjectArrayElement(env, jPaths, i); + if (!s) continue; + const char *p = (*env)->GetStringUTFChars(env, s, NULL); + if (p) { + size_t pl = strlen(p); + memcpy(uri + uo, "file://", 7); uo += 7; + memcpy(uri + uo, p, pl); uo += pl; + memcpy(uri + uo, "\r\n", 2); uo += 2; + + memcpy(gnome + go, "file://", 7); go += 7; + memcpy(gnome + go, p, pl); go += pl; + if (i < pc - 1) { gnome[go++] = '\n'; } + (*env)->ReleaseStringUTFChars(env, s, p); + } + (*env)->DeleteLocalRef(env, s); + } + set_offer_locked(OFFER_URI_LIST, uri, uo); + set_offer_locked(OFFER_GNOME_URIS, gnome, go); + /* KDE cut marker. */ + set_offer_locked(OFFER_KDE_CUT, isCut ? "1" : "0", 1); + } + free(uri); free(gnome); + } + } + + g_offer_is_cut = (isCut == JNI_TRUE); + + /* Use CurrentTime; xcb_set_selection_owner is routed via server and will + * be our implicit timestamp. Record it for TIMESTAMP replies. */ + g_own_ts = XCB_CURRENT_TIME; + p_xcb_set_selection_owner(g_conn, g_window, a_CLIPBOARD, XCB_CURRENT_TIME); + p_xcb_flush(g_conn); + + pthread_mutex_unlock(&g_mutex); + return JNI_TRUE; +} + +JNIEXPORT jboolean JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_linux_NativeX11ClipboardBridge_nativeClear( + JNIEnv *env, jclass cls) { + (void)env; (void)cls; + if (!g_inited) return JNI_FALSE; + + pthread_mutex_lock(&g_mutex); + + /* Best-effort SAVE_TARGETS handoff to the clipboard manager. */ + /* Skipped for V1 — GNOME no longer ships gsd-clipboard, so the handoff + * often dies silently anyway. */ + + free_offers_locked(); + p_xcb_set_selection_owner(g_conn, XCB_ATOM_NONE, a_CLIPBOARD, XCB_CURRENT_TIME); + p_xcb_flush(g_conn); + + pthread_mutex_unlock(&g_mutex); + return JNI_TRUE; +} diff --git a/clipboard-linux/src/main/resources/META-INF/native-image/io.github.kdroidfilter/nucleus.clipboard-linux/reachability-metadata.json b/clipboard-linux/src/main/resources/META-INF/native-image/io.github.kdroidfilter/nucleus.clipboard-linux/reachability-metadata.json new file mode 100644 index 000000000..3d351a211 --- /dev/null +++ b/clipboard-linux/src/main/resources/META-INF/native-image/io.github.kdroidfilter/nucleus.clipboard-linux/reachability-metadata.json @@ -0,0 +1,8 @@ +{ + "reflection": [ + { + "type": "io.github.kdroidfilter.nucleus.clipboard.linux.NativeX11ClipboardBridge", + "jniAccessible": true + } + ] +} diff --git a/clipboard-linux/src/main/resources/META-INF/services/io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardBackend b/clipboard-linux/src/main/resources/META-INF/services/io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardBackend new file mode 100644 index 000000000..76a0f7072 --- /dev/null +++ b/clipboard-linux/src/main/resources/META-INF/services/io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardBackend @@ -0,0 +1 @@ +io.github.kdroidfilter.nucleus.clipboard.linux.LinuxClipboardBackend diff --git a/clipboard-linux/src/test/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/X11RoundTripSmokeTest.kt b/clipboard-linux/src/test/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/X11RoundTripSmokeTest.kt new file mode 100644 index 000000000..3a854c739 --- /dev/null +++ b/clipboard-linux/src/test/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/X11RoundTripSmokeTest.kt @@ -0,0 +1,44 @@ +package io.github.kdroidfilter.nucleus.clipboard.linux + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Integration smoke test — exercised locally when `DISPLAY` is set. Skipped + * automatically otherwise so headless CI workers do not fail. + * + * Bypasses the ServiceLoader + session detection and drives the native X11 + * bridge directly so it works on Wayland hosts with Xwayland available. + */ +class X11RoundTripSmokeTest { + @Test + fun textRoundTrip() { + val display = System.getenv("DISPLAY") ?: return + if (display.isEmpty()) return + if (!NativeX11ClipboardBridge.isLoaded) return + if (!NativeX11ClipboardBridge.nativeInit()) { + println("X11 init failed — skipping smoke test") + return + } + println("X11 bridge initialized, changeCount=${NativeX11ClipboardBridge.nativeChangeCount()}") + + val probe = "nucleus-smoke-${System.currentTimeMillis()}" + val wrote = NativeX11ClipboardBridge.nativeWrite(probe, null, null, null, null, false) + assertTrue(wrote, "native write should succeed") + + // Give the X server a moment to propagate the ownership. + Thread.sleep(SETTLE_MS) + + val got = NativeX11ClipboardBridge.nativeReadText() + assertEquals(probe, got?.trim(), "round-trip text mismatch") + + val targets = NativeX11ClipboardBridge.nativeAvailableTargets().toSet() + println("targets=$targets") + assertTrue("UTF8_STRING" in targets || "text/plain;charset=utf-8" in targets) + } + + private companion object { + const val SETTLE_MS: Long = 150L + } +} diff --git a/example/build.gradle.kts b/example/build.gradle.kts index 1945cf4b8..d008f4c23 100644 --- a/example/build.gradle.kts +++ b/example/build.gradle.kts @@ -42,6 +42,7 @@ dependencies { implementation(project(":menu-macos")) implementation(project(":clipboard-common")) implementation(project(":clipboard-macos")) + implementation(project(":clipboard-linux")) implementation(project(":sf-symbols")) implementation(project(":media-control")) implementation(libs.coroutines.swing) diff --git a/settings.gradle.kts b/settings.gradle.kts index 475a5671f..7c251d0ca 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -64,6 +64,7 @@ include(":launcher-macos") include(":menu-macos") include(":clipboard-common") include(":clipboard-macos") +include(":clipboard-linux") include(":freedesktop-icons") include(":sf-symbols") include(":system-info") From eccbad640e30b7ae56adf2e89afcbe9f0a7b5bb0 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 19 Apr 2026 20:57:43 +0300 Subject: [PATCH 5/8] 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 --- .../linux/WaylandClipboardDelegate.kt | 50 ++++++++++++-- .../clipboard/linux/WaylandImageSmokeTest.kt | 21 ++++++ .../com/example/demo/ClipboardScreen.kt | 66 +++++++++++++++++-- 3 files changed, 126 insertions(+), 11 deletions(-) create mode 100644 clipboard-linux/src/test/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/WaylandImageSmokeTest.kt diff --git a/clipboard-linux/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/WaylandClipboardDelegate.kt b/clipboard-linux/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/WaylandClipboardDelegate.kt index b24710277..afae7ca64 100644 --- a/clipboard-linux/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/WaylandClipboardDelegate.kt +++ b/clipboard-linux/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/WaylandClipboardDelegate.kt @@ -12,7 +12,11 @@ import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicLong private const val PROCESS_WRITE_TIMEOUT_SECONDS: Long = 3L -private const val STREAM_DRAIN_MS: Long = 200L +private const val IMAGE_READ_TIMEOUT_MS: Long = 5000L +private const val DESTROY_DRAIN_MS: Long = 200L + +private val IMAGE_MIME_PREFERENCE: List = + listOf("image/png", "image/jpeg", "image/jpg", "image/webp", "image/bmp", "image/gif", "image/tiff") /** * Wayland backend implemented by delegating to `wl-copy` and `wl-paste` from the @@ -108,7 +112,43 @@ internal class WaylandClipboardDelegate { fun readImagePng(): ByteArray? { val paste = wlPaste ?: return null - return runCaptureBytes(listOf(paste, "-n", "-t", "image/png"), timeoutMs = 3000) + val available = listTypes().map { it.lowercase() }.toSet() + // 1. Try the preferred image MIME types actually advertised on the clipboard. + val preferred = IMAGE_MIME_PREFERENCE.firstOrNull { it in available } + if (preferred != null) { + runCaptureBytes(listOf(paste, "-n", "-t", preferred), timeoutMs = IMAGE_READ_TIMEOUT_MS) + ?.let { return it } + } + // 2. Any other image/* type the source publishes. + val anyImage = available.firstOrNull { it.startsWith("image/") && it !in IMAGE_MIME_PREFERENCE } + if (anyImage != null) { + runCaptureBytes(listOf(paste, "-n", "-t", anyImage), timeoutMs = IMAGE_READ_TIMEOUT_MS) + ?.let { return it } + } + // 3. File-URI copy from a file manager — if the first URI points at an + // image file, read it directly from disk. + return readImageFromFileUri() + } + + private fun readImageFromFileUri(): ByteArray? { + val files = readFiles() + val first = files.firstOrNull() ?: return null + val name = first.fileName?.toString()?.lowercase() ?: return null + val isImage = + name.endsWith(".png") || + name.endsWith(".jpg") || + name.endsWith(".jpeg") || + name.endsWith(".webp") || + name.endsWith(".bmp") || + name.endsWith(".gif") || + name.endsWith(".tiff") + if (!isImage) return null + return try { + java.nio.file.Files + .readAllBytes(first) + } catch (_: IOException) { + null + } } fun readFiles(): List { @@ -226,10 +266,12 @@ internal class WaylandClipboardDelegate { } if (!p.waitFor(timeoutMs, TimeUnit.MILLISECONDS)) { p.destroy() - t.join(STREAM_DRAIN_MS) + t.join(DESTROY_DRAIN_MS) return null } - t.join(STREAM_DRAIN_MS) + // Process exited; the pipe is closed so the reader will finish promptly. + // Join without a cap to avoid truncating large payloads (screenshots, etc.). + t.join() if (p.exitValue() != 0) return null val out = buf.toByteArray() if (out.isEmpty()) null else out diff --git a/clipboard-linux/src/test/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/WaylandImageSmokeTest.kt b/clipboard-linux/src/test/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/WaylandImageSmokeTest.kt new file mode 100644 index 000000000..0bda4db36 --- /dev/null +++ b/clipboard-linux/src/test/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/WaylandImageSmokeTest.kt @@ -0,0 +1,21 @@ +package io.github.kdroidfilter.nucleus.clipboard.linux + +import kotlin.test.Test +import kotlin.test.assertNotNull + +/** + * Reads whatever image/png is currently on the Wayland clipboard. Runs only when + * WAYLAND_DISPLAY is set and wl-clipboard is on PATH. + */ +class WaylandImageSmokeTest { + @Test + fun readCurrentImage() { + System.getenv("WAYLAND_DISPLAY") ?: return + val delegate = WaylandClipboardDelegate() + if (!delegate.isAvailable) return + println("types=${delegate.listTypes()}") + val bytes = delegate.readImagePng() + println("image bytes=${bytes?.size}") + assertNotNull(bytes, "expected image/png on clipboard") + } +} diff --git a/example/src/main/kotlin/com/example/demo/ClipboardScreen.kt b/example/src/main/kotlin/com/example/demo/ClipboardScreen.kt index 1f81ee80a..a75bc84fa 100644 --- a/example/src/main/kotlin/com/example/demo/ClipboardScreen.kt +++ b/example/src/main/kotlin/com/example/demo/ClipboardScreen.kt @@ -49,15 +49,32 @@ import org.jetbrains.skia.Image as SkiaImage private const val EVENT_LOG_MAX = 40 +private sealed class LogEntry { + abstract val line: String + + data class Text(override val line: String) : LogEntry() + + data class Image(override val line: String, val bitmap: ImageBitmap) : LogEntry() +} + @Suppress("FunctionNaming", "LongMethod", "CyclomaticComplexMethod") @Composable fun ClipboardScreen() { val scope = rememberCoroutineScope() - val events = remember { mutableStateListOf() } + val events = remember { mutableStateListOf() } fun log(msg: String) { val ts = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) - events.add(0, "[$ts] $msg") + events.add(0, LogEntry.Text("[$ts] $msg")) + while (events.size > EVENT_LOG_MAX) events.removeAt(events.lastIndex) + } + + fun logImage( + msg: String, + bitmap: ImageBitmap, + ) { + val ts = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + events.add(0, LogEntry.Image("[$ts] $msg", bitmap)) while (events.size > EVENT_LOG_MAX) events.removeAt(events.lastIndex) } @@ -80,6 +97,16 @@ fun ClipboardScreen() { currentFormats = event.formats currentChangeCount = event.changeCount log("change #${event.changeCount} formats=${event.formats}") + if (ClipboardFormat.Image in event.formats) { + val bytes = Clipboard.readImageBytes() + val bitmap = + bytes?.let { + runCatching { SkiaImage.makeFromEncoded(it).toComposeImageBitmap() }.getOrNull() + } + if (bitmap != null) { + logImage("image copied · ${bytes.size} bytes (png)", bitmap) + } + } } } @@ -172,7 +199,7 @@ fun ClipboardScreen() { onReadImage = { scope.launch { val bytes = Clipboard.readImageBytes() - lastImage = + val bitmap = bytes?.let { runCatching { SkiaImage @@ -181,7 +208,12 @@ fun ClipboardScreen() { ).toComposeImageBitmap() }.getOrNull() } - log("read image: ${bytes?.size ?: 0} bytes (png)") + lastImage = bitmap + if (bitmap != null && bytes != null) { + logImage("read image · ${bytes.size} bytes (png)", bitmap) + } else { + log("read image: ${bytes?.size ?: 0} bytes (png)") + } } }, onReadFiles = { @@ -374,7 +406,7 @@ private fun ReadRow( } @Composable -private fun EventLogCard(events: List) { +private fun EventLogCard(events: List) { Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { Text("Event log", style = MaterialTheme.typography.titleMedium) @@ -387,8 +419,28 @@ private fun EventLogCard(events: List) { } else { HorizontalDivider() Spacer(Modifier.height(4.dp)) - events.take(EVENT_LOG_MAX).forEach { - Text(it, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace) + events.take(EVENT_LOG_MAX).forEach { entry -> + when (entry) { + is LogEntry.Text -> + Text( + entry.line, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + is LogEntry.Image -> + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + entry.line, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + Image( + painter = BitmapPainter(entry.bitmap), + contentDescription = "Clipboard image", + modifier = Modifier.widthIn(max = 240.dp).heightIn(max = 160.dp), + ) + } + } } } } From 9c4b4c030eab22c4e189477fc7064fa2dc2f8763 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 19 Apr 2026 22:10:33 +0300 Subject: [PATCH 6/8] fix(clipboard): address critical bugs and design issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X11 ICCCM compliance (#4/#5): - SetSelectionOwner now uses real server timestamp via PropertyNotify probe, not XCB_CURRENT_TIME (violates ICCCM §2.1). Added get_server_timestamp_locked() which fires a zero-byte ChangeProperty to trigger timestamp event. - TIMESTAMP replies now return g_own_ts (real value) instead of truncated 0. - Verified: xclip -o -t TIMESTAMP returns non-zero after our clipboard write. INCR cleanup (#3): - On INCR read timeout, delete property to unblock sender waiting for PropertyNotify=Delete (ICCCM compliant termination). Process lifecycle (#7): - Wayland: runCaptureBytes, runSilently, writeBytes now escalate to destroyForcibly() if SIGTERM doesn't terminate after 500ms grace. AccessBehavior mapping (#12): - Kotlin: explicit when() mapping (0→AlwaysAllow, 1→AskEveryTime, 2→AlwaysDeny) instead of ordinal/entries.getOrNull (fragile with future macOS versions). - ObjC: validate input 0..2 on set; return -1 if get() returns out-of-range. Documentation & robustness (#13, #1): - Clipboard.watch() doc: clarify poll interval is always honored; source of counter differs by backend (Mach IPC / XFixes / wl-paste). - Re-check isActive after slow availableFormats() to avoid emitting to cancelled flow. Added X11TimestampSmokeTest to verify real timestamps are used. --- .../nucleus/clipboard/Clipboard.kt | 15 +++- .../linux/WaylandClipboardDelegate.kt | 10 +++ .../native/linux/nucleus_clipboard_linux.c | 87 +++++++++++++++---- .../clipboard/linux/X11TimestampSmokeTest.kt | 53 +++++++++++ .../clipboard/macos/MacClipboardBackend.kt | 16 +++- .../macos/NativeMacClipboardBridge.kt | 10 ++- .../src/main/native/macos/nucleus_clipboard.m | 9 +- 7 files changed, 173 insertions(+), 27 deletions(-) create mode 100644 clipboard-linux/src/test/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/X11TimestampSmokeTest.kt diff --git a/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/Clipboard.kt b/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/Clipboard.kt index cf2c9a68e..2f0afafb1 100644 --- a/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/Clipboard.kt +++ b/clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/Clipboard.kt @@ -132,9 +132,10 @@ object Clipboard { * Cold [Flow] that emits whenever the clipboard contents change. * * The event carries only format metadata and a monotonic `changeCount`, not - * payload bytes — call a `readXxx` method to fetch content. Polls the backend's - * change counter (cheap Mach IPC on macOS, WM_CLIPBOARDUPDATE on Windows, - * XFixes on X11, data-control events on Wayland). + * payload bytes — call a `readXxx` method to fetch content. The flow polls + * `changeCount()` on every [pollInterval] tick regardless of backend; what + * differs is the source that bumps the counter: Mach IPC on macOS, a native + * event thread backed by XFixes on X11, and `wl-paste --watch` on Wayland. * * The baseline is captured on `collect`, so the flow does not emit the current * clipboard state — only subsequent changes. @@ -149,7 +150,13 @@ object Clipboard { val cur = backend.changeCount() if (cur != last) { last = cur - trySend(ClipboardEvent(formats = availableFormats(), changeCount = cur)) + // availableFormats() may be slow on Wayland (spawns a process); + // re-check isActive so a cancellation during the await + // does not leak a ghost emission after close. + val formats = availableFormats() + if (isActive) { + trySend(ClipboardEvent(formats = formats, changeCount = cur)) + } } } } diff --git a/clipboard-linux/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/WaylandClipboardDelegate.kt b/clipboard-linux/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/WaylandClipboardDelegate.kt index afae7ca64..be7c28191 100644 --- a/clipboard-linux/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/WaylandClipboardDelegate.kt +++ b/clipboard-linux/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/WaylandClipboardDelegate.kt @@ -14,6 +14,7 @@ import java.util.concurrent.atomic.AtomicLong private const val PROCESS_WRITE_TIMEOUT_SECONDS: Long = 3L private const val IMAGE_READ_TIMEOUT_MS: Long = 5000L private const val DESTROY_DRAIN_MS: Long = 200L +private const val DESTROY_GRACE_MS: Long = 500L private val IMAGE_MIME_PREFERENCE: List = listOf("image/png", "image/jpeg", "image/jpg", "image/webp", "image/bmp", "image/gif", "image/tiff") @@ -219,6 +220,9 @@ internal class WaylandClipboardDelegate { p.waitFor(PROCESS_WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS) if (p.isAlive) { p.destroy() + if (!p.waitFor(DESTROY_GRACE_MS, TimeUnit.MILLISECONDS)) { + p.destroyForcibly() + } false } else { p.exitValue() == 0 @@ -266,6 +270,9 @@ internal class WaylandClipboardDelegate { } if (!p.waitFor(timeoutMs, TimeUnit.MILLISECONDS)) { p.destroy() + if (!p.waitFor(DESTROY_GRACE_MS, TimeUnit.MILLISECONDS)) { + p.destroyForcibly() + } t.join(DESTROY_DRAIN_MS) return null } @@ -296,6 +303,9 @@ internal class WaylandClipboardDelegate { val p = ProcessBuilder(cmd).redirectErrorStream(true).start() if (!p.waitFor(timeoutMs, TimeUnit.MILLISECONDS)) { p.destroy() + if (!p.waitFor(DESTROY_GRACE_MS, TimeUnit.MILLISECONDS)) { + p.destroyForcibly() + } false } else { p.exitValue() == 0 diff --git a/clipboard-linux/src/main/native/linux/nucleus_clipboard_linux.c b/clipboard-linux/src/main/native/linux/nucleus_clipboard_linux.c index 30da1f251..366ea31ce 100644 --- a/clipboard-linux/src/main/native/linux/nucleus_clipboard_linux.c +++ b/clipboard-linux/src/main/native/linux/nucleus_clipboard_linux.c @@ -313,7 +313,7 @@ static xcb_atom_t a_CLIPBOARD, a_TARGETS, a_MULTIPLE, a_TIMESTAMP, a_SAVE_TARGET a_INCR, a_ATOM_PAIR, a_UTF8_STRING, a_STRING, a_TEXT, a_COMPOUND_TEXT, a_text_plain, a_text_plain_utf8, a_text_html, a_text_rtf, a_application_rtf, a_image_png, a_text_uri_list, a_x_special_gnome_copied_files, - a_application_x_kde_cutselection, a_clipboard_manager, a_prop; + a_application_x_kde_cutselection, a_clipboard_manager, a_prop, a_ts_probe; /* Mutex protecting everything below. */ static pthread_mutex_t g_mutex = PTHREAD_MUTEX_INITIALIZER; @@ -327,7 +327,15 @@ typedef struct { static payload_t g_offers[OFFER__COUNT]; static bool g_offer_is_cut = false; -static xcb_timestamp_t g_own_ts = 0; + +/* Latest X11 server timestamp observed on our own window. Used instead of + * XCB_CURRENT_TIME for SetSelectionOwner / TIMESTAMP replies, as ICCCM §2.1 + * forbids CurrentTime for those. Fetched via a zero-byte ChangeProperty probe + * which triggers a PropertyNotify that the event thread captures. */ +static xcb_timestamp_t g_latest_ts = 0; +static bool g_ts_pending = false; +static pthread_cond_t g_ts_cond = PTHREAD_COND_INITIALIZER; +static xcb_timestamp_t g_own_ts = 0; /* Requestor-side pending read. */ typedef struct { @@ -444,6 +452,46 @@ static void intern_all_atoms(void) { a_application_x_kde_cutselection = intern("application/x-kde-cutselection", false); a_clipboard_manager = intern("CLIPBOARD_MANAGER", false); a_prop = intern(PROP_NAME, false); + a_ts_probe = intern("NUCLEUS_CLIPBOARD_TS_PROBE", false); +} + +/* --------------------------------------------------------------------- */ +/* Deadline helper */ +/* --------------------------------------------------------------------- */ + +static void add_ms(struct timespec *ts, long ms) { + ts->tv_sec += ms / 1000; + ts->tv_nsec += (ms % 1000) * 1000000L; + if (ts->tv_nsec >= 1000000000L) { ts->tv_sec++; ts->tv_nsec -= 1000000000L; } +} + +/* --------------------------------------------------------------------- */ +/* ICCCM-compliant server timestamp fetch */ +/* --------------------------------------------------------------------- */ + +/* Forces the server to emit a PropertyNotify on our window and captures its + * `time` field via the event thread. Must be called with g_mutex held; the + * condvar wait releases it transiently. Falls back to g_latest_ts on timeout, + * or 0 if no event has ever been observed. */ +static xcb_timestamp_t get_server_timestamp_locked(void) { + g_ts_pending = true; + /* Append 0 bytes to a dedicated property → unconditional PropertyNotify. */ + p_xcb_change_property(g_conn, XCB_PROP_MODE_APPEND, g_window, a_ts_probe, + XCB_ATOM_STRING, 8, 0, NULL); + p_xcb_flush(g_conn); + + struct timespec deadline; + clock_gettime(CLOCK_REALTIME, &deadline); + add_ms(&deadline, 500); + + while (g_ts_pending) { + int rc = pthread_cond_timedwait(&g_ts_cond, &g_mutex, &deadline); + if (rc == ETIMEDOUT) { + g_ts_pending = false; + break; + } + } + return g_latest_ts; } /* --------------------------------------------------------------------- */ @@ -616,13 +664,6 @@ static uint8_t *fetch_property(xcb_atom_t prop, bool *out_incr, size_t *out_len) return out; } -/* Deadline helper. */ -static void add_ms(struct timespec *ts, long ms) { - ts->tv_sec += ms / 1000; - ts->tv_nsec += (ms % 1000) * 1000000L; - if (ts->tv_nsec >= 1000000000L) { ts->tv_sec++; ts->tv_nsec -= 1000000000L; } -} - /* Perform a full read for the given target (atom). Returns malloc'd bytes or NULL. */ static uint8_t *read_selection(xcb_atom_t target, size_t *out_len) { *out_len = 0; @@ -665,6 +706,11 @@ static uint8_t *read_selection(xcb_atom_t target, size_t *out_len) { while (!g_read.incr_done) { int rc = pthread_cond_timedwait(&g_read_cond, &g_mutex, &incr_deadline); if (rc == ETIMEDOUT) { + /* Delete the property so the sender is unblocked for its next + * chunk (ICCCM requires the requestor to ack each chunk by + * deleting the property). We then abandon the transfer. */ + p_xcb_delete_property(g_conn, g_window, a_prop); + p_xcb_flush(g_conn); g_read.waiting = false; read_reset_locked(); pthread_mutex_unlock(&g_mutex); @@ -720,8 +766,18 @@ static void on_selection_notify_locked(const xcb_selection_notify_event_t *ev) { } static void on_property_notify_locked(const xcb_property_notify_event_t *ev) { + /* Any PropertyNotify on our own window gives us a fresh server timestamp + * we can use for subsequent SetSelectionOwner calls (ICCCM §2.1). */ + if (ev->window == g_window) { + g_latest_ts = ev->time; + if (ev->atom == a_ts_probe && g_ts_pending) { + g_ts_pending = false; + pthread_cond_broadcast(&g_ts_cond); + } + } + /* state 0 = NewValue, 1 = Delete. We only care about NewValue on our - * own property during INCR. */ + * own read property during INCR. */ if (!g_read.waiting || !g_read.incr || g_read.incr_done) return; if (ev->window != g_window || ev->atom != a_prop || ev->state != 0) return; @@ -1248,10 +1304,10 @@ Java_io_github_kdroidfilter_nucleus_clipboard_linux_NativeX11ClipboardBridge_nat g_offer_is_cut = (isCut == JNI_TRUE); - /* Use CurrentTime; xcb_set_selection_owner is routed via server and will - * be our implicit timestamp. Record it for TIMESTAMP replies. */ - g_own_ts = XCB_CURRENT_TIME; - p_xcb_set_selection_owner(g_conn, g_window, a_CLIPBOARD, XCB_CURRENT_TIME); + /* ICCCM §2.1: SetSelectionOwner must use a real server timestamp, + * never CurrentTime. Probe one via a zero-byte ChangeProperty. */ + g_own_ts = get_server_timestamp_locked(); + p_xcb_set_selection_owner(g_conn, g_window, a_CLIPBOARD, g_own_ts); p_xcb_flush(g_conn); pthread_mutex_unlock(&g_mutex); @@ -1271,7 +1327,8 @@ Java_io_github_kdroidfilter_nucleus_clipboard_linux_NativeX11ClipboardBridge_nat * often dies silently anyway. */ free_offers_locked(); - p_xcb_set_selection_owner(g_conn, XCB_ATOM_NONE, a_CLIPBOARD, XCB_CURRENT_TIME); + xcb_timestamp_t ts = get_server_timestamp_locked(); + p_xcb_set_selection_owner(g_conn, XCB_ATOM_NONE, a_CLIPBOARD, ts); p_xcb_flush(g_conn); pthread_mutex_unlock(&g_mutex); diff --git a/clipboard-linux/src/test/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/X11TimestampSmokeTest.kt b/clipboard-linux/src/test/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/X11TimestampSmokeTest.kt new file mode 100644 index 000000000..e7daf65c4 --- /dev/null +++ b/clipboard-linux/src/test/kotlin/io/github/kdroidfilter/nucleus/clipboard/linux/X11TimestampSmokeTest.kt @@ -0,0 +1,53 @@ +package io.github.kdroidfilter.nucleus.clipboard.linux + +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Verifies that SetSelectionOwner uses a real X11 server timestamp (ICCCM §2.1), + * not XCB_CURRENT_TIME. We can't introspect the native timestamp directly; the + * proof is indirect: `xclip -selection clipboard -o -t TIMESTAMP` returns the + * selection owner's timestamp, which must be > 0 when the protocol is respected. + * + * Skipped when DISPLAY is unset or when xclip is not installed. + */ +class X11TimestampSmokeTest { + @Test + fun selectionTimestampIsNonZero() { + val display = System.getenv("DISPLAY") ?: return + if (display.isEmpty()) return + if (!NativeX11ClipboardBridge.isLoaded) return + if (!NativeX11ClipboardBridge.nativeInit()) return + if (!hasBinary("xclip")) return + + NativeX11ClipboardBridge.nativeWrite( + "nucleus-ts-probe-${System.currentTimeMillis()}", + null, + null, + null, + null, + false, + ) + Thread.sleep(150) + + val output = + ProcessBuilder("xclip", "-selection", "clipboard", "-o", "-t", "TIMESTAMP") + .redirectErrorStream(true) + .start() + .inputStream + .bufferedReader() + .readText() + .trim() + + println("TIMESTAMP output=$output") + val ts = output.toLongOrNull() + assertTrue(ts != null && ts > 0L, "selection timestamp must be non-zero, got '$output'") + } + + private fun hasBinary(name: String): Boolean { + val path = System.getenv("PATH") ?: return false + return path.split(':').any { dir -> + dir.isNotEmpty() && java.io.File(dir, name).canExecute() + } + } +} diff --git a/clipboard-macos/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/macos/MacClipboardBackend.kt b/clipboard-macos/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/macos/MacClipboardBackend.kt index 9e9a756c2..33af9e2e2 100644 --- a/clipboard-macos/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/macos/MacClipboardBackend.kt +++ b/clipboard-macos/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/macos/MacClipboardBackend.kt @@ -76,13 +76,23 @@ class MacClipboardBackend : ClipboardBackend { override fun setAccessBehavior(behavior: AccessBehavior) { if (!NativeMacClipboardBridge.isLoaded) return - NativeMacClipboardBridge.nativeSetAccessBehavior(behavior.ordinal) + val raw = + when (behavior) { + AccessBehavior.AlwaysAllow -> 0 + AccessBehavior.AskEveryTime -> 1 + AccessBehavior.AlwaysDeny -> 2 + } + NativeMacClipboardBridge.nativeSetAccessBehavior(raw) } override fun accessBehavior(): AccessBehavior? { if (!NativeMacClipboardBridge.isLoaded) return null - val raw = NativeMacClipboardBridge.nativeGetAccessBehavior() - return AccessBehavior.entries.getOrNull(raw) + return when (NativeMacClipboardBridge.nativeGetAccessBehavior()) { + 0 -> AccessBehavior.AlwaysAllow + 1 -> AccessBehavior.AskEveryTime + 2 -> AccessBehavior.AlwaysDeny + else -> null + } } override fun isAccessBehaviorSupported(): Boolean = diff --git a/clipboard-macos/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/macos/NativeMacClipboardBridge.kt b/clipboard-macos/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/macos/NativeMacClipboardBridge.kt index 9ac9bad4c..eaeaf2534 100644 --- a/clipboard-macos/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/macos/NativeMacClipboardBridge.kt +++ b/clipboard-macos/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/macos/NativeMacClipboardBridge.kt @@ -58,15 +58,17 @@ internal object NativeMacClipboardBridge { external fun nativeClear(): Boolean /** - * Sets `NSPasteboard.accessBehavior`. Values match [AccessBehavior] ordinal: - * 0 = always allow, 1 = ask every time, 2 = always deny. No-op on macOS < 15.4. + * Sets `NSPasteboard.accessBehavior`. Accepts 0 = always allow, + * 1 = ask every time, 2 = always deny. Other values are refused. + * No-op on macOS < 15.4. */ @JvmStatic external fun nativeSetAccessBehavior(value: Int) /** - * Reads `NSPasteboard.accessBehavior`. Returns the raw enum value on - * macOS 15.4+, or `-1` when the property is not exposed by the runtime. + * Reads `NSPasteboard.accessBehavior`. Returns 0/1/2 on macOS 15.4+, + * or `-1` when the property is not exposed or the value is outside the + * documented range (future macOS may add new cases). */ @JvmStatic external fun nativeGetAccessBehavior(): Int diff --git a/clipboard-macos/src/main/native/macos/nucleus_clipboard.m b/clipboard-macos/src/main/native/macos/nucleus_clipboard.m index 16e57d4ee..0e9c44c58 100644 --- a/clipboard-macos/src/main/native/macos/nucleus_clipboard.m +++ b/clipboard-macos/src/main/native/macos/nucleus_clipboard.m @@ -315,10 +315,13 @@ static jobjectArray toJStringArray(JNIEnv *env, NSArray *items) { JNIEnv *env, jclass cls, jint value) { (void)env; (void)cls; @autoreleasepool { + // macOS 15.4 enum layout: 0 = alwaysAllow, 1 = askEveryTime, 2 = alwaysDeny. + // Refuse out-of-range values rather than propagating them to AppKit where + // they would produce undefined behavior. + if (value < 0 || value > 2) return; NSPasteboard *pb = [NSPasteboard generalPasteboard]; SEL sel = NSSelectorFromString(@"setAccessBehavior:"); if (![pb respondsToSelector:sel]) return; - // macOS 15.4 enum layout: 0 = alwaysAllow, 1 = askEveryTime, 2 = alwaysDeny. NSInteger mapped = (NSInteger)value; NSMethodSignature *sig = [pb methodSignatureForSelector:sel]; NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig]; @@ -344,6 +347,10 @@ static jobjectArray toJStringArray(JNIEnv *env, NSArray *items) { [inv invoke]; NSInteger result = 0; [inv getReturnValue:&result]; + // Only forward the three documented enum values. A future macOS + // release may add intermediate cases — surface them as -1 so the + // Kotlin side can degrade to null rather than silently misreport. + if (result < 0 || result > 2) return -1; return (jint)result; } } From 57012a5ddf09a792a7adf3aae7777957c2a812ed Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 19 Apr 2026 22:14:57 +0300 Subject: [PATCH 7/8] style(clipboard): apply ktlint class-signature formatting --- .../src/main/kotlin/com/example/demo/ClipboardScreen.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/example/src/main/kotlin/com/example/demo/ClipboardScreen.kt b/example/src/main/kotlin/com/example/demo/ClipboardScreen.kt index a75bc84fa..5422f126a 100644 --- a/example/src/main/kotlin/com/example/demo/ClipboardScreen.kt +++ b/example/src/main/kotlin/com/example/demo/ClipboardScreen.kt @@ -52,9 +52,14 @@ private const val EVENT_LOG_MAX = 40 private sealed class LogEntry { abstract val line: String - data class Text(override val line: String) : LogEntry() + data class Text( + override val line: String, + ) : LogEntry() - data class Image(override val line: String, val bitmap: ImageBitmap) : LogEntry() + data class Image( + override val line: String, + val bitmap: ImageBitmap, + ) : LogEntry() } @Suppress("FunctionNaming", "LongMethod", "CyclomaticComplexMethod") From 3aa5a457be3f9b24945eae43618d1e2752630295 Mon Sep 17 00:00:00 2001 From: "Elie G." Date: Mon, 20 Apr 2026 00:27:09 +0300 Subject: [PATCH 8/8] feat(clipboard): add Windows Win32 backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements full Win32 clipboard support via JNI (user32, gdi32, gdiplus): - Reads/writes: text (CF_UNICODETEXT), HTML (CF_HTML wrapper), RTF, images (PNG + CF_DIBV5 with Chromium alpha sanitization), file lists (CF_HDROP) - Change detection via GetClipboardSequenceNumber (no message pump needed) - Open-clipboard retry loop (5 × 10ms) for rdpclip.exe contention - GDI+ transcoding: PNG ↔ DIB via synthetic BMP-in-memory - x64 + ARM64 DLLs built via MSVC (build.bat) --- clipboard-windows/build.gradle.kts | 92 +++ .../windows/NativeWindowsClipboardBridge.kt | 78 ++ .../windows/WindowsClipboardBackend.kt | 101 +++ .../src/main/native/windows/build.bat | 119 +++ .../windows/nucleus_clipboard_windows.cpp | 710 ++++++++++++++++++ .../reachability-metadata.json | 8 + ...ucleus.clipboard.internal.ClipboardBackend | 1 + example/build.gradle.kts | 1 + settings.gradle.kts | 1 + 9 files changed, 1111 insertions(+) create mode 100644 clipboard-windows/build.gradle.kts create mode 100644 clipboard-windows/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/windows/NativeWindowsClipboardBridge.kt create mode 100644 clipboard-windows/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/windows/WindowsClipboardBackend.kt create mode 100644 clipboard-windows/src/main/native/windows/build.bat create mode 100644 clipboard-windows/src/main/native/windows/nucleus_clipboard_windows.cpp create mode 100644 clipboard-windows/src/main/resources/META-INF/native-image/io.github.kdroidfilter/nucleus.clipboard-windows/reachability-metadata.json create mode 100644 clipboard-windows/src/main/resources/META-INF/services/io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardBackend diff --git a/clipboard-windows/build.gradle.kts b/clipboard-windows/build.gradle.kts new file mode 100644 index 000000000..e587ac4f3 --- /dev/null +++ b/clipboard-windows/build.gradle.kts @@ -0,0 +1,92 @@ +import org.apache.tools.ant.taskdefs.condition.Os +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + kotlin("jvm") + alias(libs.plugins.vanniktechMavenPublish) +} + +val publishVersion = + providers + .environmentVariable("GITHUB_REF") + .orNull + ?.removePrefix("refs/tags/v") + ?: "1.0.0" + +dependencies { + implementation(project(":core-runtime")) + implementation(project(":clipboard-common")) + implementation(libs.coroutines.core) +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } +} + +val nativeResourceDir = layout.projectDirectory.dir("src/main/resources/nucleus/native") + +val buildNativeWindows by tasks.registering(Exec::class) { + description = "Compiles the C++ JNI bridge into Windows DLLs (x64 + ARM64)" + group = "build" + val nativeDir = file("src/main/native/windows") + val outputDir = file("src/main/resources/nucleus/native") + val checkFile = File(outputDir, "win32-x64/nucleus_clipboard_windows.dll") + onlyIf { Os.isFamily(Os.FAMILY_WINDOWS) && !checkFile.exists() } + inputs.dir(nativeDir) + outputs.dir(outputDir) + workingDir(nativeDir) + commandLine("cmd", "/c", File(nativeDir, "build.bat").absolutePath) +} + +tasks.processResources { + dependsOn(buildNativeWindows) +} + +tasks.configureEach { + if (name == "sourcesJar") { + dependsOn(buildNativeWindows) + } +} + +mavenPublishing { + coordinates("io.github.kdroidfilter", "nucleus.clipboard-windows", publishVersion) + + pom { + name.set("Nucleus Clipboard Windows") + description.set("Windows Win32 clipboard backend for Nucleus via JNI") + url.set("https://github.com/kdroidFilter/Nucleus") + + licenses { + license { + name.set("MIT License") + url.set("https://opensource.org/licenses/MIT") + } + } + + developers { + developer { + id.set("kdroidfilter") + name.set("kdroidFilter") + url.set("https://github.com/kdroidFilter") + } + } + + scm { + url.set("https://github.com/kdroidFilter/Nucleus") + connection.set("scm:git:git://github.com/kdroidFilter/Nucleus.git") + developerConnection.set("scm:git:ssh://git@github.com/kdroidFilter/Nucleus.git") + } + } + + publishToMavenCentral() + if (project.hasProperty("signingInMemoryKey")) { + signAllPublications() + } +} diff --git a/clipboard-windows/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/windows/NativeWindowsClipboardBridge.kt b/clipboard-windows/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/windows/NativeWindowsClipboardBridge.kt new file mode 100644 index 000000000..c467ea904 --- /dev/null +++ b/clipboard-windows/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/windows/NativeWindowsClipboardBridge.kt @@ -0,0 +1,78 @@ +package io.github.kdroidfilter.nucleus.clipboard.windows + +import io.github.kdroidfilter.nucleus.core.runtime.NativeLibraryLoader + +private const val LIBRARY_NAME = "nucleus_clipboard_windows" + +/** + * Low-level JNI surface over the Win32 clipboard (User32). + * + * All methods apply to the system clipboard. Each native call opens the + * clipboard with a bounded retry loop (5 × ~10 ms) to ride out transient + * contention from `rdpclip.exe` and clipboard-history viewers. Safe to call + * from any thread; no dedicated message pump is required because + * [nativeChangeCount] uses `GetClipboardSequenceNumber` (does not require + * opening the clipboard or a message-only window). + */ +@Suppress("TooManyFunctions") +internal object NativeWindowsClipboardBridge { + private val loaded = NativeLibraryLoader.load(LIBRARY_NAME, NativeWindowsClipboardBridge::class.java) + + val isLoaded: Boolean get() = loaded + + /** `GetClipboardSequenceNumber()` — cheap, no clipboard open. */ + @JvmStatic + external fun nativeChangeCount(): Long + + /** + * Returns the format names currently advertised on the clipboard. + * Predefined formats are mapped back to canonical strings + * (`CF_UNICODETEXT`, `CF_HDROP`, `CF_DIBV5`, `CF_DIB`, `CF_BITMAP`); + * registered formats return their original registered name + * (`HTML Format`, `Rich Text Format`, `PNG`, MIME strings, …). + */ + @JvmStatic + external fun nativeAvailableFormats(): Array + + @JvmStatic + external fun nativeReadText(): String? + + /** Unwraps the CF_HTML header and returns the fragment bytes decoded as UTF-8. */ + @JvmStatic + external fun nativeReadHtml(): String? + + @JvmStatic + external fun nativeReadRtf(): String? + + /** + * Returns PNG-encoded bytes. Prefers the registered `"PNG"` format when + * available; otherwise falls back to `CF_DIBV5` / `CF_DIB` transcoded via + * GDI+. + */ + @JvmStatic + external fun nativeReadImagePng(): ByteArray? + + /** Returns absolute paths read from `CF_HDROP`. */ + @JvmStatic + external fun nativeReadFilePaths(): Array + + /** + * Atomic multi-format write — opens the clipboard once, empties it, and + * publishes every non-null representation between one + * `OpenClipboard`/`CloseClipboard` pair. Images are published both as + * `"PNG"` (registered) and `CF_DIBV5` for broad compatibility. + * `paths`, when non-null, produces a `CF_HDROP` plus + * `"Preferred DropEffect"` = `DROPEFFECT_COPY`. + */ + @JvmStatic + external fun nativeWrite( + text: String?, + html: String?, + rtf: String?, + imagePng: ByteArray?, + paths: Array?, + ): Boolean + + @JvmStatic + external fun nativeClear(): Boolean +} diff --git a/clipboard-windows/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/windows/WindowsClipboardBackend.kt b/clipboard-windows/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/windows/WindowsClipboardBackend.kt new file mode 100644 index 000000000..c00bf8007 --- /dev/null +++ b/clipboard-windows/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/windows/WindowsClipboardBackend.kt @@ -0,0 +1,101 @@ +package io.github.kdroidfilter.nucleus.clipboard.windows + +import io.github.kdroidfilter.nucleus.clipboard.AccessBehavior +import io.github.kdroidfilter.nucleus.clipboard.ClipboardFormat +import io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardBackend +import io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardWritePayload +import io.github.kdroidfilter.nucleus.core.runtime.Platform +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.nio.file.Path +import java.nio.file.Paths + +/** + * Windows [ClipboardBackend] backed by the Win32 clipboard API via JNI. + * + * Registered through `META-INF/services`. Supports text (`CF_UNICODETEXT`), + * HTML (`"HTML Format"` with CF_HTML header), RTF (`"Rich Text Format"`), + * images (`"PNG"` + `CF_DIBV5`), and file lists (`CF_HDROP` + preferred + * drop effect). Change detection uses `GetClipboardSequenceNumber()` — the + * public [io.github.kdroidfilter.nucleus.clipboard.Clipboard.watch] façade + * polls it on a 250 ms interval, so no message-only window is needed. + */ +class WindowsClipboardBackend : ClipboardBackend { + override val name: String = "Windows Win32 Clipboard" + + override fun isAvailable(): Boolean = Platform.Current == Platform.Windows && NativeWindowsClipboardBridge.isLoaded + + override suspend fun readText(): String? = + withContext(Dispatchers.IO) { NativeWindowsClipboardBridge.nativeReadText() } + + override suspend fun readHtml(): String? = + withContext(Dispatchers.IO) { NativeWindowsClipboardBridge.nativeReadHtml()?.stripBom() } + + override suspend fun readRtf(): String? = + withContext(Dispatchers.IO) { NativeWindowsClipboardBridge.nativeReadRtf() } + + override suspend fun readImagePng(): ByteArray? = + withContext(Dispatchers.IO) { NativeWindowsClipboardBridge.nativeReadImagePng() } + + override suspend fun readFiles(): List = + withContext(Dispatchers.IO) { + NativeWindowsClipboardBridge.nativeReadFilePaths().map(Paths::get) + } + + override suspend fun availableFormats(): Set = + withContext(Dispatchers.IO) { + val formats = NativeWindowsClipboardBridge.nativeAvailableFormats() + buildSet { + for (f in formats) { + when { + f.equals("CF_UNICODETEXT", ignoreCase = true) || + f.equals("CF_TEXT", ignoreCase = true) || + f.equals("CF_OEMTEXT", ignoreCase = true) -> add(ClipboardFormat.Text) + f.equals("HTML Format", ignoreCase = true) || + f.equals("text/html", ignoreCase = true) -> add(ClipboardFormat.Html) + f.equals("Rich Text Format", ignoreCase = true) || + f.equals("Rich Text Format Without Objects", ignoreCase = true) || + f.equals("application/rtf", ignoreCase = true) -> add(ClipboardFormat.Rtf) + f.equals("PNG", ignoreCase = true) || + f.equals("JFIF", ignoreCase = true) || + f.equals("CF_DIBV5", ignoreCase = true) || + f.equals("CF_DIB", ignoreCase = true) || + f.equals("CF_BITMAP", ignoreCase = true) || + f.startsWith("image/", ignoreCase = true) -> add(ClipboardFormat.Image) + f.equals("CF_HDROP", ignoreCase = true) -> add(ClipboardFormat.Files) + } + } + } + } + + override suspend fun write(payload: ClipboardWritePayload): Boolean = + withContext(Dispatchers.IO) { + val paths = payload.files?.map { it.toAbsolutePath().toString() }?.toTypedArray() + NativeWindowsClipboardBridge.nativeWrite( + payload.text, + payload.html, + payload.rtf, + payload.imagePng, + paths, + ) + } + + override suspend fun clear(): Boolean = withContext(Dispatchers.IO) { NativeWindowsClipboardBridge.nativeClear() } + + override fun changeCount(): Long = + if (NativeWindowsClipboardBridge.isLoaded) NativeWindowsClipboardBridge.nativeChangeCount() else 0L + + // Windows has no per-process pasteboard privacy toggle — callers are unrestricted. + override fun setAccessBehavior(behavior: AccessBehavior) = Unit + + override fun accessBehavior(): AccessBehavior? = null + + override fun isAccessBehaviorSupported(): Boolean = false + + /** + * Strips a leading UTF-8 BOM (`\uFEFF`) that Chromium emits on `text/html` + * payloads. CF_HTML's own ASCII header is already consumed by the native + * side. + */ + private fun String.stripBom(): String = if (isNotEmpty() && this[0] == '\uFEFF') substring(1) else this +} diff --git a/clipboard-windows/src/main/native/windows/build.bat b/clipboard-windows/src/main/native/windows/build.bat new file mode 100644 index 000000000..6765140cc --- /dev/null +++ b/clipboard-windows/src/main/native/windows/build.bat @@ -0,0 +1,119 @@ +@echo off +REM Compiles nucleus_clipboard_windows.cpp into per-architecture DLLs (x64 + ARM64). +REM +REM Prerequisites: Visual Studio Build Tools (MSVC) with Windows SDK, JAVA_HOME set. +REM Usage: build.bat + +setlocal enabledelayedexpansion + +set "SCRIPT_DIR=%~dp0" +set "SRC=%SCRIPT_DIR%nucleus_clipboard_windows.cpp" +set "RESOURCE_DIR=%SCRIPT_DIR%..\..\resources\nucleus\native" +set "OUT_DIR_X64=%RESOURCE_DIR%\win32-x64" +set "OUT_DIR_ARM64=%RESOURCE_DIR%\win32-aarch64" + +if "%JAVA_HOME%"=="" ( + echo ERROR: JAVA_HOME is not set. >&2 + exit /b 1 +) +if not exist "%JAVA_HOME%\include\jni.h" ( + echo ERROR: JNI headers not found at %JAVA_HOME%\include >&2 + exit /b 1 +) + +set "JNI_INCLUDE=%JAVA_HOME%\include" +set "JNI_INCLUDE_WIN32=%JAVA_HOME%\include\win32" + +set "VCVARSALL=" +for %%v in (2022 2019 2017) do ( + for %%e in (Enterprise Professional Community BuildTools) do ( + if exist "C:\Program Files\Microsoft Visual Studio\%%v\%%e\VC\Auxiliary\Build\vcvarsall.bat" ( + set "VCVARSALL=C:\Program Files\Microsoft Visual Studio\%%v\%%e\VC\Auxiliary\Build\vcvarsall.bat" + goto :found_vc + ) + if exist "C:\Program Files (x86)\Microsoft Visual Studio\%%v\%%e\VC\Auxiliary\Build\vcvarsall.bat" ( + set "VCVARSALL=C:\Program Files (x86)\Microsoft Visual Studio\%%v\%%e\VC\Auxiliary\Build\vcvarsall.bat" + goto :found_vc + ) + ) +) +:found_vc +if "%VCVARSALL%"=="" ( + echo ERROR: Could not locate vcvarsall.bat. Install Visual Studio Build Tools. >&2 + exit /b 1 +) + +echo Using vcvarsall.bat: %VCVARSALL% + +if not exist "%OUT_DIR_X64%" mkdir "%OUT_DIR_X64%" +if not exist "%OUT_DIR_ARM64%" mkdir "%OUT_DIR_ARM64%" + +REM ---- Compile x64 ---- +echo. +echo === Building x64 DLL === +setlocal +call "%VCVARSALL%" x64 +if errorlevel 1 ( + echo ERROR: vcvarsall x64 failed >&2 + exit /b 1 +) + +cl /LD /O1 /EHsc /std:c++17 /GS- /nologo ^ + /I"%JNI_INCLUDE%" /I"%JNI_INCLUDE_WIN32%" ^ + "%SRC%" ^ + /Fe:"%OUT_DIR_X64%\nucleus_clipboard_windows.dll" ^ + /link user32.lib kernel32.lib shell32.lib gdi32.lib gdiplus.lib ole32.lib +if errorlevel 1 ( + echo ERROR: x64 compilation failed >&2 + exit /b 1 +) +endlocal + +del /q "%OUT_DIR_X64%\*.obj" "%OUT_DIR_X64%\*.lib" "%OUT_DIR_X64%\*.exp" 2>nul +del /q "%SCRIPT_DIR%\*.obj" 2>nul + +REM ---- Compile ARM64 ---- +echo. +echo === Building ARM64 DLL === +setlocal +call "%VCVARSALL%" x64_arm64 +if errorlevel 1 ( + echo WARNING: vcvarsall x64_arm64 failed. ARM64 cross-compilation may not be available. >&2 + endlocal + goto :done +) + +cl /LD /O1 /EHsc /std:c++17 /GS- /nologo ^ + /I"%JNI_INCLUDE%" /I"%JNI_INCLUDE_WIN32%" ^ + "%SRC%" ^ + /Fe:"%OUT_DIR_ARM64%\nucleus_clipboard_windows.dll" ^ + /link user32.lib kernel32.lib shell32.lib gdi32.lib gdiplus.lib ole32.lib +if errorlevel 1 ( + echo WARNING: ARM64 compilation failed. >&2 + endlocal + goto :done +) +endlocal + +del /q "%OUT_DIR_ARM64%\*.obj" "%OUT_DIR_ARM64%\*.lib" "%OUT_DIR_ARM64%\*.exp" 2>nul +del /q "%SCRIPT_DIR%\*.obj" 2>nul + +:done +echo. +echo Built DLLs: +if exist "%OUT_DIR_X64%\nucleus_clipboard_windows.dll" echo %OUT_DIR_X64%\nucleus_clipboard_windows.dll +if exist "%OUT_DIR_ARM64%\nucleus_clipboard_windows.dll" echo %OUT_DIR_ARM64%\nucleus_clipboard_windows.dll + +if defined LOCALAPPDATA ( + set "CACHE_BASE=%LOCALAPPDATA%\nucleus\native" + if exist "!CACHE_BASE!\win32-x64\nucleus_clipboard_windows.dll" ( + del /q "!CACHE_BASE!\win32-x64\nucleus_clipboard_windows.dll" + echo Cleared x64 cache + ) + if exist "!CACHE_BASE!\win32-aarch64\nucleus_clipboard_windows.dll" ( + del /q "!CACHE_BASE!\win32-aarch64\nucleus_clipboard_windows.dll" + echo Cleared ARM64 cache + ) +) + +endlocal diff --git a/clipboard-windows/src/main/native/windows/nucleus_clipboard_windows.cpp b/clipboard-windows/src/main/native/windows/nucleus_clipboard_windows.cpp new file mode 100644 index 000000000..0f95b265a --- /dev/null +++ b/clipboard-windows/src/main/native/windows/nucleus_clipboard_windows.cpp @@ -0,0 +1,710 @@ +// JNI bridge for the Win32 system clipboard. +// +// Supports CF_UNICODETEXT, CF_HTML, "Rich Text Format", "PNG" + CF_DIBV5, +// and CF_HDROP (with a "Preferred DropEffect" companion). Every entry opens +// the clipboard with a bounded retry loop to ride out transient contention +// from rdpclip.exe and clipboard-history viewers. + +#define WIN32_LEAN_AND_MEAN +#define UNICODE +#define _UNICODE +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#pragma comment(lib, "user32.lib") +#pragma comment(lib, "gdi32.lib") +#pragma comment(lib, "gdiplus.lib") +#pragma comment(lib, "shell32.lib") +#pragma comment(lib, "ole32.lib") + +namespace { + +// --------------------------------------------------------------------------- +// GDI+ lifecycle — init once on DLL load, shut down on unload. +// --------------------------------------------------------------------------- + +ULONG_PTR g_gdiplusToken = 0; + +void initGdiplus() { + if (g_gdiplusToken != 0) return; + Gdiplus::GdiplusStartupInput input; + Gdiplus::GdiplusStartup(&g_gdiplusToken, &input, nullptr); +} + +void shutdownGdiplus() { + if (g_gdiplusToken != 0) { + Gdiplus::GdiplusShutdown(g_gdiplusToken); + g_gdiplusToken = 0; + } +} + +// --------------------------------------------------------------------------- +// Scoped clipboard open with retry. +// --------------------------------------------------------------------------- + +class ScopedClipboard { +public: + explicit ScopedClipboard(HWND owner = nullptr) { + for (int i = 0; i < 5; ++i) { + if (OpenClipboard(owner)) { + opened_ = true; + return; + } + Sleep(10); + } + } + ~ScopedClipboard() { if (opened_) CloseClipboard(); } + bool isOpen() const { return opened_; } +private: + bool opened_ = false; +}; + +// --------------------------------------------------------------------------- +// Registered format cache. +// --------------------------------------------------------------------------- + +UINT regFmt(const wchar_t* name) { + static struct { const wchar_t* n; UINT v; } cache[8] = {}; + for (auto& e : cache) { + if (e.n != nullptr && wcscmp(e.n, name) == 0) return e.v; + } + UINT v = RegisterClipboardFormatW(name); + for (auto& e : cache) { + if (e.n == nullptr) { e.n = name; e.v = v; break; } + } + return v; +} + +UINT cfHtml() { return regFmt(L"HTML Format"); } +UINT cfRtf() { return regFmt(L"Rich Text Format"); } +UINT cfPng() { return regFmt(L"PNG"); } +UINT cfPreferredDropEffect() { return regFmt(L"Preferred DropEffect"); } + +// --------------------------------------------------------------------------- +// JNI string helpers. +// --------------------------------------------------------------------------- + +std::wstring jstringToWString(JNIEnv* env, jstring jstr) { + if (jstr == nullptr) return {}; + const jchar* raw = env->GetStringChars(jstr, nullptr); + jsize len = env->GetStringLength(jstr); + std::wstring out(reinterpret_cast(raw), len); + env->ReleaseStringChars(jstr, raw); + return out; +} + +jstring wstringToJString(JNIEnv* env, const std::wstring& s) { + return env->NewString(reinterpret_cast(s.data()), static_cast(s.size())); +} + +jstring utf8ToJString(JNIEnv* env, const std::string& utf8) { + int wlen = MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), static_cast(utf8.size()), nullptr, 0); + std::wstring w(wlen, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), static_cast(utf8.size()), w.data(), wlen); + return wstringToJString(env, w); +} + +std::string wstringToUtf8(const std::wstring& w) { + if (w.empty()) return {}; + int len = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), static_cast(w.size()), nullptr, 0, nullptr, nullptr); + std::string out(len, '\0'); + WideCharToMultiByte(CP_UTF8, 0, w.c_str(), static_cast(w.size()), out.data(), len, nullptr, nullptr); + return out; +} + +jbyteArray bytesToJByteArray(JNIEnv* env, const void* data, size_t len) { + jbyteArray arr = env->NewByteArray(static_cast(len)); + if (arr == nullptr) return nullptr; + env->SetByteArrayRegion(arr, 0, static_cast(len), + reinterpret_cast(data)); + return arr; +} + +// --------------------------------------------------------------------------- +// HGLOBAL helpers for SetClipboardData payloads. +// --------------------------------------------------------------------------- + +HGLOBAL makeGlobal(const void* data, size_t len) { + HGLOBAL h = GlobalAlloc(GMEM_MOVEABLE | GMEM_ZEROINIT, len); + if (h == nullptr) return nullptr; + void* p = GlobalLock(h); + if (p == nullptr) { GlobalFree(h); return nullptr; } + memcpy(p, data, len); + GlobalUnlock(h); + return h; +} + +// --------------------------------------------------------------------------- +// CF_HTML wrapper — ASCII header + UTF-8 payload. +// learn.microsoft.com/en-us/windows/win32/dataxchg/html-clipboard-format +// --------------------------------------------------------------------------- + +std::string wrapCfHtml(const std::string& fragmentUtf8) { + // Placeholder header with 10-digit offsets — patched after assembly. + std::string prefix = + "Version:0.9\r\n" + "StartHTML:0000000000\r\n" + "EndHTML:0000000000\r\n" + "StartFragment:0000000000\r\n" + "EndFragment:0000000000\r\n"; + std::string open = "\r\n"; + std::string close = "\r\n"; + + std::string all = prefix + open + fragmentUtf8 + close; + + size_t startHtml = prefix.size(); + size_t startFragment = prefix.size() + open.size(); + size_t endFragment = startFragment + fragmentUtf8.size(); + size_t endHtml = all.size(); + + auto patch = [&](const char* key, size_t value) { + size_t pos = all.find(key); + if (pos == std::string::npos) return; + pos += strlen(key); + char buf[11]; + snprintf(buf, sizeof(buf), "%010zu", value); + memcpy(&all[pos], buf, 10); + }; + patch("StartHTML:", startHtml); + patch("EndHTML:", endHtml); + patch("StartFragment:", startFragment); + patch("EndFragment:", endFragment); + return all; +} + +// Extracts the fragment between StartFragment/EndFragment byte offsets. Falls +// back to the whole payload if the header is malformed. +std::string unwrapCfHtml(const std::string& raw) { + auto findNum = [&](const char* key) -> long long { + size_t p = raw.find(key); + if (p == std::string::npos) return -1; + p += strlen(key); + while (p < raw.size() && (raw[p] == ' ' || raw[p] == '\t')) ++p; + long long n = 0; bool any = false; + while (p < raw.size() && raw[p] >= '0' && raw[p] <= '9') { + n = n * 10 + (raw[p] - '0'); ++p; any = true; + } + return any ? n : -1; + }; + long long sf = findNum("StartFragment:"); + long long ef = findNum("EndFragment:"); + if (sf >= 0 && ef > sf && static_cast(ef) <= raw.size()) { + return raw.substr(static_cast(sf), + static_cast(ef - sf)); + } + return raw; +} + +// --------------------------------------------------------------------------- +// GDI+ image helpers — PNG ↔ DIB. +// --------------------------------------------------------------------------- + +// Loads PNG bytes into a GDI+ Bitmap. +std::unique_ptr pngToBitmap(const BYTE* data, size_t len) { + HGLOBAL h = GlobalAlloc(GMEM_MOVEABLE, len); + if (h == nullptr) return nullptr; + void* p = GlobalLock(h); + memcpy(p, data, len); + GlobalUnlock(h); + IStream* stream = nullptr; + if (CreateStreamOnHGlobal(h, TRUE, &stream) != S_OK) { GlobalFree(h); return nullptr; } + auto* bmp = Gdiplus::Bitmap::FromStream(stream); + stream->Release(); // also frees the HGLOBAL (fDeleteOnRelease=TRUE) + if (bmp == nullptr || bmp->GetLastStatus() != Gdiplus::Ok) { + delete bmp; return nullptr; + } + return std::unique_ptr(bmp); +} + +// Locates the GDI+ PNG encoder CLSID. +bool getPngEncoderClsid(CLSID& out) { + UINT num = 0, size = 0; + Gdiplus::GetImageEncodersSize(&num, &size); + if (size == 0) return false; + std::vector buf(size); + auto* infos = reinterpret_cast(buf.data()); + Gdiplus::GetImageEncoders(num, size, infos); + for (UINT i = 0; i < num; ++i) { + if (wcscmp(infos[i].MimeType, L"image/png") == 0) { + out = infos[i].Clsid; return true; + } + } + return false; +} + +// Saves a GDI+ Bitmap as PNG bytes. +std::vector bitmapToPng(Gdiplus::Bitmap& bmp) { + std::vector result; + CLSID clsid; + if (!getPngEncoderClsid(clsid)) return result; + IStream* stream = nullptr; + if (CreateStreamOnHGlobal(nullptr, TRUE, &stream) != S_OK) return result; + if (bmp.Save(stream, &clsid, nullptr) == Gdiplus::Ok) { + HGLOBAL h = nullptr; + if (GetHGlobalFromStream(stream, &h) == S_OK && h != nullptr) { + SIZE_T sz = GlobalSize(h); + void* p = GlobalLock(h); + if (p != nullptr) { + result.assign(static_cast(p), static_cast(p) + sz); + GlobalUnlock(h); + } + } + } + stream->Release(); + return result; +} + +// Decodes a CF_DIB / CF_DIBV5 buffer into a GDI+ Bitmap by prefixing a +// synthetic BITMAPFILEHEADER and feeding it as a BMP to GDI+. +std::unique_ptr dibToBitmap(const BYTE* dib, size_t dibLen) { + if (dibLen < sizeof(BITMAPINFOHEADER)) return nullptr; + const auto* bih = reinterpret_cast(dib); + DWORD hdrSize = bih->biSize; + DWORD palSize = 0; + if (bih->biBitCount <= 8) { + palSize = (bih->biClrUsed != 0 ? bih->biClrUsed + : (1u << bih->biBitCount)) * sizeof(RGBQUAD); + } else if (bih->biCompression == BI_BITFIELDS && hdrSize == sizeof(BITMAPINFOHEADER)) { + palSize = 3 * sizeof(DWORD); + } + DWORD pixelOffset = sizeof(BITMAPFILEHEADER) + hdrSize + palSize; + + std::vector bmp(sizeof(BITMAPFILEHEADER) + dibLen); + auto* bfh = reinterpret_cast(bmp.data()); + bfh->bfType = 0x4D42; + bfh->bfSize = static_cast(bmp.size()); + bfh->bfReserved1 = 0; + bfh->bfReserved2 = 0; + bfh->bfOffBits = pixelOffset; + memcpy(bmp.data() + sizeof(BITMAPFILEHEADER), dib, dibLen); + + HGLOBAL h = GlobalAlloc(GMEM_MOVEABLE, bmp.size()); + if (h == nullptr) return nullptr; + void* p = GlobalLock(h); + memcpy(p, bmp.data(), bmp.size()); + GlobalUnlock(h); + IStream* stream = nullptr; + if (CreateStreamOnHGlobal(h, TRUE, &stream) != S_OK) { GlobalFree(h); return nullptr; } + auto* b = Gdiplus::Bitmap::FromStream(stream); + stream->Release(); + if (b == nullptr || b->GetLastStatus() != Gdiplus::Ok) { + delete b; return nullptr; + } + return std::unique_ptr(b); +} + +// Converts a PNG-bearing GDI+ Bitmap into a DIBV5 byte buffer (BITMAPV5HEADER +// + 32-bpp premultiplied-safe BGRA rows, bottom-up). Applies the Chromium +// alpha sanitization (R>A||G>A||B>A → force opaque) which prevents Word from +// rendering black halos around semi-transparent pixels. +std::vector bitmapToDibV5(Gdiplus::Bitmap& bmp) { + std::vector out; + UINT w = bmp.GetWidth(), hgt = bmp.GetHeight(); + if (w == 0 || hgt == 0) return out; + + Gdiplus::Rect rect(0, 0, static_cast(w), static_cast(hgt)); + Gdiplus::BitmapData data{}; + if (bmp.LockBits(&rect, Gdiplus::ImageLockModeRead, + PixelFormat32bppARGB, &data) != Gdiplus::Ok) { + return out; + } + + size_t rowBytes = static_cast(w) * 4; + out.resize(sizeof(BITMAPV5HEADER) + rowBytes * hgt); + auto* hdr = reinterpret_cast(out.data()); + hdr->bV5Size = sizeof(BITMAPV5HEADER); + hdr->bV5Width = static_cast(w); + hdr->bV5Height = static_cast(hgt); // positive = bottom-up + hdr->bV5Planes = 1; + hdr->bV5BitCount = 32; + hdr->bV5Compression = BI_BITFIELDS; + hdr->bV5SizeImage = static_cast(rowBytes * hgt); + hdr->bV5RedMask = 0x00FF0000; + hdr->bV5GreenMask = 0x0000FF00; + hdr->bV5BlueMask = 0x000000FF; + hdr->bV5AlphaMask = 0xFF000000; + hdr->bV5CSType = 0x73524742; // 'sRGB' + hdr->bV5Intent = LCS_GM_GRAPHICS; + + BYTE* dst = out.data() + sizeof(BITMAPV5HEADER); + for (UINT y = 0; y < hgt; ++y) { + // Flip vertically (GDI+ scan0 is top-down; DIB is bottom-up). + const BYTE* src = static_cast(data.Scan0) + + static_cast(data.Stride) * (hgt - 1 - y); + BYTE* row = dst + y * rowBytes; + memcpy(row, src, rowBytes); + // Chromium fix: sanitize broken premultiplication. + for (UINT x = 0; x < w; ++x) { + BYTE* px = row + x * 4; // BGRA in memory, premul candidate + BYTE b = px[0], g = px[1], r = px[2], a = px[3]; + if (a == 0) continue; + if (r > a || g > a || b > a) { + px[3] = 0xFF; // force opaque + } + } + } + bmp.UnlockBits(&data); + return out; +} + +// --------------------------------------------------------------------------- +// Reads +// --------------------------------------------------------------------------- + +std::wstring readCfUnicodeText() { + ScopedClipboard lock; + if (!lock.isOpen()) return {}; + HANDLE h = GetClipboardData(CF_UNICODETEXT); + if (h == nullptr) return {}; + auto* p = static_cast(GlobalLock(h)); + if (p == nullptr) return {}; + std::wstring out(p); + GlobalUnlock(h); + return out; +} + +std::string readRegisteredAsBytes(UINT fmt) { + ScopedClipboard lock; + if (!lock.isOpen()) return {}; + HANDLE h = GetClipboardData(fmt); + if (h == nullptr) return {}; + SIZE_T sz = GlobalSize(h); + auto* p = static_cast(GlobalLock(h)); + if (p == nullptr) return {}; + // Strip trailing NUL padding, which CF_HTML and RTF producers commonly emit. + size_t n = sz; + while (n > 0 && p[n - 1] == '\0') --n; + std::string out(p, n); + GlobalUnlock(h); + return out; +} + +std::vector readRawFormat(UINT fmt) { + std::vector result; + ScopedClipboard lock; + if (!lock.isOpen()) return result; + HANDLE h = GetClipboardData(fmt); + if (h == nullptr) return result; + SIZE_T sz = GlobalSize(h); + auto* p = static_cast(GlobalLock(h)); + if (p == nullptr) return result; + result.assign(p, p + sz); + GlobalUnlock(h); + return result; +} + +std::vector readHdropPaths() { + std::vector out; + ScopedClipboard lock; + if (!lock.isOpen()) return out; + HANDLE h = GetClipboardData(CF_HDROP); + if (h == nullptr) return out; + auto hDrop = static_cast(h); + UINT count = DragQueryFileW(hDrop, 0xFFFFFFFF, nullptr, 0); + for (UINT i = 0; i < count; ++i) { + UINT len = DragQueryFileW(hDrop, i, nullptr, 0); + std::wstring path(len, L'\0'); + DragQueryFileW(hDrop, i, path.data(), len + 1); + out.push_back(std::move(path)); + } + return out; +} + +// --------------------------------------------------------------------------- +// Write — atomic multi-format. +// --------------------------------------------------------------------------- + +// Builds a CF_HDROP HGLOBAL from wide paths. Caller loses ownership on success. +HGLOBAL buildHdrop(const std::vector& paths) { + // Layout: DROPFILES + path1\0 path2\0 ... \0 + size_t pathChars = 1; // trailing extra NUL + for (const auto& p : paths) pathChars += p.size() + 1; + size_t total = sizeof(DROPFILES) + pathChars * sizeof(wchar_t); + HGLOBAL h = GlobalAlloc(GMEM_MOVEABLE | GMEM_ZEROINIT, total); + if (h == nullptr) return nullptr; + auto* df = static_cast(GlobalLock(h)); + df->pFiles = sizeof(DROPFILES); + df->fWide = TRUE; + auto* dst = reinterpret_cast(reinterpret_cast(df) + sizeof(DROPFILES)); + for (const auto& p : paths) { + memcpy(dst, p.c_str(), (p.size() + 1) * sizeof(wchar_t)); + dst += p.size() + 1; + } + *dst = L'\0'; + GlobalUnlock(h); + return h; +} + +} // anonymous namespace + +// =========================================================================== +// DllMain — init GDI+. +// =========================================================================== + +BOOL APIENTRY DllMain(HMODULE, DWORD reason, LPVOID) { + switch (reason) { + case DLL_PROCESS_ATTACH: initGdiplus(); break; + case DLL_PROCESS_DETACH: shutdownGdiplus(); break; + default: break; + } + return TRUE; +} + +// =========================================================================== +// JNI entry points +// =========================================================================== + +extern "C" { + +JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM*, void*) { + initGdiplus(); + return JNI_VERSION_1_8; +} + +JNIEXPORT jlong JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_windows_NativeWindowsClipboardBridge_nativeChangeCount( + JNIEnv*, jclass) { + return static_cast(GetClipboardSequenceNumber()); +} + +JNIEXPORT jobjectArray JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_windows_NativeWindowsClipboardBridge_nativeAvailableFormats( + JNIEnv* env, jclass) { + std::vector names; + { + ScopedClipboard lock; + if (!lock.isOpen()) { + jclass sc = env->FindClass("java/lang/String"); + return env->NewObjectArray(0, sc, nullptr); + } + UINT fmt = 0; + while ((fmt = EnumClipboardFormats(fmt)) != 0) { + const wchar_t* builtin = nullptr; + switch (fmt) { + case CF_TEXT: builtin = L"CF_TEXT"; break; + case CF_BITMAP: builtin = L"CF_BITMAP"; break; + case CF_METAFILEPICT: builtin = L"CF_METAFILEPICT"; break; + case CF_SYLK: builtin = L"CF_SYLK"; break; + case CF_DIF: builtin = L"CF_DIF"; break; + case CF_TIFF: builtin = L"CF_TIFF"; break; + case CF_OEMTEXT: builtin = L"CF_OEMTEXT"; break; + case CF_DIB: builtin = L"CF_DIB"; break; + case CF_PALETTE: builtin = L"CF_PALETTE"; break; + case CF_PENDATA: builtin = L"CF_PENDATA"; break; + case CF_RIFF: builtin = L"CF_RIFF"; break; + case CF_WAVE: builtin = L"CF_WAVE"; break; + case CF_UNICODETEXT: builtin = L"CF_UNICODETEXT"; break; + case CF_ENHMETAFILE: builtin = L"CF_ENHMETAFILE"; break; + case CF_HDROP: builtin = L"CF_HDROP"; break; + case CF_LOCALE: builtin = L"CF_LOCALE"; break; + case CF_DIBV5: builtin = L"CF_DIBV5"; break; + default: break; + } + if (builtin != nullptr) { + names.emplace_back(builtin); + } else { + wchar_t buf[256] = {}; + int n = GetClipboardFormatNameW(fmt, buf, 256); + if (n > 0) names.emplace_back(buf, n); + } + } + } + jclass sc = env->FindClass("java/lang/String"); + jobjectArray arr = env->NewObjectArray(static_cast(names.size()), sc, nullptr); + for (size_t i = 0; i < names.size(); ++i) { + jstring js = wstringToJString(env, names[i]); + env->SetObjectArrayElement(arr, static_cast(i), js); + env->DeleteLocalRef(js); + } + return arr; +} + +JNIEXPORT jstring JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_windows_NativeWindowsClipboardBridge_nativeReadText( + JNIEnv* env, jclass) { + std::wstring w = readCfUnicodeText(); + if (w.empty()) { + // IsClipboardFormatAvailable is cheap and avoids returning "" when the + // clipboard is truly empty. + if (!IsClipboardFormatAvailable(CF_UNICODETEXT)) return nullptr; + } + return wstringToJString(env, w); +} + +JNIEXPORT jstring JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_windows_NativeWindowsClipboardBridge_nativeReadHtml( + JNIEnv* env, jclass) { + if (!IsClipboardFormatAvailable(cfHtml())) return nullptr; + std::string raw = readRegisteredAsBytes(cfHtml()); + if (raw.empty()) return nullptr; + std::string fragment = unwrapCfHtml(raw); + return utf8ToJString(env, fragment); +} + +JNIEXPORT jstring JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_windows_NativeWindowsClipboardBridge_nativeReadRtf( + JNIEnv* env, jclass) { + if (!IsClipboardFormatAvailable(cfRtf())) return nullptr; + std::string raw = readRegisteredAsBytes(cfRtf()); + if (raw.empty()) return nullptr; + // RTF is pure ASCII with \uXXXX escapes — UTF-8 decodes it unchanged. + return utf8ToJString(env, raw); +} + +JNIEXPORT jbyteArray JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_windows_NativeWindowsClipboardBridge_nativeReadImagePng( + JNIEnv* env, jclass) { + // 1. Prefer the "PNG" registered format — no transcoding. + if (IsClipboardFormatAvailable(cfPng())) { + std::vector png = readRawFormat(cfPng()); + if (!png.empty()) return bytesToJByteArray(env, png.data(), png.size()); + } + // 2. Fall back to DIBv5/DIB transcoded via GDI+. + UINT fmt = 0; + if (IsClipboardFormatAvailable(CF_DIBV5)) fmt = CF_DIBV5; + else if (IsClipboardFormatAvailable(CF_DIB)) fmt = CF_DIB; + if (fmt == 0) return nullptr; + + std::vector dib = readRawFormat(fmt); + if (dib.empty()) return nullptr; + auto bmp = dibToBitmap(dib.data(), dib.size()); + if (!bmp) return nullptr; + std::vector png = bitmapToPng(*bmp); + if (png.empty()) return nullptr; + return bytesToJByteArray(env, png.data(), png.size()); +} + +JNIEXPORT jobjectArray JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_windows_NativeWindowsClipboardBridge_nativeReadFilePaths( + JNIEnv* env, jclass) { + std::vector paths = readHdropPaths(); + jclass sc = env->FindClass("java/lang/String"); + jobjectArray arr = env->NewObjectArray(static_cast(paths.size()), sc, nullptr); + for (size_t i = 0; i < paths.size(); ++i) { + jstring js = wstringToJString(env, paths[i]); + env->SetObjectArrayElement(arr, static_cast(i), js); + env->DeleteLocalRef(js); + } + return arr; +} + +JNIEXPORT jboolean JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_windows_NativeWindowsClipboardBridge_nativeWrite( + JNIEnv* env, jclass, + jstring jText, jstring jHtml, jstring jRtf, jbyteArray jPng, jobjectArray jPaths) { + + // Pre-convert Java arguments while no clipboard lock is held. + std::wstring text = jstringToWString(env, jText); + std::wstring html = jstringToWString(env, jHtml); + std::wstring rtf = jstringToWString(env, jRtf); + + std::vector pngBytes; + if (jPng != nullptr) { + jsize n = env->GetArrayLength(jPng); + pngBytes.resize(n); + env->GetByteArrayRegion(jPng, 0, n, reinterpret_cast(pngBytes.data())); + } + + std::vector paths; + if (jPaths != nullptr) { + jsize n = env->GetArrayLength(jPaths); + paths.reserve(n); + for (jsize i = 0; i < n; ++i) { + auto js = static_cast(env->GetObjectArrayElement(jPaths, i)); + paths.push_back(jstringToWString(env, js)); + if (js != nullptr) env->DeleteLocalRef(js); + } + } + + // Prebuild DIBV5 from PNG when both present. + std::vector dibv5; + if (!pngBytes.empty()) { + auto bmp = pngToBitmap(pngBytes.data(), pngBytes.size()); + if (bmp) dibv5 = bitmapToDibV5(*bmp); + } + + ScopedClipboard lock; + if (!lock.isOpen()) return JNI_FALSE; + EmptyClipboard(); + + bool anyPublished = false; + + // 1. Image first so compatible consumers (Word) see it before text. + if (!pngBytes.empty()) { + if (HGLOBAL h = makeGlobal(pngBytes.data(), pngBytes.size())) { + if (SetClipboardData(cfPng(), h) == nullptr) GlobalFree(h); + else anyPublished = true; + } + if (!dibv5.empty()) { + if (HGLOBAL h = makeGlobal(dibv5.data(), dibv5.size())) { + if (SetClipboardData(CF_DIBV5, h) == nullptr) GlobalFree(h); + else anyPublished = true; + } + } + } + + // 2. HTML — UTF-8 payload wrapped in the ASCII CF_HTML header. + if (!html.empty()) { + std::string fragment = wstringToUtf8(html); + std::string wrapped = wrapCfHtml(fragment); + // Include terminating NUL — most consumers expect a C-string payload. + if (HGLOBAL h = makeGlobal(wrapped.c_str(), wrapped.size() + 1)) { + if (SetClipboardData(cfHtml(), h) == nullptr) GlobalFree(h); + else anyPublished = true; + } + } + + // 3. RTF. + if (!rtf.empty()) { + std::string bytes = wstringToUtf8(rtf); + if (HGLOBAL h = makeGlobal(bytes.c_str(), bytes.size() + 1)) { + if (SetClipboardData(cfRtf(), h) == nullptr) GlobalFree(h); + else anyPublished = true; + } + } + + // 4. Plain text. Write CF_UNICODETEXT only — the OS synthesizes CF_TEXT / + // CF_OEMTEXT on demand via implicit conversion. + if (!text.empty()) { + size_t bytes = (text.size() + 1) * sizeof(wchar_t); + if (HGLOBAL h = makeGlobal(text.c_str(), bytes)) { + if (SetClipboardData(CF_UNICODETEXT, h) == nullptr) GlobalFree(h); + else anyPublished = true; + } + } + + // 5. Files: CF_HDROP + "Preferred DropEffect" (DROPEFFECT_COPY). + if (!paths.empty()) { + if (HGLOBAL h = buildHdrop(paths)) { + if (SetClipboardData(CF_HDROP, h) == nullptr) GlobalFree(h); + else anyPublished = true; + } + DWORD effect = DROPEFFECT_COPY; + if (HGLOBAL h = makeGlobal(&effect, sizeof(effect))) { + if (SetClipboardData(cfPreferredDropEffect(), h) == nullptr) GlobalFree(h); + } + } + + return anyPublished ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT jboolean JNICALL +Java_io_github_kdroidfilter_nucleus_clipboard_windows_NativeWindowsClipboardBridge_nativeClear( + JNIEnv*, jclass) { + ScopedClipboard lock; + if (!lock.isOpen()) return JNI_FALSE; + return EmptyClipboard() ? JNI_TRUE : JNI_FALSE; +} + +} // extern "C" diff --git a/clipboard-windows/src/main/resources/META-INF/native-image/io.github.kdroidfilter/nucleus.clipboard-windows/reachability-metadata.json b/clipboard-windows/src/main/resources/META-INF/native-image/io.github.kdroidfilter/nucleus.clipboard-windows/reachability-metadata.json new file mode 100644 index 000000000..941e93f38 --- /dev/null +++ b/clipboard-windows/src/main/resources/META-INF/native-image/io.github.kdroidfilter/nucleus.clipboard-windows/reachability-metadata.json @@ -0,0 +1,8 @@ +{ + "reflection": [ + { + "type": "io.github.kdroidfilter.nucleus.clipboard.windows.NativeWindowsClipboardBridge", + "jniAccessible": true + } + ] +} diff --git a/clipboard-windows/src/main/resources/META-INF/services/io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardBackend b/clipboard-windows/src/main/resources/META-INF/services/io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardBackend new file mode 100644 index 000000000..9a8283130 --- /dev/null +++ b/clipboard-windows/src/main/resources/META-INF/services/io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardBackend @@ -0,0 +1 @@ +io.github.kdroidfilter.nucleus.clipboard.windows.WindowsClipboardBackend diff --git a/example/build.gradle.kts b/example/build.gradle.kts index d008f4c23..8bb805837 100644 --- a/example/build.gradle.kts +++ b/example/build.gradle.kts @@ -43,6 +43,7 @@ dependencies { implementation(project(":clipboard-common")) implementation(project(":clipboard-macos")) implementation(project(":clipboard-linux")) + implementation(project(":clipboard-windows")) implementation(project(":sf-symbols")) implementation(project(":media-control")) implementation(libs.coroutines.swing) diff --git a/settings.gradle.kts b/settings.gradle.kts index 7c251d0ca..e86b22704 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -65,6 +65,7 @@ include(":menu-macos") include(":clipboard-common") include(":clipboard-macos") include(":clipboard-linux") +include(":clipboard-windows") include(":freedesktop-icons") include(":sf-symbols") include(":system-info")