JetBrains / compose-multiplatform

Compose Multiplatform, a modern UI framework for Kotlin that makes building performant and beautiful user interfaces easy and enjoyable.
https://jetbrains.com/lp/compose-multiplatform
Apache License 2.0
16.38k stars 1.18k forks source link

Document how to use ProGuard with Compose for Desktop #1174

Closed ghost closed 2 years ago

ghost commented 3 years ago

I build my binary with packageDmg / packageMsi and I'm looking for a sample how I must modify my build.gradle.kts to include an obfuscation step. I don't find an answer to that.

I think it would be nice if the official template would just contain such a step because it's a hard requirement for anybody who ships business software to customers.

In issue #607 this was asked, but the answer was using packageUberJarForCurrentOS and obfuscate that. I find that not really satisfying, because I want the DMG / MSI as a result.

Further the Gradle task for Android comes with easy proguard support. Why shoul the nativeDistributions section not contain a proguardConfFile param just to make it work out of the box?

dkoding commented 3 years ago

This is the latest hurdle preventing us from switching to compose in our development pipeline.

ghost commented 2 years ago

@igordmn @akurasov I saw that a guide was added how to obfuscate the uberjar. That's a great first step.

Can you now add how I can feed the resulting uberjar into the process that gets me a MSI / DMG file out? What tasks can I call for that?

Or do we rather still need this issue to hook ProGuard into the whole process?

mcpiroman commented 2 years ago

I have managed to create a template setup with ProGruard, that supports native distribution and packaging. It does not relay on uberjar, but can be easily modified to merge jars.

ghost commented 2 years ago

I have managed to create a template setup with ProGruard, that supports native distribution and packaging. It does not relay on uberjar, but can be easily modified to merge jars.

Thank you a lot for your effort!

Do you have any idea how this must look for a Multiplatform project? tasks.jar is here not available - only for jvm.

Does it have another name in that scope or is another solution needed?

Unfortunately I can't convert my desktop module to jvm as my shared module does not compile anymore in that case.

mcpiroman commented 2 years ago

@AshStefanOltmann Dunno :( Only enabling certain tasks for jvm should be somehow possible, I'd read a docs about configuring kotlin multiplatform project.

SmialyKot commented 2 years ago

Any news on this topic? I've configured Proguard to my liking based on the guide, which creates a nice obfuscated .jar, but I'm stuck trying to somehow connect that to native distribution packaging, so that .exe, .dmg generated by ./gradlew package use this file automatically instead of the default one.

mcpiroman commented 2 years ago

@SmialyKot https://github.com/JetBrains/compose-jb/issues/1174#issuecomment-1075155835

ghost commented 2 years ago

@SmialyKot If you use jvm the template above will likely help you. If you also require multiplatform I guess we need to wait until @akurasov has news for us.

MrStahlfelge commented 2 years ago

I've got the following setup to work:

I followed different routes, but ultimately, I skipped the compose desktop plugin entirely due to different shortcomings. The setup is now:

Build file is here

racka98 commented 2 years ago

I have managed to create a template setup with ProGruard, that supports native distribution and packaging. It does not relay on uberjar, but can be easily modified to merge jars.

I've manage to sort of get this working. The app launches fine but I get javax.management.MalformedObjectNameException when I open any screen destination that uses ktor for Networking. Not sure how I would go about fixing this.

Also is there a way to get the Proguard task to only run with packageX instead of it running even when you do a simple gradlew :run

racka98 commented 2 years ago

I have managed to create a template setup with ProGruard, that supports native distribution and packaging. It does not relay on uberjar, but can be easily modified to merge jars.

Thank you a lot for your effort!

Do you have any idea how this must look for a Multiplatform project? tasks.jar is here not available - only for jvm.

Does it have another name in that scope or is another solution needed?

Unfortunately I can't convert my desktop module to jvm as my shared module does not compile anymore in that case.

You can get tasks.jar using named tasks, like val jarTask = tasks.named<Jar>("jar")

Though this may be problematic if the module you run this on also has an Android sourceSet

StefanOltmann commented 2 years ago

You can get tasks.jar using named tasks, like val jarTask = tasks.named<Jar>("jar")

Though this may be problematic if the module you run this on also has an Android sourceSet

Thanks for your reply. Unfortunately this does not work for me.

The solution of MrStahlfelge also does not work for me, because here the "shadowJar" task can't be found.

My desktop app uses a Kotlin Multiplatform shared library. In that case the configuration looks like this: https://github.com/JetBrains/compose-jb/blob/master/templates/multiplatform-template/desktop/build.gradle.kts

Here the provided solution is not applicable.

I guess I will have to go the way using packageUberJarForCurrentOS, obfuscating that and use jpackage manually. The project of MrStahlfelge shows how to use jpackage with GitHub Actions which will be a great help. :)

