Back to Blog

System Integration: Clipboard, Commands & Notifications | Kotlin Desktop #18

Sandy LaneSandy Lane

Video: System Integration: Clipboard, Commands & Notifications | Kotlin Desktop #18 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

System Integration: Clipboard, Shell Commands, and Notifications

AWT's Toolkit.systemClipboard for clipboard read/write. ProcessBuilder for running shell commands. TrayState.sendNotification for 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.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.