Shadow and Obfuscate Compose Desktop application

Roman
5 min readAug 2, 2024

--

As of 12 June 2024, there’s no yet great tutorial for Compose Desktop ShadowJar and obfuscate.
The existing tutorials will tell you how to use ComposeMultiplatform gradle task to distribute native.
But what if you want to use ShadowJar? So every user can launch your application via Java.

Disclaimer: This tutorial will provide some corner cases, but depending on your projects the steps can be different.
This article probably will not provide full coverage of specific case.
Moreover, not every user will be able to launch created `.jar` file.
For example, user with Java8 will not be able to launch `.jar` file, which is built with later version of Java.

Let’s break this article into small steps:

  1. Setup build.gradle.kts with required plugins
  2. Add required dependencies
  3. Setup basic compose.desktop requirements
  4. Create shadowJar task
  5. Create ProGuard task

Create project

If you don’t have project — you can download it from https://terrakok.github.io/Compose-Multiplatform-Wizard/

Setup dependencies

After download let’s fill our `libs.versions.toml` with pre-defined dependencies:

[versions]
# Project
packagename = "com.makeevrserg.composeshadow"
version-string = "1.0.0"
name = "ComposeShadow"
description = "Sample compose shadow"

[libraries]
proguard = { module = "com.guardsquare:proguard-gradle", version.strictly = "7.5.0" }
kotlin-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.strictly = "1.9.0-RC" }

[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.strictly = "2.0.20-RC" }
kotlin-compose = { id = "org.jetbrains.compose", version.strictly = "1.7.0-alpha02" }
kotlin-compose-gradle = { id = "org.jetbrains.kotlin.plugin.compose", version.strictly = "2.0.20-RC" }
shadow = { id = "com.github.johnrengelman.shadow", version.strictly = "8.1.1" }

Setup gradle.kts

In your `root.gradle.kts` you should add proguard and shadow

buildscript {
dependencies {
classpath(libs.proguard)
}
}

plugins {
alias(libs.plugins.some.other.dependencies).apply(false)
alias(libs.plugins.shadow).apply(false)
}

Now we need to setup our `build.gradle.kts` of ComposeDesktop module.

The main pitfall here is that we can’t use `kotlin(“multiplatform”)`. The ShadowJar plugin doesn’t work with it. So the
solution is to enable `kotlin(“jvm”)` instead.

plugins {
kotlin("jvm")
id("org.jetbrains.compose")
id("org.jetbrains.kotlin.plugin.compose")
alias("com.github.johnrengelman.shadow")
}

Now we need to add compose dependencies. There’s also some pitfalls:

  • Jars built on Windows will be able to launch only on windows
  • Jars built on Linux will be able to launch on Windows and Linux
  • Jars build on MacOs will be able to launch on MacOs, Windows and Linux

Thus, we must built our shadow on MacOs to enable all targets.

But I don’t have MacOs! No problem — GitHub provides us with CI, which contains different images, including macos. You
can use it to build this super mega jar file!
Again — test locally on your current OS and use GitHub CI to build it for every os at once on MacOs runner.

Setup dependencies

dependencies {
implementation(compose.desktop.macos_x64)
implementation(compose.desktop.macos_arm64)
implementation(compose.desktop.linux_x64)
implementation(compose.desktop.linux_arm64)
implementation(compose.desktop.windows_x64)
// Here your other dependencies
}

Here we enable list of all compose desktop targets. The native binaries will be embedded inside resulting `.jar` file.

Setup compose plugin

May be you want use ShadowJar, but during debug it’s very useful to have compose-desktop plugin configured, so setup
this:

compose.desktop {
application {
// Target at main class which contains main() function
mainClass = "${libs.versions.packagename.get()}.MainKt"
// Add jvm arguments of your taste
jvmArgs += listOf("--add-opens", "java.base/java.lang=ALL-UNNAMED")
// Setup native distributions just in case
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
licenseFile.set(rootProject.file("LICENSE.md"))
packageName = libs.versions.packagename.get()
description = libs.versions.description.get()
packageVersion = libs.versions.version.string.get()
// Here you should insert modules, which will be embedded
// Not very convenient if you're not familiar with java, but print this and sse what jmods your java have
println("JMODS Folder: ${compose.desktop.application.javaHome}/jmods/java.base.jmod")
// For example, if you're using ROOM Multiplatform, you definitely need this
modules("java.sql")
// Or include everything at once
includeAllModules = false
}
}
}