I still wish for a build-in solution (like the Android Gradle plugin has) to do that, but my time is running out and I have to deliver my app soon.

MrStahlfelge commented 2 years ago

Problem with packageUberJarForCurrentOS was for me that it is not customizable and failed because zip64 could not be set. You can absolutely use it instead of shadowjar when it works for you.

For having the shadowjar task available, you have to add the shadowjar plugin to the Gradle build classpath (see shadowjar documentation)

racka98 commented 2 years ago

I tried the MrStahlfelge's method and just got jar that couldn't run. I was using the Multiplatform plugin on the desktop module for my app but switched to jvm (since I don't use any of the other targets in this module). packageUberJarForCurrentOS is not present in my project for some reason. I had success with the other method after switching to full jvm but ProGuard still removes important stuff like @Serializable and SLF4J despite having rules to keep them.

StefanOltmann commented 2 years ago

but ProGuard still removes important stuff like @Serializable and SLF4J despite having rules to keep them.

Yes, that's really strange. For Android ProGuard it worked with the settings from the docs of KotlinX Serializable.

For my Compose for Desktop JAR I needed to add the entries mentioned here: https://github.com/Kotlin/kotlinx.serialization/issues/1105#issuecomment-1126717300

racka98 commented 2 years ago

That got rid of the @Serializable issue but seems like there's a lot more stuff that's been removed. I use Ktor and that doesn't work either. Now I just get generic error: java.lang.RuntimeException: java.lang.NoSuchFieldException: top when I initiate a network call.

I guess I'll have to keep looking

StefanOltmann commented 2 years ago

That got rid of the @Serializable issue but seems like there's a lot more stuff that's been removed. I use Ktor and that doesn't work either. Now I just get generic error: java.lang.RuntimeException: java.lang.NoSuchFieldException: top when I initiate a network call.

I guess I'll have to keep looking

Call me lazy but because of this I ended up with

# Don't touch third party libraries
-dontwarn !com.mycompany.myapp.**
-keep class !com.mycompany.myapp.** { *; }
racka98 commented 2 years ago
# Don't touch third party libraries
-dontwarn !com.mycompany.myapp.**
-keep class !com.mycompany.myapp.** { *; }

This definitely fixes my problem but the package size isn't reduced by much. It just went from 90MB -> 75MB

racka98 commented 2 years ago

However, I've found a temp solution for my problem using -dontobfuscate in the proguard-rules. I know this means I give up the security obfuscation provides but it's better than nothing. With this the package size went form 90MB to 52MB (was 46MB with obfuscate enabled).

Now I just need to figure out how to setup the defined proguard task to only run when you use the packageX tasks or createDistributable and not when you just do a simple :run task. Because right now I runs the proguard task even when you do gradlew :run and this can slow down development a bit since the run task doesn't even use the obfuscated jar when launching.

My current proguard-rules file is as follows:

-keepclasseswithmembers public class com.company.MainKt {  # <-- Change com.company to yours
    public static void main(java.lang.String[]);
}

-dontwarn kotlinx.coroutines.debug.*

-keep class kotlin.** { *; }
-keep class kotlinx.** { *; }
-keep class kotlinx.coroutines.** { *; }
-keep class org.jetbrains.skia.** { *; }
-keep class org.jetbrains.skiko.** { *; }

-assumenosideeffects public class androidx.compose.runtime.ComposerKt {
    void sourceInformation(androidx.compose.runtime.Composer,java.lang.String);
    void sourceInformationMarkerStart(androidx.compose.runtime.Composer,int,java.lang.String);
    void sourceInformationMarkerEnd(androidx.compose.runtime.Composer);
}

# Keep `Companion` object fields of serializable classes.
# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects.
-if @kotlinx.serialization.Serializable class **
-keepclassmembers class <1> {
    static <1>$Companion Companion;
}

# Keep `serializer()` on companion objects (both default and named) of serializable classes.
-if @kotlinx.serialization.Serializable class ** {
    static **$* *;
}
-keepclassmembers class <2>$<3> {
    kotlinx.serialization.KSerializer serializer(...);
}

# Keep `INSTANCE.serializer()` of serializable objects.
-if @kotlinx.serialization.Serializable class ** {
    public static ** INSTANCE;
}
-keepclassmembers class <1> {
    public static <1> INSTANCE;
    kotlinx.serialization.KSerializer serializer(...);
}

# @Serializable and @Polymorphic are used at runtime for polymorphic serialization.
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault

-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt # core serialization annotations
-dontnote kotlinx.serialization.SerializationKt

# Keep Serializers

-keep,includedescriptorclasses class com.company.package.**$$serializer { *; }  # <-- Change com.company.package
-keepclassmembers class com.company.package.** {  # <-- Change com.company.package to yours
    *** Companion;
}
-keepclasseswithmembers class com.company.package.** { # <-- Change com.company.package to yours
    kotlinx.serialization.KSerializer serializer(...);
}

# When kotlinx.serialization.json.JsonObjectSerializer occurs

-keepclassmembers class kotlinx.serialization.json.** {
    *** Companion;
}
-keepclasseswithmembers class kotlinx.serialization.json.** {
    kotlinx.serialization.KSerializer serializer(...);
}

# JSR 305 annotations are for embedding nullability information.
-dontwarn javax.annotation.**

# A resource is loaded with a relative path so the package of this class must be preserved.
-adaptresourcefilenames okhttp3/internal/publicsuffix/PublicSuffixDatabase.gz

# Animal Sniffer compileOnly dependency to ensure APIs are compatible with older versions of Java.
-dontwarn org.codehaus.mojo.animal_sniffer.*

# OkHttp platform used only on JVM and when Conscrypt and other security providers are available.
-dontwarn okhttp3.internal.platform.**
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**
#################################### SLF4J #####################################
-dontwarn org.slf4j.**

# Prevent runtime crashes from use of class.java.getName()
-dontwarn javax.naming.**

# Ignore warnings and Don't obfuscate for now
-dontobfuscate
-ignorewarnings
racka98 commented 2 years ago

Here's my completed build.gradle.kts. This setup works perfectly when paired with the above proguard-rules. This works if you are using kotlin("jvm"). Will try to make a setup for multiplatform later since it kind of worked before with multiplatform.

I have an Environment variable called OPTIMIZE that I can set to true for my custom gradle run configurations in Intellij. You can also use System.properties to have a similar value (gradle -Poptimize=true :packageX). This is to avoid running the proguard task when you don't need it because it will slow down your builds. You could also remove the check and just run the Main function from the IDE to avoid running the proguard task when you are just developing.

I have also exculed all slf4j and logback containing Jars to avoid crashes when you are using Ktor or Kermit for logging. You can exclude more Jars that you want to skip if they have issues. Note: Using || or && inside partition{} is unreliable and can lead to skipping things completely. Prefer using .or() / and() in this case.

Package size for .exe and .msi went from 90MB to 51.7MB. That's a 42.5% reduction in size.

I hope this helps someone who has been struggling with the setup like me.

plugins {
    kotlin("jvm")
    id("org.jetbrains.compose")
    kotlin("plugin.serialization").version("1.7.0")
}

group = "com.company"
version = "1.0"

repositories {
    google()
    mavenCentral()
    maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
}

with(tasks) {
    withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
        kotlinOptions.jvmTarget = JavaVersion.VERSION_11.majorVersion
    }
}

