Compose Multiplatform for Desktop

This content was generated with the assistance of AI and should be verified against the official Kotlin Multiplatform documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Desktop is the Compose Multiplatform (CMP) target that feels most like Jetpack Compose on Android, with one big difference: it runs on the JVM and renders through Skia into an AWT/Swing window. That gives you a mature runtime (threads, the file system, java.net, any JVM library) and a different set of platform concerns than a phone: several resizable windows, a menu bar, a system tray, keyboard shortcuts, a mouse with hover and right-click, and installers for three operating systems. This page covers the desktop-specific parts of the UI API and the packaging pipeline. Everything that is shared with the other targets (state, theming, resources, navigation) is covered in Compose Multiplatform Fundamentals.

The examples target the section baseline: Kotlin 2.4.20, Compose Multiplatform 1.12.1, AGP 9.4 with the com.android.kotlin.multiplatform.library plugin for the shared modules, a JDK 17 or newer to run and package, and the libraries Koin 4.2.2 and Ktor 3.6.0 used by the shared code. Desktop JVM is Stable in Compose Multiplatform. The APIs below that are not Stable are flagged where they appear: the window and dialog API v2 (androidx.compose.ui.window.v2) is Experimental, the modalityType parameter of DialogWindow is Experimental, the onPointerEvent modifier is Experimental, off-screen rendering of a ComposePanel is Experimental, and separate views for popups are Experimental. For context, Swift export and SwiftPM import (iOS) are Alpha, and Kotlin/Wasm and CMP for web are Beta, but neither concerns this page.

The examples slice the running example TrailMate Everywhere (package com.example.trailmate), introduced in What Kotlin Multiplatform Is and When to Use It. The shared screens (App(), TrailListScreen, TrailDetailScreen, SettingsScreen) live in sharedUI; the desktop shell lives in desktopApp. Jetpack Compose itself is not re-taught; see Compose Fundamentals and State, Recomposition and Side Effects in Compose.

How the Desktop Target Is Organized

A desktop app is a JVM application. The Kotlin Multiplatform IDE plugin generates a dedicated desktopApp Gradle module (plain Kotlin/JVM plus the Compose Multiplatform Gradle plugin) that depends on sharedUI, which in turn declares a jvm() target. The desktopApp module owns only what is truly desktop: the main() function, the windows, the tray, the menu bar, and the packaging configuration. All screens come from sharedUI.

Module Role on desktop Contains

sharedLogic

jvm() target consumed as a normal library

Domain, repository, Ktor client (engine ktor-client-java or ktor-client-cio), ViewModels

sharedUI

jvm() target with Compose

App(), screens, TrailMateTheme, resources, desktop-friendly modifiers in jvmMain

desktopApp

Kotlin/JVM application module

main(), application {}, windows, tray, menu bar, nativeDistributions

The reason for a separate application module is the same as on Android and iOS: the shared module is a library, and the platform entry point is an application. Keeping window management out of sharedUI means the shared screens can be hosted unchanged in a Swing panel, a web page or an iOS view controller. See Project Structure, Targets, Source Sets and the Hierarchy Template for the full module layout.

// desktopApp/build.gradle.kts
import org.jetbrains.compose.desktop.application.dsl.TargetFormat

plugins {
    kotlin("jvm")
    alias(libs.plugins.composeMultiplatform)
    alias(libs.plugins.composeCompiler)
}

dependencies {
    implementation(project(":sharedUI"))
    // Skiko native libraries for the OS and CPU running the build
    implementation(compose.desktop.currentOs)
}

kotlin {
    jvmToolchain(17)
}

compose.desktop {
    application {
        mainClass = "com.example.trailmate.desktop.MainKt"
    }
}

compose.desktop.currentOs pulls the Skiko native binaries for the machine that builds the app. That is convenient for development, but it also explains why an installer can only be produced on the matching operating system (see Native Distributions). Use compose.desktop.windows_x64, macos_arm64 and similar accessors if you need to bundle another platform’s binaries explicitly.

Entry Points: application and Window

The desktop entry point is the application {} function. It is itself a composable scope: it keeps the process alive while it contains composition, and it is where you declare windows, a tray icon, and global state. A top-level Window creates one operating-system window, and exitApplication stops the whole app.