Finally, the initial step is completed. You can now creat jar and native distributions via commands:

./gradlew :composeApp:packageDistributionForCurrentOS
./gradlew :composeApp:packageUberJarForCurrentOS

But we want to use shadow for independence from composeDesktop plugin!

Setup ShadowJar

The default ShadowJar task probably shouldn’t cause many troubles, but it can vary from project to project.

The task below covers many corner cases, so probably should work in most projects.

val shadowJar by tasks.named<ShadowJar>("shadowJar") {
dependsOn(configurations)
// Different for every project, but just in case
minimize {
// Exclude swing-coroutines
exclude(dependency(libs.kotlin.coroutines.swing.get()))
// Exclude ever compose target
exclude(dependency(dependencies.compose.desktop.macos_x64))
exclude(dependency(dependencies.compose.desktop.macos_arm64))
exclude(dependency(dependencies.compose.desktop.linux_x64))
exclude(dependency(dependencies.compose.desktop.linux_arm64))
exclude(dependency(dependencies.compose.desktop.windows_x64))
// Using apache poi? Or some other legacy java dependency?
// Don't forget to exclude
exclude(dependency("org.apache.poi:poi-ooxml:.*"))
// Some subprojects excluded during minimize?
// Add every dependency manually or everything at once like that
rootProject.subprojects.map(::dependency).forEach(::exclude)
}
// Some default shadow setup
mergeServiceFiles()
isReproducibleFileOrder = true
archiveClassifier = null as String?
archiveVersion = "${libs.versions.version.string.get()}-desktop"
archiveBaseName = libs.versions.name.get()
// Relocate output jar into other folder
rootProject.file("jars").also { destination ->
if (!destination.exists()) destination.parentFile?.mkdirs()
destinationDirectory = destination
}
// Don't forget to point into Main file which contains main() function
manifest {
attributes("Main-Class" to "${libs.versions.packagename.get()}.MainKt")
}
}

Now we can finally create our `.jar` file with ShadowJar task:

./gradlew :composeApp:shadowJar

And this `.jar` file contains native binaries for all platforms(if built on macos)

Ok, that’s cool, but didn’t I just unzipped .jar file? I can literally see the sources and understand them! I don’t want
someone to steal my code!

That’s the point, we can finally use Gradle ProGuard task, let’s configure it

Setup ProGuard

The proguard task is difficult because of many pitfalls.
Doubtful that you will run obfuscate task successfully from first run.
Even if you do — the launched .jar task probably will crash because of unresolved class.

tasks.register<ProGuardTask>("obfuscate") {
// Don't use Jetbrains JDK!!
dependsOn(shadowJar)
// Insert basic jmods
libraryjars("${compose.desktop.application.javaHome}/jmods/java.base.jmod")
libraryjars("${compose.desktop.application.javaHome}/jmods/java.desktop.jmod")
// If you have something specific - add this jars here, as in example
libraryjars(project.file("lib").resolve("asm-3.1.jar"))
// Create obfuscated from shadow output
injars(shadowJar.outputs.files)
// Set output file for obfuscated jar
val obfuscated = rootProject.file("jars")
.resolve("${libs.versions.name.get()}-${libs.versions.version.string.get()}-desktop-obf.jar")
outjars(obfuscated)
// Don't forget to create seed so you can backport obfuscated sources
printseeds("$buildDir/obfuscated/seeds.txt")
printmapping("$buildDir/obfuscated/mapping.txt")
// Add any proguard files
// And don't forget to add default compose desktop proguard
configuration(files("proguard-rules.pro", "default-compose-desktop-rules.pro"))

verbose()
}

We finally can obfuscate our .jar file

./gradlew :composeApp:obfuscate

Don’t use JetBrains runtime

Wait! The console is red! It said it can’t resolve some classes or something like that!

Yet another pitfall. Make sure you don’t use JetBrains’s Runtime Java!

There can be other different pitfalls during ProGuard obfuscate tasks which can’t be covered in this tutorial.
Just use `./gradlew :composeApp:obfuscate — info — stacktrace` to see every error happened during build and add
exclusions by your own.

The size of jar

Depending on your ProGuard configuration, the size of generated jar files will be different.
But anyway, you are embedding 5 compose targets. So the size is minimum ~60MB.
So if you are ready to sacrifice the size — the shadowJar is for you!

Sources

The completed and working source is located at https://github.com/makeevrserg/ComposeShadow

--

--