dependencies {
    // Compose
    implementation(compose.desktop.currentOs)
    implementation(compose.materialIconsExtended)
    @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
    implementation(compose.desktop.components.splitPane)
    @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
    implementation(compose.desktop.components.animatedImage)

    // Koin
    implementation("io.insert-koin:koin-core:$koinVersion")
    implementation("io.insert-koin:koin-test:$koinVersion")

    // Kotlin - Date/Time
    implementation("org.jetbrains.kotlinx:kotlinx-datetime:$kotlinDateTimeVersion")

    // Ktor
    implementation("io.ktor:ktor-client-core:$ktorVersion")
    implementation("io.ktor:ktor-client-java:$ktorVersion")
    implementation("io.ktor:ktor-client-logging:$ktorVersion")
    implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
    implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
    implementation("io.ktor:ktor-client-resources:$ktorVersion")

    // Coroutines
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:$coroutinesVersion")

    // Decompose - Navigation
    implementation("com.arkivanov.decompose:decompose:$decomposeVersion")
    implementation("com.arkivanov.decompose:extensions-compose-jetbrains:$decomposeVersion")

    // Multiplatform Settings
    implementation("com.russhwolf:multiplatform-settings:$multiplatformSettingsVersion")
    implementation("com.russhwolf:multiplatform-settings-no-arg:$multiplatformSettingsVersion")

    // SQL Delight
    implementation("com.squareup.sqldelight:runtime:$sqldelightVersion")
    implementation("com.squareup.sqldelight:coroutines-extensions:$sqldelightVersion")
    implementation("com.squareup.sqldelight:sqlite-driver:$sqldelightVersion")

    // Logs
    implementation("ch.qos.logback:logback-classic:$logbackVersion")
    implementation("co.touchlab:kermit:$kermitVersion")
}