// desktopApp/src/main/kotlin/com/example/trailmate/desktop/Main.kt
package com.example.trailmate.desktop

import com.example.trailmate.ui.App

fun main() = application {
    Window(
        onCloseRequest = ::exitApplication,
        title = "TrailMate"
    ) {
        App()
    }
}

Window is a regular composable function, so its properties are declarative: change the title expression and the window title changes on the next recomposition. Closing a window is not automatic either. onCloseRequest is called when the user clicks the close button, and you decide whether to hide the window, ask for confirmation, or exit.

For an app with exactly one window and nothing else, singleWindowApplication wraps application and Window in one call. It is ideal for samples and tests, and you should switch to application {} the moment you need a second window, a tray icon or custom closing logic.

WindowState and Window Placement

WindowState holds the window’s placement (floating, maximized, minimized or fullscreen), position and size. Any change to the state recomposes the window, and any change the user makes (resizing, maximizing) is written back into it. Create one with rememberWindowState and pass it to Window.

// desktopApp/src/main/kotlin/com/example/trailmate/desktop/TrailMateWindow.kt
package com.example.trailmate.desktop

import com.example.trailmate.ui.App

@Composable
fun ApplicationScope.TrailMateWindow(onSizeChanged: (DpSize) -> Unit) {
    val state = rememberWindowState(
        placement = WindowPlacement.Floating,
        position = WindowPosition.PlatformDefault,
        size = DpSize(1100.dp, 760.dp)
    )

    // Persist the size the user chose (for example in DataStore)
    LaunchedEffect(state) {
        snapshotFlow { state.size }.collect(onSizeChanged)
    }

    Window(
        onCloseRequest = ::exitApplication,
        state = state,
        title = "TrailMate",
        resizable = true
    ) {
        App()
    }
}

To size a window to its content instead of a fixed size, give the state width = Dp.Unspecified and height = Dp.Unspecified; the window then adopts the content’s preferred size. This works with the original API, but it has a drawback that the v2 API fixes (see the next section): the content then has to be measured with fixed constraints, so modifiers such as fillMaxSize() cannot expand when the user enlarges the window.

Multiple Windows

Because windows are composables, opening and closing them is a matter of keeping a list in state: a new item adds a Window, removing an item closes it. For TrailMate this gives a "pop out trail" action that opens a trail detail in its own window. Keying each window by trail id keeps its state attached to the right trail.

// desktopApp/src/main/kotlin/com/example/trailmate/desktop/DetachedTrails.kt
class DetachedTrails {
    val trailIds = mutableStateListOf<Long>()
}

@Composable
fun ApplicationScope.DetachedTrailWindows(detached: DetachedTrails) {
    for (id in detached.trailIds.toList()) {
        key(id) {
            Window(onCloseRequest = { detached.trailIds -= id }, title = "Trail $id") {
                TrailDetailScreen(trailId = id, onBack = { detached.trailIds -= id })
            }
        }
    }
}

DialogWindow

DialogWindow shows a separate OS-level window with its own title bar, which is what you want for confirmations, file pickers, or any decision the user must make before continuing. Do not confuse it with the multiplatform Dialog and Popup composables, which draw an overlay inside the current window and behave identically on every target (see Layouts, Material 3, Adaptive UI, Popups and Accessibility).

The modalityType parameter controls blocking and is Experimental (opt in with @OptIn(ExperimentalComposeUiApi::class)): Modeless blocks nothing, DocumentModal blocks the parent window and its attached windows, and ApplicationModal blocks every other window of the app.

// desktopApp/src/main/kotlin/com/example/trailmate/desktop/ConfirmQuit.kt
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ConfirmQuitDialog(onQuit: () -> Unit, onCancel: () -> Unit) {
    DialogWindow(
        onCloseRequest = onCancel,
        state = rememberDialogState(width = 360.dp, height = 180.dp),
        title = "Quit TrailMate?",
        modalityType = DialogModalityType.ApplicationModal
    ) {
        Button(onClick = onQuit) { Text("Quit") }
    }
}

