Intro
Some time ago, I was working on a desktop application for a client. Nothing particularly complex — a program with a simple but functional graphical interface. A few forms with input validation, reading and writing files to the local disk, and integration with an external API.
One of the requirements was support for Windows. Even though all popular IDEs are available on that platform, I use Linux on a daily basis and had no intention of switching operating systems just for this project.
The first challenge was finding a technology that would allow me to build a single application and run it on multiple operating systems. There were two obvious options on the table:
- a web application running in a browser,
- a desktop application.
I started by building a simple prototype using streamlit in Python — a framework for creating browser-based data applications. While UI design and data handling worked quite well, the project quickly became difficult to manage as more views were added.
Further research led me to a framework developed and maintained by JetBrains — Kotlin Compose Multiplatform. The promise was appealing: write the application once and run it in the browser, on the desktop, and on mobile devices. On top of that, JVM-based code should run without much trouble across different operating systems.
It sounded like a solution tailored exactly to my needs. What could possibly go wrong?
This article focuses on the build, packaging, and distribution aspects of a Compose Desktop application. It deliberately avoids UI development details and instead concentrates on Gradle configuration, OS-specific constraints, and the practical steps required to deliver a runnable application to end users.
IDE Support
Let’s start with the obvious: JetBrains is the company behind both Compose and IntelliJ IDEA. It’s reasonable to assume that they would provide solid IDE support for their own framework — at least that was my expectation.
So how does it look in practice?
There is an official plugin available in the marketplace called Compose Multiplatform for Desktop IDE Support. However, its description contains the following notice:
⚠️ This plugin is in maintenance mode
The Compose Multiplatform for Desktop IDE Support plugin is no longer under active development.
Its functionality is being gradually integrated into the new Kotlin Multiplatform plugin,
which already includes support for Compose Previews on macOS.
What this means for you:
On macOS: We recommend switching to the Kotlin Multiplatform plugin, which now includes improved Compose Previews support.
On Windows/Linux: You can continue using this plugin. It remains available and maintained for now, until equivalent support becomes available in the Kotlin Multiplatform plugin.
No new features will be added, but we'll address critical issues as needed during this transition.
Unfortunately, I wasn’t able to make this plugin work reliably on my setup. Which raises an important question: what is it actually needed for?
In theory, the plugin allows you to preview Compose UI directly in the IDE without rebuilding and running the application. This is extremely useful during UI development, as it shortens the feedback loop significantly.
Of course, it’s still possible to work without it. The downside is that every UI change requires rebuilding and restarting the application, which makes the iteration cycle noticeably slower.
Packaging and Distribution
My primary goal was to distribute the application together with its runtime as a single package. I didn’t want the client to install any additional software on their machine, nor worry about JRE versions or configuration issues.
The application should be installable and runnable out of the box, regardless of the target operating system.
In this section, I’ll show how I implemented the required packaging and distribution setup in Gradle, using Kotlin-based configuration.
OS Specific Dependencies
When working with the JVM, we usually assume that a JAR file built on one operating system will run without issues on another. With Compose Desktop, this assumption does not hold.
Depending on the target operating system, the JAR must include a different runtime dependency:
- Windows —
org.jetbrains.compose.desktop:desktop-jvm-windows-x64 - Linux —
org.jetbrains.compose.desktop:desktop-jvm-linux-x64 - macOS —
org.jetbrains.compose.desktop:desktop-jvm-macos-x64
What’s more, these artifacts cannot be built cross-platform. Each dependency is only available on its dedicated operating system, which means you must build the JAR on the target OS itself.
To handle this, I defined two separate Gradle configurations — one for Windows and one for Linux:
| |
As a result, running the application locally on Linux required building one JAR variant, while delivering the application to the client meant building a separate Windows-specific variant.
Fat JAR
The next step is building a fat JAR — a single executable artifact that contains all required dependencies. This includes both the regular runtime classpath and the OS-specific Compose runtime selected at build time.
While there are existing plugins that can handle fat JAR creation automatically, I opted for an explicit setup here. The goal was full control over which artifacts end up in the final JAR and, more importantly, which OS-specific runtime gets included.
| |
This setup ensures that the resulting JAR always contains the correct Compose runtime for the operating system it was built on — and only that one.
Custom Runtime
To make the application truly portable, a custom and minimal runtime environment is required.
Starting with Java 9, the JDK provides the jlink tool for exactly this purpose.
For Gradle, there is a well-known and actively maintained plugin —
badass-jlink-plugin.
However, in this case I opted for a custom solution to retain full control over the runtime generation process.
Instead of relying on an external plugin, I implemented a dedicated Gradle task that invokes jlink directly:
| |
It’s important to note that the required Java modules must be specified explicitly. Only the listed modules will be included in the resulting runtime, which keeps it small and tailored to the application’s actual needs.
App Image
The next step is producing an actual runnable application bundle that uses the custom runtime built in the previous step.
This is where jpackage comes in.
Instead of generating an installer right away, I start by creating an app image — a self-contained application directory containing:
- the launcher executable,
- the fat JAR,
- the custom runtime image,
- and OS-specific metadata (like the icon).
The exact format and structure are OS-dependent, but the result can be launched without any additional setup.
| |
Final Package
Instead of generating a platform-specific installer, I decided to ship the application as a single ZIP archive. This approach keeps the delivery process simple and avoids installer-related issues such as permissions, system integration, or corporate security restrictions.
The ZIP archive contains the complete app image produced in the previous step — including the launcher, application code, and the custom runtime. After extraction, the application can be started immediately.
| |
GitHub Workflow
To automate the build and distribution process, I set up a GitHub Actions workflow that produces separate packages for Linux and Windows using dedicated runners.
The workflow is triggered when a new GitHub release is published. Each job builds the application for its target operating system and uploads the resulting ZIP archive as a release asset.
| |
Conclusion
Compose Desktop turned out to be a solid choice for building a cross-platform desktop application, but the real complexity lies outside the UI layer. Most of the challenges I encountered were related to build configuration, OS-specific dependencies, and packaging — not day-to-day application development.
The JVM ecosystem provides all the necessary tools to address these problems, but they rarely work out of the box for desktop applications. A reliable setup requires explicit decisions about runtime composition, build isolation per operating system, and artifact structure.
One notable disappointment was the state of IDE support. Given that Compose and IntelliJ IDEA are both JetBrains products, I expected a more mature and consistent development experience. In practice, UI previews were unreliable on Linux, and a significant part of the workflow still depended on rebuilding and restarting the application.
By investing time in a Gradle-based build pipeline, I ended up with a predictable and repeatable distribution process:
- separate builds per OS,
- a minimal custom runtime,
- a portable ZIP archive ready to be delivered to the client.
The main takeaway is that cross-platform desktop development with Compose is absolutely feasible — as long as the build and distribution pipeline reflects the OS-bound nature of the platform and tooling limitations are taken into account.