compose.desktop {
    application {
        mainClass = "com.comany.app.MainKt"
        nativeDistributions {
            targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Exe, TargetFormat.Deb)
            packageName = "MyApp"
            packageVersion = "1.0.0"
            description = "MyDescription"
            copyright = "© 2022 MyApp. All rights reserved."
            vendor = "MyCompany"

            modules("java.net.http")

            windows {
                shortcut = true
                menuGroup = packageName
                //https://wixtoolset.org/documentation/manual/v3/howtos/general/generate_guids.html
                upgradeUuid = "28EA1N5A-D39A-4D09-A6FC-EFD835CFTG72"
            }
        }

        // Run ProGuard configuration when OPTIMIZE is set to true in Environment Configs
        if (System.getenv("OPTIMIZE").toBoolean()) {
            configureProguard()
        }
    }
}

fun JvmApplication.configureProguard() {
    val allJars =
        tasks.jar.get().outputs.files + sourceSets.main.get().runtimeClasspath.filter { it.path.endsWith(".jar") }
            // workaround https://github.com/JetBrains/compose-jb/issues/1971
            .filterNot { it.name.startsWith("skiko-awt-") && !it.name.startsWith("skiko-awt-runtime-") }
            .distinctBy { it.name } // Prevent duplicate jars

    // Split the Jars to get the ones that need obfuscation and those that do not
    val (obfuscateJars, otherJars) = allJars.partition {
        !it.name.contains("slf4j", ignoreCase = true)
            .or(it.name.contains("logback", ignoreCase = true))
    }

    // Proguard Task definition!
    val proguard by tasks.register<ProGuardTask>("proguard") {
        dependsOn(tasks.jar.get())
        println("Config ProGuard")
        for (file in obfuscateJars) {
            injars(file)
            outjars(mapObfuscatedJarFile(file))
        }
        val library = if (System.getProperty("java.version").startsWith("1.")) "lib/rt.jar" else "jmods"
        libraryjars("${compose.desktop.application.javaHome ?: System.getProperty("java.home")}/$library")
        libraryjars(otherJars)
        configuration("desktop-proguard-rules.pro")
    }

    // Disable Compose Desktop default config and add your own Jars
    disableDefaultConfiguration()
    fromFiles(proguard.outputs.files.asFileTree)
    fromFiles(otherJars)
    mainJar.set(tasks.jar.map { RegularFile { mapObfuscatedJarFile(it.archiveFile.get().asFile) } })
}

// Map Files to a known path
fun mapObfuscatedJarFile(file: File) =
    File("${project.buildDir}/tmp/obfuscated/${file.nameWithoutExtension}.min.jar")
dimsuz commented 2 years ago

Is the use of Proguard documented somewhere? Commit which closes this issue doesn't contain any new documentation, and this issue is about documenting proguard usage. Or was it documented independently in the past?

StefanOltmann commented 2 years ago

@dimsuz Doesn't look like that. I skipped to the PR and all I can see is that there is a Unit test project that gives a clue how to use it. Look at gradle-plugins/compose/src/test/test-projects/application/proguard/

@AlexeyTsvetkov Thank you for implementing this! 👍 We got one big step further to mass adoption now. 💯

sproctor commented 2 years ago

Has anyone figured out how to use this in production? I see a new gradle command proguardReleaseJars. I'm sure I'm just a bit lost. I tried building with ./gradlew :app:proguardReleaseJars :app:packageDeb but it appears that the resulting package isn't optimized. I'm working on an open source project, so my main goal is to reduce the size of the resulting binary. Using extended material icons makes it quite unnecessarily large. Reading the examples hasn't helped my understanding.

okushnikov commented 4 months ago

Please check the following ticket on YouTrack for follow-ups to this issue. GitHub issues will be closed in the coming weeks.

shirozatou commented 2 weeks ago

My gradle configuration, seems OK to me.

compose.desktop { // ...

application.buildTypes.release.proguard {
    version = "7.6.0"
    isEnabled = true  // false to disable proguard
    optimize = true
    obfuscate = true

    // additional rule
    configurationFiles.from(file("proguard-rules.pro"))
}

}

tasks.withType(org.jetbrains.compose.desktop.application.tasks.AbstractProguardTask::class.java) { val proguardFile = File.createTempFile("tmp", ".pro", temporaryDir) proguardFile.deleteOnExit()

compose.desktop.application.buildTypes.release.proguard {
    configurationFiles.from(proguardFile)
}

doFirst {
    proguardFile.bufferedWriter().use { proguardFileWriter ->
        sourceSets.main.get().runtimeClasspath
            .filter { it.extension == "jar" }
            .forEach { jar ->
                val zip = zipTree(jar)
                zip.matching { include("META-INF/**/proguard/*.pro") }.forEach {
                    proguardFileWriter.appendLine("########   ${jar.name} ${it.name}")
                    proguardFileWriter.appendLine(it.readText())
                }
                zip.matching { include("META-INF/services/*") }.forEach {
                    it.readLines().forEach { cls ->
                        val rule = "-keep class $cls"
                        proguardFileWriter.appendLine(rule)
                    }
                }
            }
    }
}

}