Other window options: undecorated = true removes the native title bar (combine it with WindowDraggableArea to build your own draggable bar), and transparent = true (which requires undecorated = true) allows rounded or non-rectangular windows.

Window and Dialog API v2 (Experimental)

Compose Multiplatform 1.12.0 introduced redesigned WindowState and DialogState classes in the androidx.compose.ui.window.v2 package. The v2 API is Experimental and is available beside the original API, so you can migrate window by window. Its central idea is to separate requesting a state from observing the state the window manager actually applied. A window manager may adjust a requested size or position (for example to fit the screen), and the user can move the window afterwards, so a single mutable value for both was always ambiguous.

Concern Original API (androidx.compose.ui.window) v2 API (androidx.compose.ui.window.v2)

Stability

Stable

Experimental

Initial position and size

rememberWindowState(position, size)

rememberWindowState with providers (initialScreenProvider, initialBoundsProvider)

Changing after creation

Assign state.position / state.size

Call requestScreen, requestSize, requestPosition or requestBounds

Observing the result

Read the same properties

Read screenId and bounds (check isInitialized)

Size to content, but still fill when enlarged

Not possible with Dp.Unspecified

WindowSizeProvider.Unconstrained

Min and max size

Set on the AWT window by hand

minSize / maxSize parameters on Window and DialogWindow

// desktopApp/src/main/kotlin/com/example/trailmate/desktop/V2Window.kt
package com.example.trailmate.desktop

// All of these live in the experimental v2 package; opt-in annotations may be required
import androidx.compose.ui.window.v2.WindowBoundsProvider
import androidx.compose.ui.window.v2.WindowPositionProvider
import androidx.compose.ui.window.v2.WindowSizeProvider
import androidx.compose.ui.window.v2.rememberWindowState

@Composable
fun rememberTrailMateWindowState() = rememberWindowState(
    initialBoundsProvider = WindowBoundsProvider(
        positionProvider = WindowPositionProvider.CenteredOnScreen,
        // Content-sized, but still able to grow when the user resizes it
        sizeProvider = WindowSizeProvider.Unconstrained
    )
)

fun requestWideLayout(state: androidx.compose.ui.window.v2.WindowState) {
    // Asynchronous: the window manager may adjust the result
    state.requestSize(DpSize(1280.dp, 800.dp))
    state.requestPosition(DpOffset(100.dp, 100.dp))
}

Treat v2 as a preview: names and opt-in requirements can still change before it is promoted to Stable, so for production windows today keep the Stable API and adopt v2 only where its sizing behavior solves a real problem.

System Tray and Notifications

Tray() is available inside the application {} scope, so it can sit next to windows or live on its own. Its parameters are the icon (a Painter), the menu contents, a state (TrayState) used to send notifications, a tooltip, and an onAction callback. The menu opens on a right-click on Windows and on a left-click on macOS, and without items it does not appear at all. TrailMate uses the tray to keep downloading offline maps after the main window is closed.

// desktopApp/src/main/kotlin/com/example/trailmate/desktop/Main.kt
package com.example.trailmate.desktop

import com.example.trailmate.ui.App
import com.example.trailmate.ui.generated.resources.Res
import com.example.trailmate.ui.generated.resources.tray_icon
import org.jetbrains.compose.resources.painterResource

fun main() = application {
    var windowVisible by remember { mutableStateOf(true) }
    val trayState = rememberTrayState()
    val syncDone = rememberNotification("TrailMate", "Offline trails are up to date")

    Tray(
        state = trayState,
        icon = painterResource(Res.drawable.tray_icon),
        tooltip = "TrailMate",
        onAction = { windowVisible = true },
        menu = {
            Item("Open TrailMate", onClick = { windowVisible = true })
            Item("Notify sync finished", onClick = { trayState.sendNotification(syncDone) })
            Separator()
            Item("Quit", onClick = ::exitApplication)
        }
    )

    if (windowVisible) {
        Window(onCloseRequest = { windowVisible = false }, title = "TrailMate") { App() }
    }
}

