Skip to content

Commit 60635b0

Browse files
committed
feat(clipboard): expose macOS 15.4+ access-behavior getter
- 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).
1 parent d219664 commit 60635b0

9 files changed

Lines changed: 138 additions & 2 deletions

File tree

clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/Clipboard.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,15 @@ object Clipboard {
119119
/** Sets the platform privacy policy for background reads. No-op outside macOS 15.4+. */
120120
fun setAccessBehavior(behavior: AccessBehavior) = backend.setAccessBehavior(behavior)
121121

122+
/**
123+
* Currently effective privacy policy, or `null` when the host OS does not
124+
* expose one (macOS < 15.4, Windows, Linux — treat as unrestricted).
125+
*/
126+
val accessBehavior: AccessBehavior? get() = backend.accessBehavior()
127+
128+
/** True when [setAccessBehavior] and [accessBehavior] are honored (macOS 15.4+). */
129+
val isAccessBehaviorSupported: Boolean get() = backend.isAccessBehaviorSupported()
130+
122131
/**
123132
* Cold [Flow] that emits whenever the clipboard contents change.
124133
*

clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/internal/ClipboardBackend.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import java.nio.file.Path
1212
* [isAvailable] returns `false`). The facade in [io.github.kdroidfilter.nucleus.clipboard.Clipboard]
1313
* picks the first available backend and delegates.
1414
*/
15+
@Suppress("TooManyFunctions")
1516
interface ClipboardBackend {
1617
/** Human-readable backend name, for logging. */
1718
val name: String
@@ -47,4 +48,15 @@ interface ClipboardBackend {
4748

4849
/** Sets the platform privacy policy (macOS 15.4+). No-op elsewhere. */
4950
fun setAccessBehavior(behavior: AccessBehavior)
51+
52+
/**
53+
* Reads the currently effective privacy policy.
54+
*
55+
* Returns `null` when the host OS has no such concept (macOS < 15.4,
56+
* Windows, Linux) — callers should interpret this as "unrestricted access".
57+
*/
58+
fun accessBehavior(): AccessBehavior?
59+
60+
/** True when [setAccessBehavior] and [accessBehavior] are honored by the backend. */
61+
fun isAccessBehaviorSupported(): Boolean
5062
}

clipboard-common/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/internal/NoOpBackend.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import io.github.kdroidfilter.nucleus.clipboard.AccessBehavior
44
import io.github.kdroidfilter.nucleus.clipboard.ClipboardFormat
55
import java.nio.file.Path
66

7+
@Suppress("TooManyFunctions")
78
internal object NoOpBackend : ClipboardBackend {
89
override val name: String = "no-op"
910

@@ -28,4 +29,8 @@ internal object NoOpBackend : ClipboardBackend {
2829
override fun changeCount(): Long = 0L
2930

3031
override fun setAccessBehavior(behavior: AccessBehavior) = Unit
32+
33+
override fun accessBehavior(): AccessBehavior? = null
34+
35+
override fun isAccessBehaviorSupported(): Boolean = false
3136
}

clipboard-macos/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/macos/MacClipboardBackend.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,15 @@ class MacClipboardBackend : ClipboardBackend {
7979
NativeMacClipboardBridge.nativeSetAccessBehavior(behavior.ordinal)
8080
}
8181

82+
override fun accessBehavior(): AccessBehavior? {
83+
if (!NativeMacClipboardBridge.isLoaded) return null
84+
val raw = NativeMacClipboardBridge.nativeGetAccessBehavior()
85+
return AccessBehavior.entries.getOrNull(raw)
86+
}
87+
88+
override fun isAccessBehaviorSupported(): Boolean =
89+
NativeMacClipboardBridge.isLoaded && NativeMacClipboardBridge.nativeIsAccessBehaviorSupported()
90+
8291
/**
8392
* Strips a leading UTF-8 BOM (`\uFEFF`) that Firefox and some web apps emit on
8493
* `public.html` payloads. Keeps the rest untouched.

clipboard-macos/src/main/kotlin/io/github/kdroidfilter/nucleus/clipboard/macos/NativeMacClipboardBridge.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ private const val LIBRARY_NAME = "nucleus_clipboard"
99
* pasteboard and are safe to call from any thread (NSPasteboard is thread-safe,
1010
* and the ObjC side wraps each entry in an autorelease pool).
1111
*/
12+
@Suppress("TooManyFunctions")
1213
internal object NativeMacClipboardBridge {
1314
private val loaded = NativeLibraryLoader.load(LIBRARY_NAME, NativeMacClipboardBridge::class.java)
1415

@@ -62,4 +63,15 @@ internal object NativeMacClipboardBridge {
6263
*/
6364
@JvmStatic
6465
external fun nativeSetAccessBehavior(value: Int)
66+
67+
/**
68+
* Reads `NSPasteboard.accessBehavior`. Returns the raw enum value on
69+
* macOS 15.4+, or `-1` when the property is not exposed by the runtime.
70+
*/
71+
@JvmStatic
72+
external fun nativeGetAccessBehavior(): Int
73+
74+
/** True when `NSPasteboard` responds to the `accessBehavior` selectors. */
75+
@JvmStatic
76+
external fun nativeIsAccessBehaviorSupported(): Boolean
6577
}

clipboard-macos/src/main/native/macos/nucleus_clipboard.m

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,3 +323,34 @@ static jobjectArray toJStringArray(JNIEnv *env, NSArray<NSString *> *items) {
323323
[inv invoke];
324324
}
325325
}
326+
327+
JNIEXPORT jint JNICALL
328+
Java_io_github_kdroidfilter_nucleus_clipboard_macos_NativeMacClipboardBridge_nativeGetAccessBehavior(
329+
JNIEnv *env, jclass cls) {
330+
(void)env; (void)cls;
331+
@autoreleasepool {
332+
NSPasteboard *pb = [NSPasteboard generalPasteboard];
333+
SEL sel = NSSelectorFromString(@"accessBehavior");
334+
if (![pb respondsToSelector:sel]) return -1;
335+
NSMethodSignature *sig = [pb methodSignatureForSelector:sel];
336+
NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig];
337+
[inv setSelector:sel];
338+
[inv setTarget:pb];
339+
[inv invoke];
340+
NSInteger result = 0;
341+
[inv getReturnValue:&result];
342+
return (jint)result;
343+
}
344+
}
345+
346+
JNIEXPORT jboolean JNICALL
347+
Java_io_github_kdroidfilter_nucleus_clipboard_macos_NativeMacClipboardBridge_nativeIsAccessBehaviorSupported(
348+
JNIEnv *env, jclass cls) {
349+
(void)env; (void)cls;
350+
@autoreleasepool {
351+
NSPasteboard *pb = [NSPasteboard generalPasteboard];
352+
BOOL hasGetter = [pb respondsToSelector:NSSelectorFromString(@"accessBehavior")];
353+
BOOL hasSetter = [pb respondsToSelector:NSSelectorFromString(@"setAccessBehavior:")];
354+
return (hasGetter && hasSetter) ? JNI_TRUE : JNI_FALSE;
355+
}
356+
}

docs/runtime/clipboard-common.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ Singleton façade. All suspending methods may be called from any coroutine conte
5454
| `isAvailable: Boolean` | `true` when a platform backend is loaded and operational. `false` on unsupported platforms or when the native library failed to load. |
5555
| `backendName: String` | Backend name for diagnostics (e.g. `"macOS NSPasteboard"` or `"no-op"`). |
5656
| `setAccessBehavior(behavior)` | Applies a privacy policy for background reads. Maps to `NSPasteboard.accessBehavior` on macOS 15.4+, no-op elsewhere. |
57+
| `accessBehavior: AccessBehavior?` | Currently effective policy, or `null` when the host OS has no such concept (treat as unrestricted). |
58+
| `isAccessBehaviorSupported: Boolean` | `true` when the backend honors `setAccessBehavior` / `accessBehavior` (macOS 15.4+). |
5759

5860
#### Read
5961

@@ -157,7 +159,10 @@ enum class AccessBehavior { AlwaysAllow, AskEveryTime, AlwaysDeny }
157159
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:
158160

159161
```kotlin
160-
Clipboard.setAccessBehavior(AccessBehavior.AskEveryTime)
162+
if (Clipboard.isAccessBehaviorSupported) {
163+
Clipboard.setAccessBehavior(AccessBehavior.AskEveryTime)
164+
}
165+
val effective: AccessBehavior? = Clipboard.accessBehavior // null on macOS < 15.4
161166
```
162167

163168
## Sensitive-content note

docs/runtime/clipboard-macos.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,15 @@ macOS 15.4 introduced a developer-preview pasteboard-privacy prompt. The backend
5454
- **Watcher & `availableFormats()`** — use `changeCount` and `-types`, both of which return metadata only. **No prompt.**
5555
- **`readXxx` methods** — read actual bytes; will trigger the prompt when `defaults write <bundle-id> EnablePasteboardPrivacyDeveloperPreview -bool yes` is active, or on macOS 16 when the prompt is enabled by default.
5656
- **`Clipboard.setAccessBehavior(...)`** — maps to `NSPasteboard.accessBehavior` (macOS 15.4+, guarded by `respondsToSelector:`). Older macOS versions silently ignore the call.
57+
- **`Clipboard.accessBehavior`** — reads the currently effective policy. Returns `null` when the runtime is older than macOS 15.4 (treat as unrestricted).
58+
- **`Clipboard.isAccessBehaviorSupported`**`true` when both getter and setter selectors respond on `NSPasteboard`. Use this to gate UI that lets the user pick a policy.
5759

5860
```kotlin
5961
// At startup — opt the app into "ask every time" policy on macOS 15.4+.
60-
Clipboard.setAccessBehavior(AccessBehavior.AskEveryTime)
62+
if (Clipboard.isAccessBehaviorSupported) {
63+
Clipboard.setAccessBehavior(AccessBehavior.AskEveryTime)
64+
println("Effective policy: ${Clipboard.accessBehavior}")
65+
}
6166
```
6267

6368
## Concealed / transient content

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import androidx.compose.ui.graphics.painter.BitmapPainter
3838
import androidx.compose.ui.graphics.toComposeImageBitmap
3939
import androidx.compose.ui.text.font.FontFamily
4040
import androidx.compose.ui.unit.dp
41+
import io.github.kdroidfilter.nucleus.clipboard.AccessBehavior
4142
import io.github.kdroidfilter.nucleus.clipboard.Clipboard
4243
import io.github.kdroidfilter.nucleus.clipboard.ClipboardEvent
4344
import io.github.kdroidfilter.nucleus.clipboard.ClipboardFormat
@@ -103,6 +104,12 @@ fun ClipboardScreen() {
103104
)
104105

105106
WatcherCard(currentFormats, currentChangeCount)
107+
AccessBehaviorCard(
108+
onChange = { behavior ->
109+
Clipboard.setAccessBehavior(behavior)
110+
log("access behavior → $behavior")
111+
},
112+
)
106113
WriteCard(
107114
textToCopy = textToCopy,
108115
onTextChange = { textToCopy = it },
@@ -221,6 +228,47 @@ private fun WatcherCard(
221228
}
222229
}
223230

231+
@Composable
232+
private fun AccessBehaviorCard(onChange: (AccessBehavior) -> Unit) {
233+
val supported = Clipboard.isAccessBehaviorSupported
234+
var current by remember { mutableStateOf(Clipboard.accessBehavior) }
235+
236+
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow)) {
237+
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
238+
Text("Privacy — macOS 15.4+", style = MaterialTheme.typography.titleMedium)
239+
Text(
240+
if (supported) {
241+
"current: ${current?.name ?: "(unknown)"}"
242+
} else {
243+
"Not supported on this OS / runtime — reads are unrestricted."
244+
},
245+
style = MaterialTheme.typography.bodySmall,
246+
color = MaterialTheme.colorScheme.onSurfaceVariant,
247+
fontFamily = FontFamily.Monospace,
248+
)
249+
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
250+
AccessBehavior.entries.forEach { behavior ->
251+
FilterChip(
252+
enabled = supported,
253+
selected = current == behavior,
254+
onClick = {
255+
onChange(behavior)
256+
current = Clipboard.accessBehavior
257+
},
258+
label = { Text(behavior.name) },
259+
)
260+
}
261+
}
262+
Text(
263+
"Watcher and availableFormats() only read metadata " +
264+
"(changeCount + types) — they never trigger the pasteboard-privacy prompt.",
265+
style = MaterialTheme.typography.bodySmall,
266+
color = MaterialTheme.colorScheme.onSurfaceVariant,
267+
)
268+
}
269+
}
270+
}
271+
224272
@Composable
225273
private fun WriteCard(
226274
textToCopy: String,

0 commit comments

Comments
 (0)