Skip to content

Commit d219664

Browse files
committed
feat(clipboard): add rich cross-platform clipboard with macOS backend
- 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<ClipboardEvent> 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.
1 parent d02c969 commit d219664

29 files changed

Lines changed: 2048 additions & 26 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ Each module is published independently to Maven Central — use them together or
108108
| `nucleus.launcher-macos` | macOS Dock API — badge, menus |
109109
| `nucleus.launcher-windows` | Windows taskbar — badges, jump lists, overlay icons, thumbnail toolbar |
110110
| `nucleus.launcher-linux` | Unity Launcher — badge, progress, urgency, quicklist |
111+
| `nucleus.clipboard-common` | Cross-platform clipboard — text, HTML, RTF, images, files + change watcher |
112+
| `nucleus.clipboard-macos` | macOS `NSPasteboard` backend for the clipboard API |
111113
| `nucleus.media-control` | OS media controls — MPRIS (Linux), Now Playing (macOS), SMTC (Windows) |
112114
| `nucleus.menu-macos` | Native macOS menu bar |
113115
| `nucleus.freedesktop-icons` | Type-safe freedesktop icon naming constants |

clipboard-common/build.gradle.kts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
2+
3+
plugins {
4+
kotlin("jvm")
5+
alias(libs.plugins.vanniktechMavenPublish)
6+
}
7+
8+
val publishVersion =
9+
providers
10+
.environmentVariable("GITHUB_REF")
11+
.orNull
12+
?.removePrefix("refs/tags/v")
13+
?: "1.0.0"
14+
15+
dependencies {
16+
implementation(project(":core-runtime"))
17+
implementation(libs.coroutines.core)
18+
testImplementation(kotlin("test"))
19+
}
20+
21+
java {
22+
sourceCompatibility = JavaVersion.VERSION_11
23+
targetCompatibility = JavaVersion.VERSION_11
24+
}
25+
26+
kotlin {
27+
compilerOptions {
28+
jvmTarget.set(JvmTarget.JVM_11)
29+
}
30+
}
31+
32+
mavenPublishing {
33+
coordinates("io.github.kdroidfilter", "nucleus.clipboard-common", publishVersion)
34+
35+
pom {
36+
name.set("Nucleus Clipboard Common")
37+
description.set("Cross-platform rich clipboard API (text, HTML, RTF, images, files) with change watcher")
38+
url.set("https://github.com/kdroidFilter/Nucleus")
39+
40+
licenses {
41+
license {
42+
name.set("MIT License")
43+
url.set("https://opensource.org/licenses/MIT")
44+
}
45+
}
46+
47+
developers {
48+
developer {
49+
id.set("kdroidfilter")
50+
name.set("kdroidFilter")
51+
url.set("https://github.com/kdroidFilter")
52+
}
53+
}
54+
55+
scm {
56+
url.set("https://github.com/kdroidFilter/Nucleus")
57+
connection.set("scm:git:git://github.com/kdroidFilter/Nucleus.git")
58+
developerConnection.set("scm:git:ssh://git@github.com/kdroidFilter/Nucleus.git")
59+
}
60+
}
61+
62+
publishToMavenCentral()
63+
if (project.hasProperty("signingInMemoryKey")) {
64+
signAllPublications()
65+
}
66+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package io.github.kdroidfilter.nucleus.clipboard
2+
3+
/**
4+
* Policy for background pasteboard access — maps to macOS 15.4+ `NSPasteboard.AccessBehavior`.
5+
* No-op on platforms without a privacy model.
6+
*/
7+
enum class AccessBehavior {
8+
/** Read freely (legacy macOS default, still the default on other platforms). */
9+
AlwaysAllow,
10+
11+
/** Show a permission prompt on every read of pasteboard contents. */
12+
AskEveryTime,
13+
14+
/** Deny programmatic reads without user interaction. */
15+
AlwaysDeny,
16+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package io.github.kdroidfilter.nucleus.clipboard
2+
3+
import io.github.kdroidfilter.nucleus.clipboard.internal.BackendFactory
4+
import io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardBackend
5+
import io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardWritePayload
6+
import kotlinx.coroutines.channels.awaitClose
7+
import kotlinx.coroutines.delay
8+
import kotlinx.coroutines.flow.Flow
9+
import kotlinx.coroutines.flow.callbackFlow
10+
import kotlinx.coroutines.isActive
11+
import kotlinx.coroutines.launch
12+
import java.nio.file.Path
13+
import kotlin.time.Duration
14+
import kotlin.time.Duration.Companion.milliseconds
15+
16+
private val DEFAULT_POLL_INTERVAL: Duration = 250.milliseconds
17+
18+
/**
19+
* Rich cross-platform clipboard façade.
20+
*
21+
* Reads and writes text, HTML, RTF, images, and file lists. Emits change events
22+
* via [watch] as a cold [Flow]. Delegates to the first available platform backend
23+
* discovered through [java.util.ServiceLoader] — on macOS that's
24+
* `nucleus.clipboard-macos`; when no backend is loaded, all methods degrade to
25+
* no-ops returning `null` / `false`.
26+
*
27+
* All suspending methods hop to the backend's native thread internally; callers
28+
* may invoke them from any coroutine context.
29+
*/
30+
@Suppress("TooManyFunctions")
31+
object Clipboard {
32+
private val backend: ClipboardBackend by lazy { BackendFactory.discover() }
33+
34+
/** True when a platform backend is loaded and operational. */
35+
val isAvailable: Boolean get() = backend.isAvailable()
36+
37+
/** Backend name for diagnostics (e.g. `"macOS NSPasteboard"` or `"no-op"`). */
38+
val backendName: String get() = backend.name
39+
40+
// --- Read ---
41+
42+
suspend fun readText(): String? = backend.readText()
43+
44+
suspend fun readHtml(): String? = backend.readHtml()
45+
46+
suspend fun readRtf(): String? = backend.readRtf()
47+
48+
/** Returns PNG-encoded bytes. Use your preferred decoder (`ImageIO.read` for `BufferedImage`). */
49+
suspend fun readImageBytes(): ByteArray? = backend.readImagePng()
50+
51+
suspend fun readFiles(): List<Path> = backend.readFiles()
52+
53+
/**
54+
* Returns the set of formats currently advertised by the clipboard without
55+
* reading any content bytes. Safe to call under restrictive pasteboard-privacy
56+
* policies (macOS 15.4+).
57+
*/
58+
suspend fun availableFormats(): Set<ClipboardFormat> = backend.availableFormats()
59+
60+
// --- Write ---
61+
62+
suspend fun writeText(text: String): Boolean =
63+
backend.write(ClipboardWritePayload(text = text, html = null, rtf = null, imagePng = null, files = null))
64+
65+
suspend fun writeHtml(
66+
html: String,
67+
plainTextFallback: String? = null,
68+
): Boolean =
69+
backend.write(
70+
ClipboardWritePayload(
71+
text = plainTextFallback,
72+
html = html,
73+
rtf = null,
74+
imagePng = null,
75+
files = null,
76+
),
77+
)
78+
79+
suspend fun writeRtf(
80+
rtf: String,
81+
plainTextFallback: String? = null,
82+
): Boolean =
83+
backend.write(
84+
ClipboardWritePayload(
85+
text = plainTextFallback,
86+
html = null,
87+
rtf = rtf,
88+
imagePng = null,
89+
files = null,
90+
),
91+
)
92+
93+
suspend fun writeImage(png: ByteArray): Boolean =
94+
backend.write(ClipboardWritePayload(text = null, html = null, rtf = null, imagePng = png, files = null))
95+
96+
suspend fun writeFiles(paths: List<Path>): Boolean =
97+
backend.write(ClipboardWritePayload(text = null, html = null, rtf = null, imagePng = null, files = paths))
98+
99+
/**
100+
* Atomic multi-format write. Richer representations (HTML, RTF, image) coexist
101+
* with a plain-text fallback on the same clipboard item.
102+
*
103+
* ```
104+
* Clipboard.write {
105+
* html = "<b>Hello</b>"
106+
* text = "Hello"
107+
* }
108+
* ```
109+
*/
110+
suspend fun write(block: ClipboardWriteScope.() -> Unit): Boolean {
111+
val scope = ClipboardWriteScope().apply(block)
112+
val payload = scope.toPayload()
113+
if (payload.isEmpty) return false
114+
return backend.write(payload)
115+
}
116+
117+
suspend fun clear(): Boolean = backend.clear()
118+
119+
/** Sets the platform privacy policy for background reads. No-op outside macOS 15.4+. */
120+
fun setAccessBehavior(behavior: AccessBehavior) = backend.setAccessBehavior(behavior)
121+
122+
/**
123+
* Cold [Flow] that emits whenever the clipboard contents change.
124+
*
125+
* The event carries only format metadata and a monotonic `changeCount`, not
126+
* payload bytes — call a `readXxx` method to fetch content. Polls the backend's
127+
* change counter (cheap Mach IPC on macOS, WM_CLIPBOARDUPDATE on Windows,
128+
* XFixes on X11, data-control events on Wayland).
129+
*
130+
* The baseline is captured on `collect`, so the flow does not emit the current
131+
* clipboard state — only subsequent changes.
132+
*/
133+
fun watch(pollInterval: Duration = DEFAULT_POLL_INTERVAL): Flow<ClipboardEvent> =
134+
callbackFlow {
135+
var last = backend.changeCount()
136+
val job =
137+
launch {
138+
while (isActive) {
139+
delay(pollInterval)
140+
val cur = backend.changeCount()
141+
if (cur != last) {
142+
last = cur
143+
trySend(ClipboardEvent(formats = availableFormats(), changeCount = cur))
144+
}
145+
}
146+
}
147+
awaitClose { job.cancel() }
148+
}
149+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package io.github.kdroidfilter.nucleus.clipboard
2+
3+
/**
4+
* Change notification emitted by [Clipboard.watch].
5+
*
6+
* Carries only metadata — no payload bytes are read from the system pasteboard,
7+
* which keeps the watcher outside the scope of macOS 15.4+ pasteboard-privacy
8+
* prompts. Call [Clipboard.readText] / [Clipboard.readImageBytes] / ... to fetch
9+
* the actual content on demand.
10+
*/
11+
data class ClipboardEvent(
12+
/** Formats currently advertised on the clipboard. */
13+
val formats: Set<ClipboardFormat>,
14+
/** Backend-provided monotonic counter. Useful to deduplicate self-writes. */
15+
val changeCount: Long,
16+
)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package io.github.kdroidfilter.nucleus.clipboard
2+
3+
/** Content categories advertised on the clipboard. */
4+
enum class ClipboardFormat {
5+
Text,
6+
Html,
7+
Rtf,
8+
Image,
9+
Files,
10+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package io.github.kdroidfilter.nucleus.clipboard
2+
3+
import io.github.kdroidfilter.nucleus.clipboard.internal.ClipboardWritePayload
4+
import java.nio.file.Path
5+
6+
/**
7+
* Builder for a multi-format clipboard write. Any non-null property is published
8+
* atomically on the same pasteboard item, so consumers can pick the richest
9+
* representation they understand.
10+
*/
11+
class ClipboardWriteScope internal constructor() {
12+
/** UTF-8 plain text. Written as `public.utf8-plain-text` on macOS. */
13+
var text: String? = null
14+
15+
/** UTF-8 HTML fragment. Written as `public.html` on macOS (no CF_HTML wrapper). */
16+
var html: String? = null
17+
18+
/** UTF-8 RTF payload. Written as `public.rtf` on macOS. */
19+
var rtf: String? = null
20+
21+
/** PNG-encoded image bytes. Published alongside a TIFF representation on macOS. */
22+
var imagePng: ByteArray? = null
23+
24+
/** Absolute file paths. Written as `public.file-url` NSURLs on macOS. */
25+
var files: List<Path>? = null
26+
27+
internal fun toPayload(): ClipboardWritePayload =
28+
ClipboardWritePayload(
29+
text = text,
30+
html = html,
31+
rtf = rtf,
32+
imagePng = imagePng,
33+
files = files,
34+
)
35+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package io.github.kdroidfilter.nucleus.clipboard.internal
2+
3+
import java.util.ServiceLoader
4+
import java.util.logging.Level
5+
import java.util.logging.Logger
6+
7+
internal object BackendFactory {
8+
private val logger = Logger.getLogger(BackendFactory::class.java.simpleName)
9+
10+
fun discover(): ClipboardBackend {
11+
val loader = ServiceLoader.load(ClipboardBackend::class.java, ClipboardBackend::class.java.classLoader)
12+
for (candidate in loader) {
13+
try {
14+
if (candidate.isAvailable()) {
15+
logger.fine("Clipboard backend selected: ${candidate.name}")
16+
return candidate
17+
}
18+
} catch (
19+
@Suppress("TooGenericExceptionCaught") e: RuntimeException,
20+
) {
21+
logger.log(Level.WARNING, "Clipboard backend ${candidate.name} failed probe", e)
22+
}
23+
}
24+
logger.fine("No clipboard backend available — falling back to no-op")
25+
return NoOpBackend
26+
}
27+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package io.github.kdroidfilter.nucleus.clipboard.internal
2+
3+
import io.github.kdroidfilter.nucleus.clipboard.AccessBehavior
4+
import io.github.kdroidfilter.nucleus.clipboard.ClipboardFormat
5+
import java.nio.file.Path
6+
7+
/**
8+
* SPI implemented by each platform clipboard backend (macOS, Windows, Linux X11, Wayland).
9+
* Discovered via [java.util.ServiceLoader].
10+
*
11+
* All methods are side-effect-free on non-matching platforms (the backend's
12+
* [isAvailable] returns `false`). The facade in [io.github.kdroidfilter.nucleus.clipboard.Clipboard]
13+
* picks the first available backend and delegates.
14+
*/
15+
interface ClipboardBackend {
16+
/** Human-readable backend name, for logging. */
17+
val name: String
18+
19+
/** True when the backend can service requests on this OS and process. */
20+
fun isAvailable(): Boolean
21+
22+
suspend fun readText(): String?
23+
24+
suspend fun readHtml(): String?
25+
26+
suspend fun readRtf(): String?
27+
28+
/** PNG-encoded bytes, or null if no image is available. */
29+
suspend fun readImagePng(): ByteArray?
30+
31+
suspend fun readFiles(): List<Path>
32+
33+
/** Formats currently advertised without reading bytes — safe under macOS 15.4+ privacy. */
34+
suspend fun availableFormats(): Set<ClipboardFormat>
35+
36+
/** Writes the payload atomically. Returns true on success. */
37+
suspend fun write(payload: ClipboardWritePayload): Boolean
38+
39+
/** Clears the clipboard. */
40+
suspend fun clear(): Boolean
41+
42+
/**
43+
* Monotonic change counter, used by the watcher. Must not open/read the clipboard —
44+
* on macOS this is `NSPasteboard.changeCount`, a cheap Mach IPC call.
45+
*/
46+
fun changeCount(): Long
47+
48+
/** Sets the platform privacy policy (macOS 15.4+). No-op elsewhere. */
49+
fun setAccessBehavior(behavior: AccessBehavior)
50+
}

0 commit comments

Comments
 (0)