Hiding a window by flipping a boolean, as above, is the idiomatic "minimize to tray": the application stays alive because the Tray is still in composition, and the process only ends when exitApplication is called. Two cautions: tray behavior varies by OS and desktop environment (some Linux desktops hide or ignore tray icons), and a notification sent through TrayState is a simple system toast, not a rich notification with actions. Use the tray for convenience, never as the only way to reach a feature.

MenuBar and KeyShortcut

MenuBar() is available in the scope of Window, so every window can have its own. On Windows and Linux it is part of the window; on macOS it appears in the system menu bar at the top of the screen while that window is active. Items take a KeyShortcut, a mnemonic, and an icon, and you can nest Menu blocks and add CheckboxItem, RadioButtonItem and Separator entries.

// desktopApp/src/main/kotlin/com/example/trailmate/desktop/TrailMenuBar.kt
package com.example.trailmate.desktop

@Composable
fun FrameWindowScope.TrailMenuBar(
    onRefresh: () -> Unit,
    onToggleFavorites: (Boolean) -> Unit,
    favoritesOnly: Boolean,
    onQuit: () -> Unit
) {
    MenuBar {
        Menu("Trails", mnemonic = 'T') {
            Item("Refresh", onClick = onRefresh, shortcut = KeyShortcut(Key.R, ctrl = true))
            CheckboxItem(
                "Favorites only",
                checked = favoritesOnly,
                onCheckedChange = onToggleFavorites
            )
            Separator()
            Item("Quit", onClick = onQuit, shortcut = KeyShortcut(Key.Q, ctrl = true))
        }
    }
}

KeyShortcut(ctrl = true) is Control on Windows and Linux, but on macOS the convention is Command, so use meta = true there. A cross-platform app typically picks the modifier from System.getProperty("os.name") once and reuses it. Also see macOS: Windows, Menus, Commands, Settings and Menu Bar Extras for what a native Mac user expects from the menu bar.

Keyboard, Mouse and Focus

Keyboard Events

Keyboard handlers exist at two levels. Focused-element handlers use Modifier.onKeyEvent or Modifier.onPreviewKeyEvent on a focusable component, and window-scope handlers use the onKeyEvent and onPreviewKeyEvent parameters of Window, singleWindowApplication and DialogWindow. The preview variant sees the event first, on its way down the tree; the normal variant sees it on its way back up. Return true to consume the event.

// desktopApp/src/main/kotlin/com/example/trailmate/desktop/Shortcuts.kt
package com.example.trailmate.desktop

// Used as Window(onKeyEvent = searchShortcut(...)): Ctrl+F focuses the search field
fun searchShortcut(focusSearch: () -> Unit): (KeyEvent) -> Boolean = { event ->
    val hit = event.isCtrlPressed && event.key == Key.F && event.type == KeyEventType.KeyDown
    if (hit) focusSearch()
    hit
}

Mouse Events

Compose Multiplatform for desktop supports click, move, scroll, enter and exit events. Modifier.combinedClickable handles click, double click and long click, but only for the primary button and touch. To react to the right or middle button, use Modifier.onClick(matcher = PointerMatcher.mouse(PointerButton.Secondary)). For hover, use Modifier.onPointerEvent(PointerEventType.Enter) and Exit, but note that onPointerEvent is Experimental (opt-in required), while Modifier.pointerInput is the Stable alternative.

// sharedUI/src/jvmMain/kotlin/com/example/trailmate/ui/components/HoverCard.kt
@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class)
@Composable
fun HoverCard(onOpen: () -> Unit, onPin: () -> Unit, content: @Composable () -> Unit) {
    var hovered by remember { mutableStateOf(false) }
    Box(
        Modifier
            .background(if (hovered) Color(0xFFE8F5E9) else Color.Transparent)
            .onPointerEvent(PointerEventType.Enter) { hovered = true }
            .onPointerEvent(PointerEventType.Exit) { hovered = false }
            .combinedClickable(onClick = onOpen, onDoubleClick = onPin)
    ) { content() }
}

A jvmMain source set in sharedUI is the right home for such desktop-only helpers, with an expect/actual bridge or an interface if common code needs them; see expect/actual and Calling Platform APIs.

Tab Navigation and Focus

On desktop, users expect Tab and Shift+Tab to move between fields. Text fields and buttons are focusable already; to add a custom component to the tab order, apply Modifier.focusable() and pass a MutableInteractionSource so you can draw a focus indicator with collectIsFocusedAsState(). Use FocusRequester to move focus programmatically, for instance to the search field when the window opens.

Scrollbars, Tooltips and Context Menus

A phone hides scroll indicators; a desktop user expects a draggable scrollbar, hover hints and a right-click menu. These three components are desktop-specific and live in jvmMain (or behind a platform check).

Scrollbars

A scrollbar shares a scroll state with its scrollable component through a scrollbar adapter. Create the state with rememberLazyListState() (or rememberScrollState()), pass it to the list, and pass rememberScrollbarAdapter(state) to VerticalScrollbar. Leave room for the bar with end padding.

// sharedUI/src/jvmMain/kotlin/com/example/trailmate/ui/list/TrailListWithScrollbar.kt
package com.example.trailmate.ui.list

import com.example.trailmate.domain.Trail

@Composable
fun TrailListWithScrollbar(trails: List<Trail>, row: @Composable (Trail) -> Unit) {
    val listState = rememberLazyListState()
    Box(Modifier.fillMaxSize()) {
        LazyColumn(Modifier.fillMaxSize().padding(end = 12.dp), state = listState) {
            items(trails, key = { it.id }) { row(it) }
        }
        VerticalScrollbar(
            modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
            adapter = rememberScrollbarAdapter(listState)
        )
    }
}

Tooltips

TooltipArea wraps any component and shows arbitrary composable content after a delay. It requires @OptIn(ExperimentalFoundationApi::class) (Experimental) and supports placement relative to the cursor or the component through tooltipPlacement.

// sharedUI/src/jvmMain/kotlin/com/example/trailmate/ui/components/FavoriteButton.kt
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun FavoriteButton(isFavorite: Boolean, onToggle: () -> Unit, icon: @Composable () -> Unit) {
    TooltipArea(
        tooltip = {
            Surface(tonalElevation = 4.dp) {
                Text(if (isFavorite) "Remove from favorites" else "Add to favorites", Modifier.padding(8.dp))
            }
        },
        delayMillis = 600
    ) {
        IconButton(onClick = onToggle, content = icon)
    }
}

Context Menus

Text fields and selectable text get a built-in context menu (cut, copy, paste, select all) that follows the system language. For other components wrap content in ContextMenuArea with a list of ContextMenuItem, and extend the built-in text menu with ContextMenuDataProvider. To make plain Text copyable, wrap it in SelectionContainer. Theme the menu with LocalContextMenuRepresentation (LightDefaultContextMenuRepresentation and DarkDefaultContextMenuRepresentation are provided).

// sharedUI/src/jvmMain/kotlin/com/example/trailmate/ui/components/TrailRowMenu.kt
@Composable
fun TrailRowMenu(onFavorite: () -> Unit, onOpenInNewWindow: () -> Unit, content: @Composable () -> Unit) {
    ContextMenuArea(items = {
        listOf(
            ContextMenuItem("Toggle favorite") { onFavorite() },
            ContextMenuItem("Open in new window") { onOpenInNewWindow() }
        )
    }) { content() }
}

Images and Icons

Desktop loads images from the multiplatform resources library exactly like the other targets, so painterResource(Res.drawable.trail_placeholder) works unchanged (see Resources and Localization in Compose Multiplatform). painterResource supports PNG, JPEG, BMP, WebP and the Android XML vector format. Because desktop is a JVM application, you may also read files and URLs directly with JVM APIs, decode them with decodeToImageBitmap(), decodeToImageVector(density) or decodeToSvgPainter(density), and show them with BitmapPainter or rememberVectorPainter. Always load off the main thread (Dispatchers.IO), because decoding and network access block. For remote photos such as Trail.photoUrl, an image-loading library is the better answer; see Essential Multiplatform Libraries for Coil 3.

Swing Interoperability

Compose Multiplatform for desktop is built on Swing/AWT, so the two can be mixed in both directions. This is mainly a migration tool: an existing Swing application can adopt Compose one panel at a time, and a Compose app can embed a Swing component that has no Compose equivalent (a web view, a chart library, a legacy editor).

Direction API Typical use

Compose inside Swing

ComposePanel (add to a JFrame, then setContent), or ComposeWindow

Incremental migration of a Swing app

Swing inside Compose

SwingPanel(factory = { JComponent })

Embed a Swing control that Compose cannot draw

Swing window helpers in Compose

AwtWindow, the window property in Window {} scope

File dialogs, drag-and-drop targets

// desktopApp/src/main/kotlin/com/example/trailmate/desktop/SwingHost.kt
package com.example.trailmate.desktop

import androidx.compose.ui.awt.ComposePanel
import com.example.trailmate.ui.App
import java.awt.BorderLayout
import javax.swing.JButton
import javax.swing.JFrame
import javax.swing.SwingUtilities
import javax.swing.WindowConstants

// A legacy Swing shell that hosts the shared App() in its center
fun main() = SwingUtilities.invokeLater {
    val frame = JFrame("TrailMate (Swing host)")
    frame.defaultCloseOperation = WindowConstants.EXIT_ON_CLOSE

    val composePanel = ComposePanel()
    frame.contentPane.add(JButton("Legacy toolbar"), BorderLayout.NORTH)
    frame.contentPane.add(composePanel, BorderLayout.CENTER)
    composePanel.setContent { App() }

    frame.setSize(1000, 700)
    frame.isVisible = true
}

The reverse direction uses SwingPanel. Compose and Swing render with different pipelines, so a SwingPanel is drawn as an overlay window area: it cannot be clipped by or partially covered by Compose content, and animating or transforming it is limited. Use it for rectangular, mostly static components.

// sharedUI/src/jvmMain/kotlin/com/example/trailmate/ui/components/LegacyElevationChart.kt
package com.example.trailmate.ui.components

import androidx.compose.ui.awt.SwingPanel
import javax.swing.JLabel

@Composable
fun LegacyElevationChart(summary: String) {
    SwingPanel(
        modifier = Modifier.fillMaxWidth().height(160.dp),
        factory = { JLabel(summary) },
        // update is called whenever the Compose state it reads changes
        update = { label -> label.text = summary }
    )
}

Two Experimental features reduce the rough edges. Off-screen rendering (ComposePanel(renderSettings = RenderSettings.SwingGraphics), or the compose.swing.render.on.graphics flag) draws the panel through Swing graphics, which avoids flicker when a panel is shown, hidden or resized and allows a Swing component above or below a ComposePanel, at a performance cost that grows with panel size. Separate views for popups let tooltips and dropdowns escape the panel bounds. Both require opt-in and are intended for evaluation.

Native Distributions

The Compose Multiplatform Gradle plugin wraps the JDK’s jpackage tool. You declare formats and metadata in nativeDistributions {} and run a Gradle task per format; the result is a self-contained installer with a trimmed JVM runtime, so users do not need Java installed.

OS Formats (TargetFormat) Build on

macOS

Dmg, Pkg

macOS only

Windows

Msi, Exe

Windows only (needs the WiX Toolset)

Linux

Deb, Rpm

Linux only

Cross-compilation is not supported: packageDmg needs macOS, packageMsi needs Windows, and incompatible tasks are skipped on the current OS. That is why release pipelines use a matrix of three runners (see CI/CD for Kotlin Multiplatform with GitHub Actions). The JDK must be 17 or newer (the Mac App Store option also requires at least JDK 17); javaHome overrides the JDK that jpackage uses.

// desktopApp/build.gradle.kts
import org.jetbrains.compose.desktop.application.dsl.TargetFormat

compose.desktop {
    application {
        mainClass = "com.example.trailmate.desktop.MainKt"
        jvmArgs += listOf("-Xmx512m")

        nativeDistributions {
            targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
            packageName = "TrailMate"
            packageVersion = "1.0.0"
            description = "Hiking trails, offline"
            vendor = "Example Company"
            copyright = "Copyright Example Company"

            // jlink keeps only the listed JDK modules: add what your libraries need
            modules("java.sql", "jdk.unsupported")

            macOS {
                bundleID = "com.example.trailmate.desktop"
                iconFile.set(project.file("icons/trailmate.icns"))
            }
            windows {
                menuGroup = "TrailMate"
                iconFile.set(project.file("icons/trailmate.ico"))
            }
            linux {
                iconFile.set(project.file("icons/trailmate.png"))
                debMaintainer = "maintainer@example.com"
            }
        }
    }
}

Tasks You Will Use

Gradle task What it does

run

Runs the app as a plain JVM application with the full runtime; fastest for development

createDistributable

Builds the final application image without an installer

runDistributable

Runs that prepackaged image, to test the trimmed runtime

package<Format> (packageDmg, packageMsi, packageDeb…​)

Builds one installer; output goes to build/compose/binaries

packageDistributionForCurrentOS

Builds every configured format that this OS supports

packageUberJarForCurrentOS

One fat JAR for the current OS (needs compose.desktop.currentOs)

suggestModules

Runs jdeps to propose missing JDK modules (output can be incomplete)

Package Versions

Each format has its own version rules, and violating them fails the build late. DMG and PKG accept MAJOR[.MINOR][.PATCH]; MSI and EXE require MAJOR.MINOR.BUILD where MAJOR and MINOR are at most 255 and BUILD at most 65535; DEB accepts [EPOCH:]UPSTREAM_VERSION[-DEBIAN_REVISION]. A frequent pitfall is a Gradle project version such as 1.0.0-SNAPSHOT or a four-part version, which msi rejects. Set packageVersion explicitly, or use the per-OS blocks (macOS { dmgPackageVersion = "…​" }, windows { msiPackageVersion = "…​" }) when the formats disagree.

Missing JDK Modules

To keep installers small, the plugin runs jlink with only the JDK modules it knows about. It does not detect your needs automatically, so a missing module surfaces only at runtime as ClassNotFoundException, often in the packaged app and never when you use run. List modules with modules("java.sql"), run suggestModules, or accept a larger installer with includeAllModules = true. Always smoke-test the output of runDistributable before publishing.

ProGuard Release Builds

For each packaging task the plugin also provides a release task that runs the application through ProGuard first: createReleaseDistributable, runReleaseDistributable, packageReleaseDmg, packageReleaseMsi, packageReleaseDeb and so on. ProGuard shrinks unused classes and optimizes bytecode; obfuscation is available but off by default. Configure it in buildTypes.release.proguard.

// desktopApp/build.gradle.kts
compose.desktop {
    application {
        buildTypes.release.proguard {
            // Keep rules for reflection, serialization and Koin lookups
            configurationFiles.from(project.file("compose-desktop.pro"))
            obfuscate.set(false)
            optimize.set(true)
            joinOutputJars.set(false)
        }
    }
}

ProGuard version 7.8.0 or newer is required when building with JDK 25 (it is the default from Compose Multiplatform 1.12.0). Release builds are where reflection-based code breaks: serializers, service loaders, and dependency-injection lookups need keep rules, and the symptom is a crash that never happens in run. Test the release distributable, not only the debug one. Because Gson-style reflection is the usual culprit, prefer kotlinx.serialization, as Networking with the Ktor Client does.

macOS Signing and Notarization

macOS refuses to run unsigned, un-notarized downloads without warnings, so a Mac installer distributed outside the Mac App Store must be signed with a Developer ID Application certificate and notarized by Apple. The Compose Multiplatform Gradle plugin automates both. The prerequisites are an Apple Developer account, the certificate in your Keychain, and an app-specific password for the Apple ID (create it on the Apple ID account page). For the Apple side of the process, see Distributing Mac Apps with Developer ID and Notarization.

// desktopApp/build.gradle.kts
compose.desktop {
    application {
        nativeDistributions {
            targetFormats(TargetFormat.Dmg)
            macOS {
                bundleID = "com.example.trailmate.desktop"

                signing {
                    sign.set(true)
                    // Certificate name as shown in Keychain Access (placeholder)
                    identity.set("<DEVELOPER_ID_CERTIFICATE_NAME>")
                }

                notarization {
                    // Credentials come from the environment, never from source control
                    val env = project.providers
                    appleID.set(env.environmentVariable("NOTARIZATION_APPLE_ID"))
                    password.set(env.environmentVariable("NOTARIZATION_PASSWORD"))
                    teamID.set(env.environmentVariable("NOTARIZATION_TEAM_ID"))
                }
            }
        }
    }
}

The notarize tasks are notarizeDmg, notarizeReleaseDmg (with ProGuard), notarizePkg and notarizeReleasePkg. Signing settings can also come from Gradle properties (compose.desktop.mac.sign, compose.desktop.mac.signing.identity, compose.desktop.mac.signing.keychain), which is handy for keeping the certificate name out of the repository. If several certificates of one type are installed, set keychain to pick the right one. Notarization typically completes within minutes but can take longer, so CI must wait. For the Mac App Store (appStore = true) two certificates and provisioning profiles for both the app and the JVM runtime are needed.

Windows and Linux have no equivalent gate: an unsigned MSI triggers a SmartScreen warning that you remove by signing with a code-signing certificate outside the Compose plugin, and DEB and RPM packages are usually signed by the repository that hosts them.

Desktop UI Tests

Because desktop is the JVM, UI tests run on your machine without an emulator. Two APIs are available. The common runComposeUiTest works in commonTest and runs on every target (see Testing Kotlin Multiplatform Code). The JUnit-based API adds createComposeRule() from androidx.compose.ui.test.junit4.v2 for desktop-specific tests that need a window, keyboard or mouse input. The dependency is compose.desktop.uiTestJUnit4 alongside compose.desktop.currentOs.

// sharedUI/src/jvmTest/kotlin/com/example/trailmate/ui/components/FavoriteButtonTest.kt
package com.example.trailmate.ui.components

import androidx.compose.ui.test.junit4.v2.createComposeRule
import org.junit.Rule
import org.junit.Test

class FavoriteButtonTest {
    @get:Rule
    val rule = createComposeRule()

    @Test
    fun clickTogglesLabel() {
        rule.setContent {
            var favorite by remember { mutableStateOf(false) }
            androidx.compose.material3.Button(
                onClick = { favorite = !favorite },
                modifier = Modifier.testTag("fav")
            ) { Text(if (favorite) "Favorited" else "Favorite") }
        }

        rule.onNodeWithTag("fav").assertTextEquals("Favorite")
        rule.onNodeWithTag("fav").performClick()
        rule.onNodeWithTag("fav").assertTextEquals("Favorited")
    }
}

Run it with ./gradlew :sharedUI:jvmTest. The official sample names its target desktop, so you will see desktopTest there; the task name follows the target name you declared. The test API is the same as Jetpack Compose’s, so Instrumented and UI Testing explains the finders and assertions. Desktop accessibility semantics (Java Access Bridge on Windows, the macOS accessibility API) are covered in Layouts, Material 3, Adaptive UI, Popups and Accessibility.

Common Mistakes

  • Forgetting onCloseRequest. It is required; without exiting or hiding the window, the close button appears to do nothing.

  • Using compose.desktop.currentOs to produce another OS’s installer. Build each installer on its own OS, typically with a CI matrix.

  • Skipping modules(…​) and only testing with run. The packaged app throws ClassNotFoundException; always run runDistributable.

  • A Gradle version msi rejects. Set packageVersion explicitly to a MAJOR.MINOR.BUILD value.

  • Testing only the non-ProGuard build. Reflection and serialization break only in release builds; add keep rules and test runReleaseDistributable.

  • Committing notarization credentials or the signing identity. Read them from environment variables or ~/.gradle/gradle.properties.

  • Loading images or files on the UI thread. Use Dispatchers.IO through produceState or LaunchedEffect.

  • Overlapping a SwingPanel with Compose content. It is drawn above Compose; keep it rectangular and unobstructed, or evaluate the Experimental off-screen mode.

  • Adopting the Experimental window API v2 in production without a fallback plan. Its names and opt-ins can change.

  • Hard-coding ctrl = true for every shortcut. Use meta = true on macOS to match platform conventions.

  • Relying on the tray as the only entry point. Some Linux desktops hide tray icons.

  • Putting window and tray code in sharedUI. Keep it in desktopApp so the screens stay reusable.