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 |
|---|---|---|
|
|
Domain, repository, Ktor client (engine |
|
|
|
|
Kotlin/JVM application module |
|
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 |
|
|
Changing after creation |
Assign |
Call |
Observing the result |
Read the same properties |
Read |
Size to content, but still fill when enlarged |
Not possible with |
|
Min and max size |
Set on the AWT window by hand |
|
// 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 |
|
Incremental migration of a Swing app |
Swing inside Compose |
|
Embed a Swing control that Compose cannot draw |
Swing window helpers in Compose |
|
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 |
|
macOS only |
Windows |
|
Windows only (needs the WiX Toolset) |
Linux |
|
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 |
|---|---|
|
Runs the app as a plain JVM application with the full runtime; fastest for development |
|
Builds the final application image without an installer |
|
Runs that prepackaged image, to test the trimmed runtime |
|
Builds one installer; output goes to |
|
Builds every configured format that this OS supports |
|
One fat JAR for the current OS (needs |
|
Runs |
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.currentOsto produce another OS’s installer. Build each installer on its own OS, typically with a CI matrix. -
Skipping
modules(…)and only testing withrun. The packaged app throwsClassNotFoundException; always runrunDistributable. -
A Gradle version
msirejects. SetpackageVersionexplicitly to aMAJOR.MINOR.BUILDvalue. -
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.IOthroughproduceStateorLaunchedEffect. -
Overlapping a
SwingPanelwith 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 = truefor every shortcut. Usemeta = trueon 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 indesktopAppso the screens stay reusable.