Hi this is my first question! I'm trying to build a standalone kotlin - compose - spring app. My client requested this app for windows so I'd like to create an installer that includes java runtime (my client doesn't have it and wants it to run on its own).
The problem is I can't seem to get it right with jpackage. This is what my gradle looks like, including the jpackage command I'm using:
plugins {
kotlin("jvm") version "1.9.10"
kotlin("plugin.spring") version "1.9.10"
id("org.springframework.boot") version "3.4.1"
id("io.spring.dependency-management") version "1.1.7"
kotlin("plugin.jpa") version "1.9.10"
// compose
id("org.jetbrains.compose") version "1.5.10"
// kotlin - spring
// id("kotlin") version "1.9.10"
// id("kotlin-spring") version "1.9.10"
}
group = "com.tfra"
var appName = "MechanicManagement"
version = "0.0.1"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
configurations {
compileOnly {
extendsFrom(configurations.annotationProcessor.get())
}
}
repositories {
google()
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.jetbrains.kotlin:kotlin-reflect")
compileOnly("org.projectlombok:lombok")
// runtimeOnly("org.postgresql:postgresql")
annotationProcessor("org.projectlombok:lombok")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
implementation(compose.desktop.currentOs)
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-web")
// https://mvnrepository.com/artifact/org.postgresql/postgresql
implementation("org.postgresql:postgresql:42.7.5")
// https://mvnrepository.com/artifact/com.mysql/mysql-connector-j
// implementation("com.mysql:mysql-connector-j:9.1.0")
}
compose.desktop {
application {
mainClass = "com.tfra.mechanic_management.MechanicManagementApplication"
}
}
kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict")
}
}
allOpen {
annotation("jakarta.persistence.Entity")
annotation("jakarta.persistence.MappedSuperclass")
annotation("jakarta.persistence.Embeddable")
}
springBoot {
mainClass = "com.tfra.mechanic_management.MechanicManagementApplicationKt"
}
tasks.withType<Test> {
useJUnitPlatform()
}
tasks.named<org.springframework.boot.gradle.tasks.bundling.BootJar>("bootJar") {
archiveFileName.set("${appName}-${version}.jar")
manifest {
attributes["Main-Class"] = "com.tfra.mechanic_management.MechanicManagementApplicationKt"
}
// Aggiunge le dipendenze al JAR
doFirst {
// Elimina eventuali duplicati
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
// Aggiunge tutte le dipendenze del classpath al JAR
from({
configurations.runtimeClasspath.get().filter { it.exists() }.map {
if (it.isDirectory) it else zipTree(it)
}
})
}
tasks.register<Exec>("generateRuntimeImage") {
group = "build"
description = "Generates a custom runtime image"
val runtimeDir = layout.buildDirectory.dir("my-runtime").get().asFile
doFirst {
if (runtimeDir.exists()) {
runtimeDir.deleteRecursively() // Cancella la directory esistente
}
}
commandLine(
"jlink",
"--module-path", "${System.getProperty("java.home")}/jmods;build/libs",
"--add-modules", "java.base,java.desktop,java.sql,java.naming",
"--output", layout.buildDirectory.dir("my-runtime").get().asFile.absolutePath
)
}
tasks.register<Exec>("createInstaller") {
group = "distribution"
description = "Creates a native installer for the application"
dependsOn("bootJar") // Assicura che bootJar venga eseguito prima
// dependsOn("jar")
val outputDir = layout.buildDirectory.dir("installer").get().asFile.absolutePath
val jarPath = layout.buildDirectory.file("libs/${appName}-${version}.jar").get().asFile.absolutePath
val javaHome = System.getProperty("java.home")
doFirst {
mkdir(outputDir)
}
dependsOn("generateRuntimeImage")
commandLine(
"$javaHome/bin/jpackage",
"--type", "exe",
"--input", "build/libs",
"--main-jar", jarPath,
"--name", appName,
"--main-class", "com.tfra.mechanic_management.MechanicManagementApplicationKt",
"--dest", outputDir,
"--app-version", version.toString(),
"--icon", "src/main/resources/car_icon.ico",
"--java-options", "-Djava.awt.headless=false",
"--java-options", "-Dlogging.file=app.log",
"--resource-dir", "src/main/resources",
"--runtime-image", layout.buildDirectory.dir("my-runtime").get().asFile.absolutePath
)
}
this is my kotlin application class, MechanicManagementApplication.kt :
@SpringBootApplication
@EnableJpaRepositories(basePackages = ["com.tfra.mechanic_management.repository.jpa"])
@EntityScan(basePackages = ["com.tfra.mechanic_management.repository.entity"])
@ComponentScan(basePackages = ["com.tfra"])
//@SpringBootApplication(exclude = [DataSourceAutoConfiguration::class])
class MechanicManagementApplication
fun main(args: Array<String>) {
// val context = runApplication<MechanicManagementApplication>()
// application {
// AppUI(context)
// }
val context = runApplication<MechanicManagementApplication>(*args)
application {
Window(
onCloseRequest = ::exitApplication,
title = "Mechanic Management"
) {
MaterialTheme { // Fornisce il tema di Material Design
AppUI(context)
}
}
}
}
when I run the generated jar or the exe from the installer (which, also, I have to execute in Windows Sandbox because otherwise it won't execute and I have to terminate using task manager), I get the following:
Error: Could not find or load main class com.tfra.mechanic_management.MechanicManagementApplicationKt
Caused by: java.lang.ClassNotFoundException: com.tfra.mechanic_management.MechanicManagementApplicationKt
What am I doing wrong?
You are trying to outsmart Spring Boot (and maybe even jpackage
?).
tasks.named<org.springframework.boot.gradle.tasks.bundling.BootJar>("bootJar") {
archiveFileName.set("${appName}-${version}.jar")
manifest {
attributes["Main-Class"] = "com.tfra.mechanic_management.MechanicManagementApplicationKt"
}
// Aggiunge le dipendenze al JAR
doFirst {
// Elimina eventuali duplicati
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
// Aggiunge tutte le dipendenze del classpath al JAR
from({
configurations.runtimeClasspath.get().filter { it.exists() }.map {
if (it.isDirectory) it else zipTree(it)
}
})
}
This task is basically replacing what Spring Boot already does and in a bad way as well. This will miss out on things.
Instead just ditch this restructuring of the Spring Boot bootJar
task and let it do its thing instead of trying to be smarter. As you now have a jar which is executable (it contains a MANIFEST.MF
with all the proper entries) you can also remove the --main-class
from the createInstaller
task.
tasks.register<Exec>("createInstaller") {
group = "distribution"
description = "Creates a native installer for the application"
dependsOn("bootJar") // Assicura che bootJar venga eseguito prima
// dependsOn("jar")
val outputDir = layout.buildDirectory.dir("installer").get().asFile.absolutePath
val jarPath = layout.buildDirectory.file("libs/${appName}-${version}.jar").get().asFile.absolutePath
val javaHome = System.getProperty("java.home")
doFirst {
mkdir(outputDir)
}
dependsOn("generateRuntimeImage")
commandLine(
"$javaHome/bin/jpackage",
"--type", "exe",
"--input", "build/libs",
"--main-jar", jarPath,
"--name", appName,
"--dest", outputDir,
"--app-version", version.toString(),
"--icon", "src/main/resources/car_icon.ico",
"--java-options", "-Djava.awt.headless=false",
"--java-options", "-Dlogging.file=app.log",
"--resource-dir", "src/main/resources",
"--runtime-image", layout.buildDirectory.dir("my-runtime").get().asFile.absolutePath
)
}
Now by making things simpler it will work.