Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
66 changes: 66 additions & 0 deletions clipboard-common/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
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<Path> = 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<ClipboardFormat> = 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<Path>): 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 = "<b>Hello</b>"
* 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)

/**
* Currently effective privacy policy, or `null` when the host OS does not
* expose one (macOS &lt; 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.
*
* The event carries only format metadata and a monotonic `changeCount`, not
* 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.
*/
fun watch(pollInterval: Duration = DEFAULT_POLL_INTERVAL): Flow<ClipboardEvent> =
callbackFlow {
var last = backend.changeCount()
val job =
launch {
while (isActive) {
delay(pollInterval)
val cur = backend.changeCount()
if (cur != last) {
last = 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))
}
}
}
}
awaitClose { job.cancel() }
}
}
Original file line number Diff line number Diff line change
@@ -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<ClipboardFormat>,
/** Backend-provided monotonic counter. Useful to deduplicate self-writes. */
val changeCount: Long,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package io.github.kdroidfilter.nucleus.clipboard

/** Content categories advertised on the clipboard. */
enum class ClipboardFormat {
Text,
Html,
Rtf,
Image,
Files,
}
Original file line number Diff line number Diff line change
@@ -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<Path>? = null

internal fun toPayload(): ClipboardWritePayload =
ClipboardWritePayload(
text = text,
html = html,
rtf = rtf,
imagePng = imagePng,
files = files,
)
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading