implement new release api features (#86)

* implement new release api features

* make hash nullable
This commit is contained in:
Chloe authored and GitHub committed 2026-08-29 17:06:45 -07:00
1 parent ef44d48ed0
commit 3b4951def1
5 files changed
+162 -90

No files matched your search

@@ -716,6 +716,9 @@ fun GeodeUpdateIndicator(snackbarHostState: SnackbarHostState, onRetry: () -> Un
ReleaseManager.UpdateException.Reason.EXTERNAL_FILE_IN_USE -> stringResource( ReleaseManager.UpdateException.Reason.EXTERNAL_FILE_IN_USE -> stringResource(
R.string.release_fetch_external_in_use_short R.string.release_fetch_external_in_use_short
) )
ReleaseManager.UpdateException.Reason.HASH_VALIDATION_FAILED -> stringResource(
R.string.release_fetch_hash_validation_failed_short
)
else -> stringResource(R.string.release_fetch_generic_short) else -> stringResource(R.string.release_fetch_generic_short)
} }
} }
@@ -3,10 +3,9 @@ package com.geode.launcher.updater
import com.geode.launcher.utils.LaunchUtils import com.geode.launcher.utils.LaunchUtils
import kotlin.time.Instant import kotlin.time.Instant
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlin.time.ExperimentalTime
@Serializable @Serializable
data class Asset @OptIn(ExperimentalTime::class) constructor( data class Asset(
val url: String, val url: String,
val id: Int, val id: Int,
val name: String, val name: String,
@@ -14,10 +13,11 @@ data class Asset @OptIn(ExperimentalTime::class) constructor(
val createdAt: Instant, val createdAt: Instant,
val updatedAt: Instant, val updatedAt: Instant,
val browserDownloadUrl: String, val browserDownloadUrl: String,
val digest: String?,
) )
@Serializable @Serializable
data class Release @OptIn(ExperimentalTime::class) constructor( data class Release(
val url: String, val url: String,
val id: Int, val id: Int,
val targetCommitish: String, val targetCommitish: String,
@@ -31,12 +31,29 @@ data class Release @OptIn(ExperimentalTime::class) constructor(
) )
@Serializable @Serializable
data class LoaderVersion @OptIn(ExperimentalTime::class) constructor( data class LoaderPlatformDownload(
val url: String,
val hash: String?,
)
@Serializable
data class LoaderDownload(
val win: LoaderPlatformDownload,
val mac: LoaderPlatformDownload,
val android32: LoaderPlatformDownload,
val android64: LoaderPlatformDownload,
val ios: LoaderPlatformDownload,
val resources: LoaderPlatformDownload,
)
@Serializable
data class LoaderVersion(
val tag: String, val tag: String,
val version: String, val version: String,
val createdAt: Instant, val createdAt: Instant,
val commitHash: String, val commitHash: String,
val prerelease: Boolean val prerelease: Boolean,
val downloads: LoaderDownload,
) )
@Serializable @Serializable
@@ -45,7 +62,25 @@ data class LoaderPayload<T>(
val error: String val error: String
) )
data class DownloadableAsset(val url: String, val filename: String, val size: Long? = null) data class DownloadableAsset(
val url: String,
val filename: String,
val size: Long? = null,
val hash: String? = null,
)
private fun mapDownload(download: Asset): DownloadableAsset {
val hash = if (download.digest?.startsWith("sha256:") == true)
download.digest.removePrefix("sha256:")
else null
return DownloadableAsset(
url = download.browserDownloadUrl,
filename = download.name,
size = download.size.toLong(),
hash = hash,
)
}
abstract class Downloadable { abstract class Downloadable {
/** /**
@@ -83,7 +118,6 @@ class DownloadableGitHubLoaderRelease(private val release: Release) : Downloadab
return release.tagName return release.tagName
} }
@OptIn(ExperimentalTime::class)
override fun getDescriptor(): Long { override fun getDescriptor(): Long {
return release.createdAt.epochSeconds return release.createdAt.epochSeconds
} }
@@ -100,11 +134,7 @@ class DownloadableGitHubLoaderRelease(private val release: Release) : Downloadab
override fun getDownload(): DownloadableAsset? { override fun getDownload(): DownloadableAsset? {
val download = getGitHubDownload() ?: return null val download = getGitHubDownload() ?: return null
return DownloadableAsset( return mapDownload(download)
url = download.browserDownloadUrl,
filename = download.name,
size = download.size.toLong()
)
} }
override fun getResourcesDownload(): DownloadableAsset? { override fun getResourcesDownload(): DownloadableAsset? {
@@ -112,11 +142,7 @@ class DownloadableGitHubLoaderRelease(private val release: Release) : Downloadab
it.name == "resources.zip" it.name == "resources.zip"
} ?: return null } ?: return null
return DownloadableAsset( return mapDownload(download)
url = download.browserDownloadUrl,
filename = download.name,
size = download.size.toLong()
)
} }
} }
@@ -125,7 +151,6 @@ class DownloadableLauncherRelease(val release: Release) : Downloadable() {
return release.tagName return release.tagName
} }
@OptIn(ExperimentalTime::class)
override fun getDescriptor(): Long { override fun getDescriptor(): Long {
return release.createdAt.epochSeconds return release.createdAt.epochSeconds
} }
@@ -149,11 +174,7 @@ class DownloadableLauncherRelease(val release: Release) : Downloadable() {
override fun getDownload(): DownloadableAsset? { override fun getDownload(): DownloadableAsset? {
val download = getGitHubDownload() ?: return null val download = getGitHubDownload() ?: return null
return DownloadableAsset( return mapDownload(download)
url = download.browserDownloadUrl,
filename = download.name,
size = download.size.toLong()
)
} }
override fun getResourcesDownload(): DownloadableAsset? { override fun getResourcesDownload(): DownloadableAsset? {
@@ -166,24 +187,27 @@ class DownloadableLoaderRelease(private val version: LoaderVersion) : Downloadab
return version.tag return version.tag
} }
@OptIn(ExperimentalTime::class)
override fun getDescriptor(): Long { override fun getDescriptor(): Long {
return version.createdAt.epochSeconds return version.createdAt.epochSeconds
} }
override fun getDownload(): DownloadableAsset { override fun getDownload(): DownloadableAsset {
val data = if (LaunchUtils.is64bit) version.downloads.android64 else version.downloads.android32
val filename = "geode-${version.tag}-${LaunchUtils.platformName}.zip" val filename = "geode-${version.tag}-${LaunchUtils.platformName}.zip"
return DownloadableAsset( return DownloadableAsset(
url = "https://github.com/geode-sdk/geode/releases/download/${version.tag}/$filename", url = data.url,
filename = filename filename = filename,
hash = data.hash,
) )
} }
override fun getResourcesDownload(): DownloadableAsset { override fun getResourcesDownload(): DownloadableAsset {
val data = version.downloads.resources
val filename = "resources.zip" val filename = "resources.zip"
return DownloadableAsset( return DownloadableAsset(
url = "https://github.com/geode-sdk/geode/releases/download/${version.tag}/resources.zip", url = data.url,
filename = filename filename = filename,
hash = data.hash,
) )
} }
} }
@@ -75,7 +75,8 @@ class ReleaseManager private constructor(
class UpdateException(reason: Reason? = null, cause: Throwable? = null) : Exception(reason?.name, cause) { class UpdateException(reason: Reason? = null, cause: Throwable? = null) : Exception(reason?.name, cause) {
enum class Reason { enum class Reason {
EXTERNAL_FILE_IN_USE EXTERNAL_FILE_IN_USE,
HASH_VALIDATION_FAILED
} }
var reason: Reason? = reason var reason: Reason? = reason
@@ -155,17 +156,17 @@ class ReleaseManager private constructor(
val outputFile = File(outputDirectory, download.filename) val outputFile = File(outputDirectory, download.filename)
try { try {
DownloadUtils.downloadStream( val hash = DownloadUtils.downloadFile(
httpClient, httpClient,
download.url, download.url,
onResponse = { body -> outputFile
body.source().use { source ->
outputFile.sink().buffer().use { sink ->
sink.writeAll(source)
}
}
}
) )
if (download.hash != null && download.hash != hash) {
Log.w("ReleaseManager", "downloadLauncherUpdate failed: found $hash but expected ${download.hash}")
outputFile.delete()
throw UpdateException(UpdateException.Reason.HASH_VALIDATION_FAILED)
}
} catch (e: Exception) { } catch (e: Exception) {
return Result.failure(e) return Result.failure(e)
} }
@@ -183,42 +184,52 @@ class ReleaseManager private constructor(
val finalDir = LaunchUtils.getGeodeResourcesDirectory(applicationContext) val finalDir = LaunchUtils.getGeodeResourcesDirectory(applicationContext)
val downloadFile = getTempFile("resources.zip")
try { try {
if (!outputDir.exists()) { if (!outputDir.exists()) {
outputDir.mkdirs() outputDir.mkdirs()
} }
DownloadUtils.downloadStream( val hash = DownloadUtils.downloadFile(
httpClient, httpClient,
resourceAsset.url, resourceAsset.url,
downloadFile,
onProgress = { progress, outOf -> onProgress = { progress, outOf ->
if (!skipStateUpdate) if (!skipStateUpdate)
_uiState.value = ReleaseManagerState.InDownload(initialSize + progress, outOf + initialSize) _uiState.value = ReleaseManagerState.InDownload(initialSize + progress, outOf + initialSize)
}, },
onResponse = { body ->
DownloadUtils.copyZipStreamToDirectory(
body.byteStream(),
outputDir
)
if (finalDir.exists()) {
finalDir.deleteRecursively()
}
if (!outputDir.renameTo(finalDir)) {
println("Failed to rename temporary directory!")
DownloadUtils.copyDirectory(outputDir, finalDir)
}
}
) )
if (resourceAsset.hash != null && resourceAsset.hash != hash) {
Log.w("ReleaseManager", "performResourceDownload failed: found $hash but expected ${resourceAsset.hash}")
throw UpdateException(UpdateException.Reason.HASH_VALIDATION_FAILED)
}
downloadFile.inputStream().use { zip ->
DownloadUtils.copyZipStreamToDirectory(
zip,
outputDir
)
}
if (finalDir.exists()) {
finalDir.deleteRecursively()
}
if (!outputDir.renameTo(finalDir)) {
println("Failed to rename temporary directory!")
DownloadUtils.copyDirectory(outputDir, finalDir)
}
} catch (e: Exception) { } catch (e: Exception) {
sendError(e) sendError(e)
return false return false
} finally { } finally {
val tempPathClone = File(outputPath) val tempPathClone = File(outputPath)
runCatching { try {
if (downloadFile.exists()) downloadFile.delete()
if (tempPathClone.exists()) tempPathClone.deleteRecursively() if (tempPathClone.exists()) tempPathClone.deleteRecursively()
} } catch (_: IOException) {}
} }
return true return true
@@ -244,12 +255,14 @@ class ReleaseManager private constructor(
val initialSize = if (releaseAsset.size == null && resourcesAsset?.size == null) null else releaseSize + resourcesSize val initialSize = if (releaseAsset.size == null && resourcesAsset?.size == null) null else releaseSize + resourcesSize
_uiState.value = ReleaseManagerState.InDownload(0, initialSize) _uiState.value = ReleaseManagerState.InDownload(0, initialSize)
val outputFile = getTempFile() val outputFile = getTempFile(LaunchUtils.geodeFilename)
// clone the file instance as renameTo may move the original file // clone the file instance as renameTo may move the original file
val tempFilePath = outputFile.path val tempFilePath = outputFile.path
val geodeFile = getGeodeOutputPath() val geodeFile = getGeodeOutputPath()
val downloadFile = getTempFile("geode.zip")
try { try {
// tempFile should be in same path as geodeFile // tempFile should be in same path as geodeFile
val geodeParent = geodeFile.parentFile val geodeParent = geodeFile.parentFile
@@ -257,45 +270,53 @@ class ReleaseManager private constructor(
geodeParent.mkdirs() geodeParent.mkdirs()
} }
DownloadUtils.downloadStream( val hash = DownloadUtils.downloadFile(
httpClient, httpClient,
releaseAsset.url, releaseAsset.url,
downloadFile,
onProgress = { progress, outOf -> onProgress = { progress, outOf ->
if (!skipStateUpdate) if (!skipStateUpdate)
_uiState.value = ReleaseManagerState.InDownload(progress, outOf + resourcesSize) _uiState.value = ReleaseManagerState.InDownload(progress, outOf + resourcesSize)
releaseSize = max(outOf, releaseSize) releaseSize = max(outOf, releaseSize)
},
onResponse = { body ->
DownloadUtils.extractFileFromZipStream(
body.byteStream(),
outputFile,
geodeFile.name
)
// work around a permission issue from adb push
if (geodeFile.exists()) {
geodeFile.delete()
}
val renameSuccessful = runCatching {
outputFile.renameTo(geodeFile)
}.getOrDefault(false)
if (!renameSuccessful) {
// attempt a manual copy if rename fails for whatever reason
DownloadUtils.copyFile(outputFile, geodeFile)
}
} }
) )
if (releaseAsset.hash != null && releaseAsset.hash != hash) {
Log.w("ReleaseManager", "performUpdate failed: found $hash but expected ${releaseAsset.hash}")
throw UpdateException(UpdateException.Reason.HASH_VALIDATION_FAILED)
}
downloadFile.inputStream().use { zip ->
DownloadUtils.extractFileFromZipStream(
zip,
outputFile,
geodeFile.name
)
}
// work around a permission issue from adb push
if (geodeFile.exists()) {
geodeFile.delete()
}
val renameSuccessful = runCatching {
outputFile.renameTo(geodeFile)
}.getOrDefault(false)
if (!renameSuccessful) {
// attempt a manual copy if rename fails for whatever reason
DownloadUtils.copyFile(outputFile, geodeFile)
}
} catch (e: Exception) { } catch (e: Exception) {
sendError(e) sendError(e)
return return
} finally { } finally {
val tempFileClone = File(tempFilePath) val tempFileClone = File(tempFilePath)
runCatching { try {
if (downloadFile.exists()) downloadFile.delete()
if (tempFileClone.exists()) tempFileClone.delete() if (tempFileClone.exists()) tempFileClone.delete()
} } catch (_: IOException) {}
} }
if (resourcesAsset != null) { if (resourcesAsset != null) {
@@ -496,15 +517,14 @@ class ReleaseManager private constructor(
} }
} }
private fun getTempFile(): File { private fun getTempFile(filename: String): File {
val geodeName = LaunchUtils.geodeFilename
val geodeDirectory = LaunchUtils.getBaseDirectory(applicationContext) val geodeDirectory = LaunchUtils.getBaseDirectory(applicationContext)
// warning!! while File::createTempFile may look tempting, a certain brand of phones has a messed up implementation of it // warning!! while File::createTempFile may look tempting, a certain brand of phones has a messed up implementation of it
// so we're making a temp file manually (as long as it doesn't collide with the geode download, it's okay) // so we're making a temp file manually (as long as it doesn't collide with the geode download, it's okay)
val suffix = createRandomString() val suffix = createRandomString()
val tmpName = "tmp-$suffix.$geodeName" val tmpName = "tmp-$suffix.$filename"
val tempFile = File(geodeDirectory, tmpName) val tempFile = File(geodeDirectory, tmpName)
@@ -17,10 +17,12 @@ import okio.BufferedSource
import okio.FileSystem import okio.FileSystem
import okio.ForwardingSink import okio.ForwardingSink
import okio.ForwardingSource import okio.ForwardingSource
import okio.HashingSink
import okio.Path.Companion.toOkioPath import okio.Path.Companion.toOkioPath
import okio.Sink import okio.Sink
import okio.Source import okio.Source
import okio.buffer import okio.buffer
import okio.sink
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
@@ -32,12 +34,31 @@ import java.util.zip.ZipInputStream
typealias ProgressCallback = (progress: Long, outOf: Long) -> Unit typealias ProgressCallback = (progress: Long, outOf: Long) -> Unit
object DownloadUtils { object DownloadUtils {
suspend fun downloadFile(
httpClient: OkHttpClient,
url: String,
outputFile: File,
onProgress: ProgressCallback? = null,
): String = downloadStream(
httpClient,
url,
onProgress = onProgress
).use { body ->
val hashingSink = HashingSink.sha256(outputFile.sink().buffer())
hashingSink.use { sink ->
body.source().use { source ->
source.readAll(sink)
}
}
hashingSink.hash.hex()
}
suspend fun downloadStream( suspend fun downloadStream(
httpClient: OkHttpClient, httpClient: OkHttpClient,
url: String, url: String,
onProgress: ProgressCallback? = null, onProgress: ProgressCallback? = null,
onResponse: suspend (ResponseBody) -> Unit, ): ResponseBody {
) {
val request = Request.Builder() val request = Request.Builder()
.url(url) .url(url)
.build() .build()
@@ -64,12 +85,15 @@ object DownloadUtils {
val progressClient = progressClientBuilder.build() val progressClient = progressClientBuilder.build()
val call = progressClient.newCall(request) val call = progressClient.newCall(request)
call.executeAsync().use { response -> val response = call.executeAsync()
when (response.code) {
200 -> onResponse(response.body) val responseCode = response.code
else -> throw IOException("unexpected response ${response.code}") if (responseCode != 200) {
} response.close()
throw IOException("unexpected response ${response.code}")
} }
return response.body
} }
suspend fun copyZipStreamToDirectory(inputStream: InputStream, output: File) = runInterruptible { suspend fun copyZipStreamToDirectory(inputStream: InputStream, output: File) = runInterruptible {
+1
View File
@@ -105,6 +105,7 @@
<string name="release_fetch_manual_check_required">Externally managed library in use, please manually check for updates if you want to overwrite it.</string> <string name="release_fetch_manual_check_required">Externally managed library in use, please manually check for updates if you want to overwrite it.</string>
<string name="release_fetch_external_in_use_short">Update failed. External library in use.</string> <string name="release_fetch_external_in_use_short">Update failed. External library in use.</string>
<string name="release_fetch_hash_validation_failed_short">Update failed. Integrity check failed.</string>
<string name="release_fetch_no_internet_short">Update failed. Internet not connected.</string> <string name="release_fetch_no_internet_short">Update failed. Internet not connected.</string>
<string name="release_fetch_generic_short">Update failed. Please try again later.</string> <string name="release_fetch_generic_short">Update failed. Please try again later.</string>