Skip to content

Commit c858085

Browse files
committed
Add auto-updater
- Use tags rather than build numbers
1 parent 2aec51d commit c858085

File tree

14 files changed

+558
-18
lines changed

14 files changed

+558
-18
lines changed

.github/workflows/client.yaml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ jobs:
2626
working-directory: client/build/compose/binaries/main-release/msi/
2727
- name: Release
2828
uses: softprops/action-gh-release@v2
29-
if: ${{ github.ref == 'refs/heads/main' }}
29+
if: startsWith(github.ref, 'refs/tags/')
3030
with:
31-
tag_name: ${{ github.run_number }}
3231
files: client/build/compose/binaries/main-release/msi/*.msi

client/build.gradle.kts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ plugins {
55
alias(libs.plugins.kotlin.compose)
66
alias(libs.plugins.kotlin.serialization)
77
alias(libs.plugins.compose)
8+
alias(libs.plugins.buildconfig)
89
}
910

10-
version = "1.3.0"
11+
version = "1.4.0"
1112

1213
repositories {
1314
mavenCentral()
@@ -61,6 +62,7 @@ compose {
6162
packageOfResClass = "dev.schlaubi.mastermind.resources"
6263
customDirectory("main", provider { layout.projectDirectory.dir("src/main/composeResources") })
6364
}
65+
6466
desktop {
6567
application {
6668
mainClass = "dev.schlaubi.mastermind.LauncherKt"
@@ -98,3 +100,9 @@ compose {
98100
}
99101
}
100102
}
103+
104+
buildConfig {
105+
packageName("dev.schlaubi.mastermind")
106+
107+
buildConfigField("APP_VERSION", provider { "${project.version}" })
108+
}

client/src/main/kotlin/Launcher.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import androidx.compose.ui.Alignment
1212
import androidx.compose.ui.Modifier
1313
import androidx.compose.ui.window.Window
1414
import androidx.compose.ui.window.application
15+
import dev.schlaubi.mastermind.core.Updater
1516
import dev.schlaubi.mastermind.core.registerKeyBoardListener
1617
import dev.schlaubi.mastermind.resources.Res
1718
import dev.schlaubi.mastermind.resources.icon
@@ -21,6 +22,7 @@ import dev.schlaubi.mastermind.windows_helper.WindowsAPI
2122
import org.jetbrains.compose.resources.painterResource
2223

2324
fun main() = application {
25+
Updater()
2426
Window(title = "GTA Killer", icon = painterResource(Res.drawable.icon), onCloseRequest = ::exitApplication) {
2527
var loading by remember { mutableStateOf(true) }
2628
if (loading) {
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
package dev.schlaubi.mastermind.core
2+
3+
import androidx.compose.foundation.layout.Arrangement
4+
import androidx.compose.foundation.layout.Column
5+
import androidx.compose.foundation.layout.fillMaxSize
6+
import androidx.compose.foundation.layout.size
7+
import androidx.compose.material.icons.Icons
8+
import androidx.compose.material.icons.filled.Download
9+
import androidx.compose.material3.*
10+
import androidx.compose.runtime.*
11+
import androidx.compose.ui.Alignment
12+
import androidx.compose.ui.Modifier
13+
import androidx.compose.ui.unit.dp
14+
import androidx.compose.ui.window.ApplicationScope
15+
import androidx.compose.ui.window.Window
16+
import dev.schlaubi.mastermind.BuildConfig
17+
import dev.schlaubi.mastermind.theme.AppTheme
18+
import dev.schlaubi.mastermind.util.Loom
19+
import dev.schlaubi.mastermind.windows_helper.WindowsAPI
20+
import io.ktor.client.*
21+
import io.ktor.client.call.*
22+
import io.ktor.client.plugins.contentnegotiation.*
23+
import io.ktor.client.request.*
24+
import io.ktor.client.statement.*
25+
import io.ktor.http.*
26+
import io.ktor.serialization.kotlinx.json.*
27+
import io.ktor.utils.io.*
28+
import io.ktor.utils.io.core.remaining
29+
import kotlinx.coroutines.Dispatchers
30+
import kotlinx.coroutines.cancel
31+
import kotlinx.coroutines.launch
32+
import kotlinx.io.readByteArray
33+
import kotlinx.serialization.SerialName
34+
import kotlinx.serialization.Serializable
35+
import kotlinx.serialization.json.Json
36+
import java.nio.ByteBuffer
37+
import java.nio.channels.FileChannel
38+
import java.nio.file.StandardOpenOption
39+
import kotlin.io.path.absolutePathString
40+
import kotlin.io.path.createTempFile
41+
import kotlin.io.use
42+
43+
private val client = HttpClient {
44+
install(ContentNegotiation) {
45+
val json = Json {
46+
ignoreUnknownKeys = true
47+
}
48+
49+
json(json)
50+
}
51+
}
52+
53+
@Serializable
54+
data class Release(
55+
val name: String,
56+
val assets: List<Asset>
57+
) {
58+
@Serializable
59+
data class Asset(@SerialName("browser_download_url") val url: String, val name: String)
60+
}
61+
62+
data class Version(val major: Int, val minor: Int, val patch: Int) : Comparable<Version> {
63+
64+
override fun compareTo(other: Version): Int =
65+
compareValuesBy(this, other, Version::major, Version::minor, Version::patch)
66+
67+
companion object {
68+
fun parse(version: String): Version {
69+
val split = version.split(".")
70+
if (split.size != 3) return Version(-1, -1, -1)
71+
return Version(split[0].toInt(), split[1].toInt(), split[2].toInt())
72+
}
73+
}
74+
}
75+
76+
private suspend fun retrieveLatestVersion() = client
77+
.get("https://api.github.com/repos/MGP6775/killscript/releases/latest").body<Release>()
78+
79+
private sealed interface State {
80+
interface HasRelease {
81+
val release: Release
82+
}
83+
84+
object Checking : State
85+
object Closed : State
86+
data class UpdateAvailable(override val release: Release) : State, HasRelease
87+
data class Downloading(
88+
override val release: Release,
89+
val downloaded: Long = 0L,
90+
val size: Long? = null,
91+
val done: Boolean = false
92+
) : State, HasRelease
93+
}
94+
95+
@Composable
96+
fun ApplicationScope.Updater() {
97+
var state by remember { mutableStateOf<State>(State.Checking) }
98+
if (state is State.Closed) return
99+
100+
LaunchedEffect(Unit) {
101+
val latest = retrieveLatestVersion()
102+
if (Version.parse(latest.name) > Version.parse(BuildConfig.APP_VERSION)) {
103+
state = State.UpdateAvailable(latest)
104+
} else {
105+
client.close()
106+
}
107+
}
108+
109+
110+
val currentState = state
111+
if (currentState is State.HasRelease) {
112+
Window({ state = State.Closed }, title = "GTAKiller - Update") {
113+
AppTheme {
114+
Scaffold {
115+
Column(
116+
verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically),
117+
horizontalAlignment = Alignment.CenterHorizontally,
118+
modifier = Modifier.fillMaxSize()
119+
) {
120+
Text("A new Version is available: ${currentState.release.name}")
121+
122+
if (currentState is State.UpdateAvailable) {
123+
Button({ state = State.Downloading(currentState.release) }) {
124+
Icon(
125+
Icons.Default.Download,
126+
"Download",
127+
modifier = Modifier.size(ButtonDefaults.IconSize)
128+
)
129+
Text("Download now")
130+
}
131+
} else if (currentState is State.Downloading) {
132+
val scope = rememberCoroutineScope()
133+
DisposableEffect(currentState.release) {
134+
val asset = currentState.release.assets.first { it.name == "client.msi" }
135+
136+
scope.launch(Dispatchers.Loom) {
137+
val file = createTempFile("gtakiller_update", ".msi")
138+
val fileChannel =
139+
FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE)
140+
141+
client.prepareGet(asset.url) {
142+
contentType(ContentType.Application.OctetStream)
143+
}.execute { response ->
144+
val input = response.bodyAsChannel()
145+
val size = response.contentLength()
146+
state = currentState.copy(size = size)
147+
148+
fileChannel.use { output ->
149+
var downloaded = 0L
150+
while (!input.isClosedForRead) {
151+
val packet = input.readRemaining(DEFAULT_BUFFER_SIZE.toLong())
152+
while (!packet.exhausted()) {
153+
downloaded += packet.remaining
154+
state = currentState.copy(downloaded = downloaded, size = size)
155+
val bytes = packet.readByteArray()
156+
157+
output.write(ByteBuffer.wrap(bytes))
158+
}
159+
}
160+
}
161+
162+
state = currentState.copy(done = true)
163+
val exitCode =
164+
WindowsAPI.spawnElevatedProcess(
165+
"msiexec",
166+
"/i",
167+
file.absolutePathString(),
168+
"/passive",
169+
"/quiet"
170+
)
171+
if (exitCode != 0) {
172+
error("msiexec returned with exit code $exitCode")
173+
}
174+
175+
val myBinary = ProcessHandle.current().info().command().get()
176+
WindowsAPI.spawnDetachedProcess(myBinary)
177+
178+
exitApplication()
179+
}
180+
}
181+
182+
onDispose {
183+
scope.cancel()
184+
}
185+
}
186+
187+
if (currentState.size == null || currentState.done) {
188+
LinearProgressIndicator()
189+
} else {
190+
LinearProgressIndicator({ currentState.downloaded / currentState.size.toFloat() })
191+
}
192+
}
193+
}
194+
}
195+
}
196+
}
197+
}
198+
}

gradle/libs.versions.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,5 @@ jwt = { group = "com.auth0", name = "java-jwt", version = "4.5.0" }
4141
[plugins]
4242
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
4343
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
44-
compose = { id = "org.jetbrains.compose", version.ref = "compose" }
44+
compose = { id = "org.jetbrains.compose", version.ref = "compose" }
45+
buildconfig = { id = "com.github.gmazzo.buildconfig", version = "5.5.1" }

gradle/wrapper/gradle-wrapper.jar

122 Bytes
Binary file not shown.

gradle/wrapper/gradle-wrapper.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
distributionBase=GRADLE_USER_HOME
22
distributionPath=wrapper/dists
3-
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip
3+
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
44
networkTimeout=10000
55
validateDistributionUrl=true
66
zipStoreBase=GRADLE_USER_HOME

gradlew

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ fi
205205
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
206206

207207
# Collect all arguments for the java command:
208-
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
208+
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
209209
# and any embedded shellness will be escaped.
210210
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
211211
# treated as '${Hostname}' itself on the command line.

0 commit comments

Comments
 (0)