From 2bab7d9c21fa3ed5e4d531e5476a251f11caf4ce Mon Sep 17 00:00:00 2001 From: Chloe Date: Sat, 15 Aug 2026 13:40:17 -0700 Subject: [PATCH 1/2] implement new release api features --- .../java/com/geode/launcher/MainActivity.kt | 3 + .../com/geode/launcher/updater/Release.kt | 80 +++++++---- .../geode/launcher/updater/ReleaseManager.kt | 130 ++++++++++-------- .../com/geode/launcher/utils/DownloadUtils.kt | 38 ++++- app/src/main/res/values/strings.xml | 1 + 5 files changed, 162 insertions(+), 90 deletions(-) diff --git a/app/src/main/java/com/geode/launcher/MainActivity.kt b/app/src/main/java/com/geode/launcher/MainActivity.kt index 536e4c44..1df504ec 100644 --- a/app/src/main/java/com/geode/launcher/MainActivity.kt +++ b/app/src/main/java/com/geode/launcher/MainActivity.kt @@ -716,6 +716,9 @@ fun GeodeUpdateIndicator(snackbarHostState: SnackbarHostState, onRetry: () -> Un ReleaseManager.UpdateException.Reason.EXTERNAL_FILE_IN_USE -> stringResource( 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) } } diff --git a/app/src/main/java/com/geode/launcher/updater/Release.kt b/app/src/main/java/com/geode/launcher/updater/Release.kt index dba1e878..5341ca2a 100644 --- a/app/src/main/java/com/geode/launcher/updater/Release.kt +++ b/app/src/main/java/com/geode/launcher/updater/Release.kt @@ -3,10 +3,9 @@ package com.geode.launcher.updater import com.geode.launcher.utils.LaunchUtils import kotlin.time.Instant import kotlinx.serialization.Serializable -import kotlin.time.ExperimentalTime @Serializable -data class Asset @OptIn(ExperimentalTime::class) constructor( +data class Asset( val url: String, val id: Int, val name: String, @@ -14,10 +13,11 @@ data class Asset @OptIn(ExperimentalTime::class) constructor( val createdAt: Instant, val updatedAt: Instant, val browserDownloadUrl: String, + val digest: String?, ) @Serializable -data class Release @OptIn(ExperimentalTime::class) constructor( +data class Release( val url: String, val id: Int, val targetCommitish: String, @@ -31,12 +31,29 @@ data class Release @OptIn(ExperimentalTime::class) constructor( ) @Serializable -data class LoaderVersion @OptIn(ExperimentalTime::class) constructor( +data class LoaderPlatformDownload( + val url: String, + val hash: String, // blank if hash is not present +) + +@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 version: String, val createdAt: Instant, val commitHash: String, - val prerelease: Boolean + val prerelease: Boolean, + val downloads: LoaderDownload, ) @Serializable @@ -45,7 +62,25 @@ data class LoaderPayload( 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 { /** @@ -83,7 +118,6 @@ class DownloadableGitHubLoaderRelease(private val release: Release) : Downloadab return release.tagName } - @OptIn(ExperimentalTime::class) override fun getDescriptor(): Long { return release.createdAt.epochSeconds } @@ -100,11 +134,7 @@ class DownloadableGitHubLoaderRelease(private val release: Release) : Downloadab override fun getDownload(): DownloadableAsset? { val download = getGitHubDownload() ?: return null - return DownloadableAsset( - url = download.browserDownloadUrl, - filename = download.name, - size = download.size.toLong() - ) + return mapDownload(download) } override fun getResourcesDownload(): DownloadableAsset? { @@ -112,11 +142,7 @@ class DownloadableGitHubLoaderRelease(private val release: Release) : Downloadab it.name == "resources.zip" } ?: return null - return DownloadableAsset( - url = download.browserDownloadUrl, - filename = download.name, - size = download.size.toLong() - ) + return mapDownload(download) } } @@ -125,7 +151,6 @@ class DownloadableLauncherRelease(val release: Release) : Downloadable() { return release.tagName } - @OptIn(ExperimentalTime::class) override fun getDescriptor(): Long { return release.createdAt.epochSeconds } @@ -149,11 +174,7 @@ class DownloadableLauncherRelease(val release: Release) : Downloadable() { override fun getDownload(): DownloadableAsset? { val download = getGitHubDownload() ?: return null - return DownloadableAsset( - url = download.browserDownloadUrl, - filename = download.name, - size = download.size.toLong() - ) + return mapDownload(download) } override fun getResourcesDownload(): DownloadableAsset? { @@ -166,24 +187,27 @@ class DownloadableLoaderRelease(private val version: LoaderVersion) : Downloadab return version.tag } - @OptIn(ExperimentalTime::class) override fun getDescriptor(): Long { return version.createdAt.epochSeconds } override fun getDownload(): DownloadableAsset { + val data = if (LaunchUtils.is64bit) version.downloads.android64 else version.downloads.android32 val filename = "geode-${version.tag}-${LaunchUtils.platformName}.zip" return DownloadableAsset( - url = "https://github.com/geode-sdk/geode/releases/download/${version.tag}/$filename", - filename = filename + url = data.url, + filename = filename, + hash = data.hash.takeUnless { it.isEmpty() } ) } override fun getResourcesDownload(): DownloadableAsset { + val data = version.downloads.resources val filename = "resources.zip" return DownloadableAsset( - url = "https://github.com/geode-sdk/geode/releases/download/${version.tag}/resources.zip", - filename = filename + url = data.url, + filename = filename, + hash = data.hash.takeUnless { it.isEmpty() }, ) } } diff --git a/app/src/main/java/com/geode/launcher/updater/ReleaseManager.kt b/app/src/main/java/com/geode/launcher/updater/ReleaseManager.kt index 572959ad..db7f7c3b 100644 --- a/app/src/main/java/com/geode/launcher/updater/ReleaseManager.kt +++ b/app/src/main/java/com/geode/launcher/updater/ReleaseManager.kt @@ -75,7 +75,8 @@ class ReleaseManager private constructor( class UpdateException(reason: Reason? = null, cause: Throwable? = null) : Exception(reason?.name, cause) { enum class Reason { - EXTERNAL_FILE_IN_USE + EXTERNAL_FILE_IN_USE, + HASH_VALIDATION_FAILED } var reason: Reason? = reason @@ -155,17 +156,17 @@ class ReleaseManager private constructor( val outputFile = File(outputDirectory, download.filename) try { - DownloadUtils.downloadStream( + val hash = DownloadUtils.downloadFile( httpClient, download.url, - onResponse = { body -> - body.source().use { source -> - outputFile.sink().buffer().use { sink -> - sink.writeAll(source) - } - } - } + outputFile ) + + 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) { return Result.failure(e) } @@ -183,42 +184,52 @@ class ReleaseManager private constructor( val finalDir = LaunchUtils.getGeodeResourcesDirectory(applicationContext) + val downloadFile = getTempFile("resources.zip") + try { if (!outputDir.exists()) { outputDir.mkdirs() } - DownloadUtils.downloadStream( + val hash = DownloadUtils.downloadFile( httpClient, resourceAsset.url, + downloadFile, onProgress = { progress, outOf -> if (!skipStateUpdate) _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) { sendError(e) return false } finally { val tempPathClone = File(outputPath) - runCatching { + try { + if (downloadFile.exists()) downloadFile.delete() if (tempPathClone.exists()) tempPathClone.deleteRecursively() - } + } catch (_: IOException) {} } return true @@ -244,12 +255,14 @@ class ReleaseManager private constructor( val initialSize = if (releaseAsset.size == null && resourcesAsset?.size == null) null else releaseSize + resourcesSize _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 val tempFilePath = outputFile.path val geodeFile = getGeodeOutputPath() + val downloadFile = getTempFile("geode.zip") + try { // tempFile should be in same path as geodeFile val geodeParent = geodeFile.parentFile @@ -257,45 +270,53 @@ class ReleaseManager private constructor( geodeParent.mkdirs() } - DownloadUtils.downloadStream( + val hash = DownloadUtils.downloadFile( httpClient, releaseAsset.url, + downloadFile, onProgress = { progress, outOf -> if (!skipStateUpdate) _uiState.value = ReleaseManagerState.InDownload(progress, outOf + resourcesSize) 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) { sendError(e) return } finally { val tempFileClone = File(tempFilePath) - runCatching { + try { + if (downloadFile.exists()) downloadFile.delete() if (tempFileClone.exists()) tempFileClone.delete() - } + } catch (_: IOException) {} } if (resourcesAsset != null) { @@ -496,15 +517,14 @@ class ReleaseManager private constructor( } } - private fun getTempFile(): File { - val geodeName = LaunchUtils.geodeFilename + private fun getTempFile(filename: String): File { val geodeDirectory = LaunchUtils.getBaseDirectory(applicationContext) // 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) val suffix = createRandomString() - val tmpName = "tmp-$suffix.$geodeName" + val tmpName = "tmp-$suffix.$filename" val tempFile = File(geodeDirectory, tmpName) diff --git a/app/src/main/java/com/geode/launcher/utils/DownloadUtils.kt b/app/src/main/java/com/geode/launcher/utils/DownloadUtils.kt index 5bb459ab..d74923e6 100644 --- a/app/src/main/java/com/geode/launcher/utils/DownloadUtils.kt +++ b/app/src/main/java/com/geode/launcher/utils/DownloadUtils.kt @@ -17,10 +17,12 @@ import okio.BufferedSource import okio.FileSystem import okio.ForwardingSink import okio.ForwardingSource +import okio.HashingSink import okio.Path.Companion.toOkioPath import okio.Sink import okio.Source import okio.buffer +import okio.sink import java.io.File import java.io.IOException import java.io.InputStream @@ -32,12 +34,31 @@ import java.util.zip.ZipInputStream typealias ProgressCallback = (progress: Long, outOf: Long) -> Unit 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( httpClient: OkHttpClient, url: String, onProgress: ProgressCallback? = null, - onResponse: suspend (ResponseBody) -> Unit, - ) { + ): ResponseBody { val request = Request.Builder() .url(url) .build() @@ -64,12 +85,15 @@ object DownloadUtils { val progressClient = progressClientBuilder.build() val call = progressClient.newCall(request) - call.executeAsync().use { response -> - when (response.code) { - 200 -> onResponse(response.body) - else -> throw IOException("unexpected response ${response.code}") - } + val response = call.executeAsync() + + val responseCode = response.code + if (responseCode != 200) { + response.close() + throw IOException("unexpected response ${response.code}") } + + return response.body } suspend fun copyZipStreamToDirectory(inputStream: InputStream, output: File) = runInterruptible { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e259c205..0ac3ea6e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -105,6 +105,7 @@ Externally managed library in use, please manually check for updates if you want to overwrite it. Update failed. External library in use. + Update failed. Integrity check failed. Update failed. Internet not connected. Update failed. Please try again later. From c42d65cef70d45a9b4cd122f9e5d033090e96175 Mon Sep 17 00:00:00 2001 From: Chloe Date: Sat, 15 Aug 2026 13:50:03 -0700 Subject: [PATCH 2/2] make hash nullable --- app/src/main/java/com/geode/launcher/updater/Release.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/geode/launcher/updater/Release.kt b/app/src/main/java/com/geode/launcher/updater/Release.kt index 5341ca2a..356a3ec6 100644 --- a/app/src/main/java/com/geode/launcher/updater/Release.kt +++ b/app/src/main/java/com/geode/launcher/updater/Release.kt @@ -33,7 +33,7 @@ data class Release( @Serializable data class LoaderPlatformDownload( val url: String, - val hash: String, // blank if hash is not present + val hash: String?, ) @Serializable @@ -197,7 +197,7 @@ class DownloadableLoaderRelease(private val version: LoaderVersion) : Downloadab return DownloadableAsset( url = data.url, filename = filename, - hash = data.hash.takeUnless { it.isEmpty() } + hash = data.hash, ) } @@ -207,7 +207,7 @@ class DownloadableLoaderRelease(private val version: LoaderVersion) : Downloadab return DownloadableAsset( url = data.url, filename = filename, - hash = data.hash.takeUnless { it.isEmpty() }, + hash = data.hash, ) } }