Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Highly experimental JAR auto update #3

Merged
merged 8 commits into from
Jun 21, 2024
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ discussed above
- Usually when we add a new fields or modify existing ones in the data classes, like, for example, adding `description`
field in the `Mod` data class, we will try to update the [Admin](./admin) module too to convert the new
data from other launchers to make the process easier for administrations
- The project generate `BuildConfig` object using a Gradle task once you start the application or building it,
you might get `Unresolved reference: BuildConfig` which can be solved by either start the application or building it.

### Development Known Issues 🚧

Expand Down
49 changes: 49 additions & 0 deletions buildSrc/src/main/kotlin/GenerateBuildConfigTask.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import org.gradle.api.DefaultTask
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.provider.MapProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction
import org.gradle.kotlin.dsl.mapProperty
import org.gradle.kotlin.dsl.property

/**
* A task to extract data from the build script into the source code to use it, such as the version of the project.
* */
open class GenerateBuildConfigTask : DefaultTask() {
@get:Input
val fieldsToGenerate: MapProperty<String, Any> = project.objects.mapProperty()

@get:Input
val classFullyQualifiedName: Property<String> = project.objects.property<String>()

@get:OutputDirectory
val generatedOutputDirectory: DirectoryProperty = project.objects.directoryProperty()

@TaskAction
fun execute() {
val directory = generatedOutputDirectory.get().asFile
directory.deleteRecursively()
directory.mkdirs()

val packageNameParts = classFullyQualifiedName.get().split(".")
val className = packageNameParts.last()
val generatedFile = directory.resolve("$className.kt")
val generatedFileContent =
buildString {
if (packageNameParts.size > 1) {
appendLine("package ${packageNameParts.dropLast(1).joinToString(".")}")
}

appendLine()
appendLine("// GENERATED FILE, Manual changes will be overwritten")
appendLine("object $className {")
for ((key, value) in fieldsToGenerate.get().entries.sortedBy { it.key }) {
appendLine(" const val $key = ${if (value is String) "\"$value\"" else value.toString()}")
}
appendLine("}")
}
generatedFile.writeText(generatedFileContent)
}
}
23 changes: 22 additions & 1 deletion common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,26 @@ tasks.test {
}

kotlin {
jvmToolchain(libs.versions.java.get().toInt())
jvmToolchain(
libs.versions.java
.get()
.toInt(),
)
}

val generateBuildConfig =
tasks.register<GenerateBuildConfigTask>("generateBuildConfig") {
val buildConfigDirectory = project.layout.buildDirectory.dir("generated")

classFullyQualifiedName.set("generated.BuildConfig")
generatedOutputDirectory.set(buildConfigDirectory)
fieldsToGenerate.put("PROJECT_VERSION", libs.versions.project.get())
}

sourceSets.main.configure {
kotlin.srcDirs(generateBuildConfig.flatMap { it.generatedOutputDirectory })
}

tasks.compileKotlin.configure {
dependsOn(generateBuildConfig)
}
5 changes: 5 additions & 0 deletions common/src/main/kotlin/constants/ProjectInfoConstants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,9 @@ object ProjectInfoConstants {

// At the moment, we don't have a website; this will make it easier to provide a link to it later
const val WEBSITE = REPOSITORY_LINK

const val LIBS_VERSIONS_TOML_FILE_URL =
"https://raw.githubusercontent.com/ellet0/kraft-sync/main/gradle/libs.versions.toml"

const val LATEST_SYNC_SCRIPT_JAR_FILE_URL = "https://github.com/ellet0/kraft-sync/releases/download/latest/kraft-sync.min.jar"
}
13 changes: 13 additions & 0 deletions common/src/main/kotlin/utils/CommandLine.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package utils

import utils.os.OperatingSystem
import java.io.File
import java.util.concurrent.TimeUnit

/**
Expand Down Expand Up @@ -74,3 +75,15 @@ fun powerShellCommandLine(
isLoggingEnabled = isLoggingEnabled,
)
}

/**
* Launch a bat script file on **Microsoft Windows** in a new window which will **prevent blocking the code execution**
* the code execution of the bat script will continue to work even if the application has been closed
* @throws IllegalStateException If the current operating system is not [OperatingSystem.Windows]
* */
fun executeBatchScriptInSeparateWindow(batScriptFile: File) {
if (!OperatingSystem.current.isWindows()) {
throw IllegalStateException("Bat script can be only executed on Windows.")
}
ProcessBuilder("cmd", "/c", "start", batScriptFile.absolutePath).start()
}
147 changes: 147 additions & 0 deletions sync-script/src/main/kotlin/JarAutoUpdater.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import constants.ProjectInfoConstants
import constants.SyncScriptInstanceFiles
import generated.BuildConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Request
import utils.FileDownloader
import utils.HttpService
import utils.SystemInfoProvider
import utils.executeAsync
import utils.executeBatchScriptInSeparateWindow
import utils.getBodyOrThrow
import utils.getRunningJarFileAsUrl
import utils.os.OperatingSystem
import utils.terminateWithOrWithoutError
import java.io.File

object JarAutoUpdater {
private suspend fun downloadLatestJarFile(): Result<File> =
try {
val newJarFile =
SyncScriptInstanceFiles.SyncScriptData.Temp.file
.resolve("${ProjectInfoConstants.NORMALIZED_NAME}-new.jar")
if (newJarFile.exists()) {
newJarFile.delete()
}
FileDownloader(
downloadUrl = ProjectInfoConstants.LATEST_SYNC_SCRIPT_JAR_FILE_URL,
targetFile = newJarFile,
progressListener = { _, _, _ -> },
).downloadFile()
Result.success(newJarFile)
} catch (e: Exception) {
e.printStackTrace()
Result.failure(e)
}

private suspend fun getLatestProjectVersion(): Result<String?> =
try {
val url = ProjectInfoConstants.LIBS_VERSIONS_TOML_FILE_URL
println("\uD83D\uDCE5 Sending GET request to: $url")
val request =
Request
.Builder()
.url(url)
.get()
.build()
val response = HttpService.client.newCall(request).executeAsync()
val responseBody: String = response.getBodyOrThrow().string()

val projectVersionRegex = Regex("""project\s*=\s*"(.+?)"""")

val projectVersion =
projectVersionRegex
.find(responseBody)
?.groups
?.get(1)
?.value
Result.success(projectVersion)
} catch (e: Exception) {
e.printStackTrace()
Result.failure(e)
}

suspend fun updateIfAvailable() {
val currentRunningJarFile =
File(
getRunningJarFileAsUrl()
.getOrElse {
println("⚠\uFE0F Auto update feature is only supported when running using JAR.")
return
}.file,
)
val latestProjectVersion =
getLatestProjectVersion().getOrElse {
println("❌ We couldn't get the latest project version: ${it.message}")
return
}
if (latestProjectVersion == null) {
println(
"⚠\uFE0F It seems that the project version is missing, it could have been moved somewhere else. " +
"Consider updating manually.",
)
return
}
if (latestProjectVersion == BuildConfig.PROJECT_VERSION) {
println("✨ You're using the latest version of the project.")
return
}
val newJarFile =
downloadLatestJarFile().getOrElse {
println("❌ An error occurred while downloading the latest version: ${it.message}")
return
}
println("ℹ\uFE0F The new update has been downloaded, will close the application.")
updateApplication(
currentRunningJarFile = currentRunningJarFile,
newJarFile = newJarFile,
)
}

private suspend fun updateApplication(
currentRunningJarFile: File,
newJarFile: File,
) {
when (OperatingSystem.current) {
OperatingSystem.Linux, OperatingSystem.MacOS -> {
Runtime.getRuntime().addShutdownHook(
Thread {
currentRunningJarFile.delete()
newJarFile.renameTo(currentRunningJarFile)
},
)
}

OperatingSystem.Windows -> {
// On Windows, we can't rename, delete or modify the current running JAR file due to file locking
val updateBatScriptFile =
SyncScriptInstanceFiles.SyncScriptData.Temp.file
.resolve("update.bat")
withContext(Dispatchers.IO) {
updateBatScriptFile.parentFile.mkdirs()
updateBatScriptFile.createNewFile()
}
updateBatScriptFile.writeText(
"""
@echo off
echo Waiting for 2 seconds to ensure application closure...
timeout /t 2 > nul
del "${currentRunningJarFile.absolutePath}"
move "${newJarFile.absolutePath}" "${currentRunningJarFile.absolutePath}"
exit
""".trimIndent(),
)
executeBatchScriptInSeparateWindow(
batScriptFile = updateBatScriptFile,
)
}

OperatingSystem.Unknown -> {
println("⚠\uFE0F Auto update feature is not supported on ${SystemInfoProvider.getOperatingSystemName()}.")
}
}
// Will require the user to launch once again after the update.
terminateWithOrWithoutError()
}
}
6 changes: 6 additions & 0 deletions sync-script/src/main/kotlin/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import config.data.ScriptConfigDataSource
import config.models.ScriptConfig
import constants.Constants
import constants.SyncScriptInstanceFiles
import generated.BuildConfig
import gui.GuiState
import gui.dialogs.CreateScriptConfigDialog
import gui.dialogs.QuickPreferencesDialog
Expand Down Expand Up @@ -40,6 +41,7 @@ suspend fun main(args: Array<String>) {

passedArgs = args

println("📋 Current project version: ${BuildConfig.PROJECT_VERSION}")
println("\uD83D\uDCC1 Current working directory: ${SystemInfoProvider.getCurrentWorkingDirectoryPath()}")

when (OperatingSystem.current) {
Expand Down Expand Up @@ -152,6 +154,10 @@ suspend fun main(args: Array<String>) {
)
}

if (scriptConfig.autoUpdateEnabled) {
JarAutoUpdater.updateIfAvailable()
}

// TODO: Plan if we should implement this in non GUI mode
if (GuiState.isGuiEnabled &&
!SyncScriptInstanceFiles.SyncScriptData.IsPreferencesConfigured.file
Expand Down
17 changes: 16 additions & 1 deletion sync-script/src/main/kotlin/config/models/ScriptConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,22 @@ data class ScriptConfig(
* then consider passing false to [isGuiEnabledOverride]
* */
val environment: Environment = Environment.Client,
// TODO: add `autoUpdate` property, plan how it work and add some kind of caching or how often it will update
/**
* Currently, the auto-update feature is **highly experimental**, and might be removed, or changed at any time.
* And for now, this feature has the following known issues:
* 1. It will always update even if the next version has **breaking changes**
* that can't be automatically migrated.
* 2. It will always update even if the next version is not a stable release;
* we haven't implemented an update channel for now (e.g., stable, beta, alpha, development, etc...).
* 3. Once the update is finished, the application will close with
* exit code 1 which will indicate an error by the launcher.
* The user will have to launch once again to run the updated JAR.
* 4. Currently, it lacks the ability to check for updates, such as on a weekly basis.
* 5. Lacks the option to ask if the user wants to update or skip.
* 6. At the moment we have minimized JAR and the fat JAR,
* the update process will always update to the minimized JAR.
* */
val autoUpdateEnabled: Boolean = false,
) {
companion object {
var instance: ScriptConfig? = null
Expand Down
2 changes: 1 addition & 1 deletion sync-script/src/main/kotlin/utils/JarUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import java.net.URL
* @throws IllegalStateException if the application is not being run from a JAR file
* */
fun getRunningJarFileAsUrl(): Result<URL> {
val codeSource = object {}.javaClass.enclosingClass.protectionDomain?.codeSource
val codeSource = object {}.javaClass.protectionDomain?.codeSource
codeSource?.location?.let {
if (!it.file.endsWith(".jar", ignoreCase = true)) {
return Result.failure(
Expand Down