System Integration: Clipboard, Commands & Notifications | Kotlin Desktop #18
Video: System Integration: Clipboard, Commands & Notifications | Kotlin Desktop #18 by Taught by Celeste AI - AI Coding Coach
System Integration: Clipboard, Shell Commands, and Notifications
AWT's
Toolkit.systemClipboardfor clipboard read/write.ProcessBuilderfor running shell commands.TrayState.sendNotificationfor native desktop notifications. The bridge from "self-contained app" to "OS citizen."
Today's app is a system toolbox — three tabs in a navigation rail. The new pieces are all about the OS:
- Clipboard tab — monitors the system clipboard, captures text every time it changes.
- Commands tab — runs shell commands and shows stdout/stderr/exit code.
- System tab — surfaces JVM/OS info (memory, CPU, OS version).
Plus a tray icon with native notifications when clipboard captures or commands complete.
What we are building
A 1100×700 window with a left-side NavigationRail and a tab pane. The main application block also registers a Tray icon with rememberTrayState() so we can fire notifications.
Project layout
src/main/kotlin/
├── Main.kt # window + tray
├── AppContent.kt # nav rail + tab switching
├── ClipboardTab.kt
├── CommandTab.kt
├── SystemTab.kt
├── ClipboardManager.kt # AWT clipboard monitoring
├── CommandRunner.kt # ProcessBuilder + suspend wrapper
└── SystemModels.kt # CommandResult, ClipboardEntry, etc.
Reading from the clipboard
fun startClipboardMonitoring(
scope: CoroutineScope,
intervalMs: Long = 1000L,
onNewEntry: (ClipboardEntry) -> Unit,
) {
scope.launch(Dispatchers.IO) {
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
var lastText = try {
clipboard.getData(DataFlavor.stringFlavor) as? String
} catch (_: Exception) { null }
while (true) {
delay(intervalMs)
try {
val current = clipboard.getData(DataFlavor.stringFlavor) as? String
if (current != null && current != lastText) {
lastText = current
onNewEntry(ClipboardEntry(current))
}
} catch (_: Exception) {
// Clipboard may be unavailable momentarily
}
}
}
}
Toolkit.getDefaultToolkit().systemClipboard returns the OS clipboard. clipboard.getData(DataFlavor.stringFlavor) pulls the current contents as a string (or throws if there's nothing there or it's a different type).
There's no clipboard change listener in AWT — so we poll. Once a second is fine for "user is copying things" use cases. The listener pattern in Lesson 10 uses Modifier.dragAndDropTarget's real event stream; system-clipboard polling is the pragmatic alternative.
Writing to the clipboard
fun copyToClipboard(text: String) {
val clipboard = Toolkit.getDefaultToolkit().systemClipboard
clipboard.setContents(StringSelection(text), null)
}
setContents(transferable, owner). StringSelection is the simplest Transferable for plain text. The second argument is an "owner" that gets a lostOwnership callback when something else writes — pass null if you don't care.
For richer types (HTML, images, files), build a custom Transferable exposing multiple DataFlavors.
Running shell commands
suspend fun executeCommand(
command: String,
timeoutSeconds: Long = 30,
): CommandResult = withContext(Dispatchers.IO) {
val startTime = System.currentTimeMillis()
val process = ProcessBuilder("/bin/sh", "-c", command)
.redirectErrorStream(false)
.start()
val completed = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
val durationMs = System.currentTimeMillis() - startTime
if (!completed) {
process.destroyForcibly()
CommandResult(command, "", "Command timed out after ${timeoutSeconds}s", -1, durationMs)
} else {
CommandResult(
command = command,
stdout = process.inputStream.bufferedReader().readText(),
stderr = process.errorStream.bufferedReader().readText(),
exitCode = process.exitValue(),
durationMs = durationMs,
)
}
}
ProcessBuilder("/bin/sh", "-c", command) runs the command through the shell, so things like pipes (ls | grep foo) work. For Windows, swap to "cmd", "/c", command.
redirectErrorStream(false) keeps stdout and stderr separate so we can show them apart.
waitFor(timeout, unit) returns true if the process completed in time, false if it timed out — we then destroyForcibly() to reap it. The exit code and streams are read after completion.
withContext(Dispatchers.IO) puts the work on the IO dispatcher — process I/O blocks threads, and you don't want it on the main thread or Default.
Streaming output (for long-running commands)
The above reads streams after the process completes, which means a long-running command shows nothing until done. For a real terminal, stream live:
fun executeCommandStreaming(command: String, onLine: (String) -> Unit, onDone: (Int) -> Unit) {
scope.launch(Dispatchers.IO) {
val process = ProcessBuilder("/bin/sh", "-c", command).start()
process.inputStream.bufferedReader().useLines { lines ->
lines.forEach { onLine(it) }
}
onDone(process.waitFor())
}
}
Reading line-by-line via useLines lets you push each line to the UI as it arrives. For binary output (e.g., Docker logs), read bytes instead.
CommandResult and rendering
data class CommandResult(
val command: String,
val stdout: String,
val stderr: String,
val exitCode: Int,
val durationMs: Long,
)
Keep the output in the model, not just success/failure. Showing actual stdout in the UI is what makes a command-runner useful versus showing "✓ done."
commandResults.add(0, result) prepends — newest result on top.
Tray notifications
val trayState = rememberTrayState()
Tray(
state = trayState,
icon = BitmapPainter(TrayIcon),
tooltip = "System Toolbox",
)
rememberTrayState() returns a TrayState. Anything in the application { } scope can call:
trayState.sendNotification(
Notification(
title = "Command Done",
message = "exit ${result.exitCode}: ${result.command.take(50)}",
type = Notification.Type.Info,
)
)
Three notification types:
Type.Info— neutral, blue icon.Type.Warning— yellow.Type.Error— red.
The OS handles display:
- macOS — Notification Center (top right).
- Windows — toast (bottom right).
- Linux — varies by desktop environment (GNOME/KDE/etc.).
Compose abstracts the platform differences. Same code, native UX.
NavigationRail
enum class Tab(val label: String, val icon: ImageVector) {
Clipboard("Clipboard", Icons.Default.ContentPaste),
Commands("Commands", Icons.Default.Terminal),
System("System", Icons.Default.Computer),
}
NavigationRail {
Tab.entries.forEach { tab ->
NavigationRailItem(
selected = selectedTab == tab,
onClick = { selectedTab = tab },
icon = { Icon(tab.icon, contentDescription = tab.label) },
label = { Text(tab.label) },
)
}
}
Material 3's left-side navigation. Vertical alternative to NavigationBar (bottom). For desktop apps with three to seven destinations, NavigationRail is the common choice.
Tab.entries.forEach iterates the enum — adding a fourth tab is one new variant; the UI auto-renders it.
SystemTab: surfacing JVM info
fun systemSnapshot(): SystemInfo {
val runtime = Runtime.getRuntime()
val osName = System.getProperty("os.name")
val osVersion = System.getProperty("os.version")
val javaVersion = System.getProperty("java.version")
return SystemInfo(
os = "$osName $osVersion",
java = javaVersion,
cores = runtime.availableProcessors(),
totalMemoryMb = runtime.totalMemory() / (1024 * 1024),
freeMemoryMb = runtime.freeMemory() / (1024 * 1024),
)
}
Runtime.getRuntime() exposes JVM facts (CPU count, memory). System.getProperty(...) exposes JVM/OS facts (OS name, Java version, user home dir).
For richer info (CPU load, disk usage), use OperatingSystemMXBean from java.lang.management or a third-party library like OSHI.
CoroutineScope for async actions
val scope = rememberCoroutineScope()
onExecute = { command ->
isRunning = true
scope.launch(Dispatchers.IO) {
val result = executeCommand(command)
commandResults.add(0, result)
isRunning = false
trayState.sendNotification(/* ... */)
}
}
rememberCoroutineScope() ties the scope to the Composable — when this composable leaves composition, the scope cancels. In-flight commands still run to completion (process I/O can't be cancelled mid-process), but any post-process state updates simply no-op.
Common mistakes
Polling the clipboard from the main thread. clipboard.getData blocks. Always Dispatchers.IO.
Forgetting redirectErrorStream(false) (or true). Default is false, but be explicit. If you want stderr merged with stdout, set true and skip the errorStream read.
Running process.waitFor() without a timeout. Hangs forever on a runaway command. Always supply a timeout.
Hardcoding /bin/sh. Doesn't exist on Windows. Detect via System.getProperty("os.name") and switch shells.
Calling sendNotification from a Composable without rememberCoroutineScope. Notifications are async — fire from a coroutine, not the render path.
Treating clipboard polling as authoritative. A 1s poll misses sub-second copies. Real clipboard managers use OS-level event hooks (JNI). Polling is a workable approximation.
What's next
Lesson 19: performance and profiling. Recomposition tracking, derivedStateOf, immutable types, key {}, lazy lists. The recipes for keeping a Compose UI fast as it grows.
Recap
Toolkit.getDefaultToolkit().systemClipboard for clipboard read (getData(DataFlavor.stringFlavor)) and write (setContents(StringSelection(text), null)). ProcessBuilder("/bin/sh", "-c", command).start() for shell commands; capture inputStream/errorStream/exitValue()/waitFor(timeout). Tray plus rememberTrayState() plus trayState.sendNotification(Notification(...)) for native OS notifications. Always Dispatchers.IO for clipboard polling and process I/O.
Next lesson: performance and profiling.