From 5613b29f7e773d370679388457fba7fc1a994e88 Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Sun, 8 Mar 2026 02:52:57 +0530 Subject: [PATCH 1/8] link update --- gradle.properties | 2 +- .../app/morphe/cli/command/PatchCommand.kt | 281 +++++++++++++++++- 2 files changed, 279 insertions(+), 4 deletions(-) diff --git a/gradle.properties b/gradle.properties index f67bfb6..ae636db 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ org.gradle.parallel = true org.gradle.caching = true kotlin.code.style = official -version = 1.5.0-dev.10 +version = 1.6.0-dev.1 diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index 1e3ddb2..3559c34 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -34,6 +34,9 @@ import kotlinx.coroutines.runBlocking import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import kotlinx.serialization.json.encodeToStream +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import org.jetbrains.annotations.VisibleForTesting import picocli.CommandLine import picocli.CommandLine.ArgGroup @@ -225,6 +228,13 @@ internal object PatchCommand : Callable { ) private var purge: Boolean = false + @CommandLine.Option( + names = ["--prerelease"], + description = ["Get the latest dev release instead of the stable release from the repo provided."], + showDefaultValue = ALWAYS, + ) + private var prerelease: Boolean = false + @CommandLine.Parameters( description = ["APK file to patch."], arity = "1", @@ -249,9 +259,16 @@ internal object PatchCommand : Callable { ) @Suppress("unused") private fun setPatchesFile(patchesFiles: Set) { - patchesFiles.firstOrNull { !it.exists() }?.let { - throw CommandLine.ParameterException(spec.commandLine(), "${it.name} can't be found") - } + patchesFiles.firstOrNull { + !it.exists() && + !it.toString().startsWith("http:/") && + !it.toString().startsWith("https:/") + }?.let { + throw CommandLine.ParameterException( + spec.commandLine(), + "${it.name} can't be found" + ) + } this.patchesFiles = patchesFiles } @@ -372,6 +389,251 @@ internal object PatchCommand : Callable { // The heavy Patch objects hold DEX classloaders and must not leak into finally. var patchesSnapshot: PatchBundle? = null + // We try to download our patch file here if the user passed a link + if (patchesFiles.any { + it.path.startsWith("http:/") || + it.path.startsWith("https:/") + }) { + try { + val urlEntry = patchesFiles.first{ + it.path.startsWith("http:/") || it.path.startsWith("https:/") + } + + val url = urlEntry.path + + val urlParts = url.split("/") + val owner = urlParts[2] + val repo = urlParts[3] + + if (url.contains("releases/tag/")){ + // We have the release version in this branch. + val version = urlParts[6] // version part of the url + val versionNumber = version.removePrefix("v") + + val repoCacheDir = File(getCliCacheDir(), "${owner}-${repo}") + + val cachedFile = repoCacheDir.listFiles()?.find { + it.name.endsWith(".mpp") && it.name.contains(versionNumber) + } + + if (cachedFile != null){ + // If the user mentioned file with that version already exists, return that file location. + // No need to check the prerelease flag because the user has already specified the version. + logger.info("Using cached patch file at ${cachedFile.path}") + patchesFiles = patchesFiles - urlEntry + cachedFile + } + else{ + // If it doesn't exist or some other version is present, then we come here. + // Either way we download our version and replace whatever else is present. + logger.info("Downloading patches from ${owner}/${repo} ${versionNumber}....") + repoCacheDir.listFiles()?.filter { + it.name.endsWith(".mpp") + }?.forEach { it.delete() } + repoCacheDir.mkdirs() + + // Now we get the .mpp file from GitHub here + // First we hit the GitHub api + val response = java.net.URI( + "https://api.github.com/repos/${owner}/${repo}/releases/tags/${version}" + ).toURL().openStream().bufferedReader().readText() + + // Then we find where the .mpp file is from the stream above + val json = Json.parseToJsonElement(response).jsonObject + val assetArray = json["assets"]?.jsonArray + + val asset = assetArray?.find { + it.jsonObject["name"]?.jsonPrimitive?.content?.endsWith(".mpp") == true + } + + // Get the .mpp file ready here + val downloadUrl = asset?.jsonObject["browser_download_url"]?.jsonPrimitive?.content + + // Also get the file name ready here + val assetName = asset?.jsonObject["name"]?.jsonPrimitive?.content + + if (downloadUrl == null || assetName == null){ + throw CommandLine.ParameterException( + spec.commandLine(), + "No .mpp file found in release $version" + ) + } + + // We finally download and set everything here. + val targetFile = File(repoCacheDir, assetName) + java.net.URI(downloadUrl).toURL().openStream().use { input -> + targetFile.outputStream().use { output -> + input.copyTo(output) + } + } + + logger.info("Patch saved at ${targetFile.path}. Use this file for your next runs!") + patchesFiles = patchesFiles - urlEntry + targetFile + + } + }else{ + // Here in this "only repo mentioned" branch check my version with the online version: + // If a file is present, and it is the same version, just return that file. + // If a file is present, and they are not same, then replace my version with the latest stable/ prerelease version depending on the --prerelease flag. + // If no file is present, then download the latest stable/ prelease version depending on the --prerelease flag. + + if (!prerelease) { + // Get latest stable version here. + // Get latest stable version from GitHub immediately to check our local file. + val response = java.net.URI( + "https://api.github.com/repos/${owner}/${repo}/releases/latest" + ).toURL().openStream().bufferedReader().readText() + + // Then we find where the .mpp file is from the stream above + val json = Json.parseToJsonElement(response).jsonObject + val assetArray = json["assets"]?.jsonArray + + val asset = assetArray?.find { + it.jsonObject["name"]?.jsonPrimitive?.content?.endsWith(".mpp") == true + } + + val version = json["tag_name"]?.jsonPrimitive?.content + ?: throw CommandLine.ParameterException( + spec.commandLine(), + "Could not determine version from ${owner}/${repo}" + ) + + val versionNumber = version.removePrefix("v") + + val repoCacheDir = File(getCliCacheDir(), "${owner}-${repo}") + + val cachedFile = repoCacheDir.listFiles()?.find { + it.name.endsWith(".mpp") && it.name.contains(versionNumber) + } + + if (cachedFile != null){ + // If the user mentioned file with that version already exists, return that file location. + // No need to check the prerelease flag because the user has already specified the version. + logger.info("Using cached patch file at ${cachedFile.path}") + patchesFiles = patchesFiles - urlEntry + cachedFile + } + else{ + // If it doesn't exist or some other version is present, then we come here. + // Either way we download our version and replace whatever else is present. + logger.info("Downloading patches from ${owner}/${repo} ${versionNumber}....") + repoCacheDir.listFiles()?.filter { + it.name.endsWith(".mpp") + }?.forEach { it.delete() } + repoCacheDir.mkdirs() + + // Get the .mpp file ready here + val downloadUrl = asset?.jsonObject["browser_download_url"]?.jsonPrimitive?.content + + // Also get the file name ready here + val assetName = asset?.jsonObject["name"]?.jsonPrimitive?.content + + if (downloadUrl == null || assetName == null){ + throw CommandLine.ParameterException( + spec.commandLine(), + "No .mpp file found in release $version" + ) + } + + // We finally download and set everything here. + val targetFile = File(repoCacheDir, assetName) + java.net.URI(downloadUrl).toURL().openStream().use { input -> + targetFile.outputStream().use { output -> + input.copyTo(output) + } + } + + logger.info("Patch saved at ${targetFile.path}. Use this file for your next runs!") + patchesFiles = patchesFiles - urlEntry + targetFile + + } + + } + else { + // Get latest dev version here. + // Get latest dev version from GitHub immediately to check our local file. + val response = java.net.URI( + "https://api.github.com/repos/${owner}/${repo}/releases" + ).toURL().openStream().bufferedReader().readText() + + val releases = Json.parseToJsonElement(response).jsonArray + val release = releases.firstOrNull { + it.jsonObject["prerelease"]?.jsonPrimitive?.content == "true" + } + ?: throw CommandLine.ParameterException( + spec.commandLine(), + "Could not get dev release from ${owner}/${repo}" + ) + + val assetArray = release.jsonObject["assets"]?.jsonArray + + val asset = assetArray?.find { + it.jsonObject["name"]?.jsonPrimitive?.content?.endsWith(".mpp") == true + } + + val version = release.jsonObject["tag_name"]?.jsonPrimitive?.content + ?: throw CommandLine.ParameterException( + spec.commandLine(), + "Could not determine version from ${owner}/${repo}" + ) + + val versionNumber = version.removePrefix("v") + + val repoCacheDir = File(getCliCacheDir(), "${owner}-${repo}") + + val cachedFile = repoCacheDir.listFiles()?.find { + it.name.endsWith(".mpp") && it.name.contains(versionNumber) + } + + if (cachedFile != null){ + // If the user mentioned file with that version already exists, return that file location. + // No need to check the prerelease flag because the user has already specified the version. + logger.info("Using cached patch file at ${cachedFile.path}") + patchesFiles = patchesFiles - urlEntry + cachedFile + } + else{ + // If it doesn't exist or some other version is present, then we come here. + // Either way we download our version and replace whatever else is present. + logger.info("Downloading patches from ${owner}/${repo} ${version}....") + repoCacheDir.listFiles()?.filter { + it.name.endsWith(".mpp") + }?.forEach { it.delete() } + repoCacheDir.mkdirs() + + // Get the .mpp file ready here + val downloadUrl = asset?.jsonObject["browser_download_url"]?.jsonPrimitive?.content + + // Also get the file name ready here + val assetName = asset?.jsonObject["name"]?.jsonPrimitive?.content + + if (downloadUrl == null || assetName == null){ + throw CommandLine.ParameterException( + spec.commandLine(), + "No .mpp file found in release $version" + ) + } + + // We finally download and set everything here. + val targetFile = File(repoCacheDir, assetName) + java.net.URI(downloadUrl).toURL().openStream().use { input -> + targetFile.outputStream().use { output -> + input.copyTo(output) + } + } + + logger.info("Patch saved at ${targetFile.path}. Use this file for your next runs!") + patchesFiles = patchesFiles - urlEntry + targetFile + + } + } + } + } catch (e: Exception) { + throw CommandLine.ParameterException( + spec.commandLine(), + "Failed to download patches from URL: ${e.message}" + ) + } + } + + try { logger.info("Loading patches") val patches: MutableSet> = loadPatchesFromJar(patchesFiles).toMutableSet() @@ -822,3 +1084,16 @@ internal object PatchCommand : Callable { } private class PatchFailedException(message: String, cause: Throwable) : Exception(message, cause) + +private fun getCliCacheDir(): File { + val userHome = System.getProperty("user.home") + val osName = System.getProperty("os.name").lowercase() + val base = when { + osName.contains("win") -> File(System.getenv("APPDATA") ?: + "$userHome/AppData/Roaming", "morphe-cli") + osName.contains("mac") -> + File("$userHome/Library/Application Support", "morphe-cli") + else -> File("$userHome/.config", "morphe-cli") + } + return File(base, "patches").also { it.mkdirs() } +} \ No newline at end of file From 3d9b9bf84f834f1cf6e3247dbe5ae2e1d2acefda Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Sun, 8 Mar 2026 16:24:41 +0530 Subject: [PATCH 2/8] Minor Fix The patch file now downloads to the temp folder. Also moved the repeated block to a helper function for better coding practice. (lmaooo I can't be the one talking good coding practices) --- .../app/morphe/cli/command/PatchCommand.kt | 332 ++++++------------ 1 file changed, 116 insertions(+), 216 deletions(-) diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index 3559c34..fcb7034 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import kotlinx.serialization.json.encodeToStream +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -48,6 +49,7 @@ import java.io.PrintWriter import java.io.StringWriter import java.util.concurrent.Callable import java.util.logging.Logger +import kotlin.collections.plus @OptIn(ExperimentalSerializationApi::class) @VisibleForTesting @@ -405,226 +407,79 @@ internal object PatchCommand : Callable { val owner = urlParts[2] val repo = urlParts[3] + // Resolve the version and asset from the GitHub API, then use the helper to cache/download. + val version: String + val asset: JsonElement? + if (url.contains("releases/tag/")){ // We have the release version in this branch. - val version = urlParts[6] // version part of the url - val versionNumber = version.removePrefix("v") + version = urlParts[6] // version part of the url - val repoCacheDir = File(getCliCacheDir(), "${owner}-${repo}") + // First we hit the GitHub api for this specific release + val response = java.net.URI( + "https://api.github.com/repos/${owner}/${repo}/releases/tags/${version}" + ).toURL().openStream().bufferedReader().readText() - val cachedFile = repoCacheDir.listFiles()?.find { - it.name.endsWith(".mpp") && it.name.contains(versionNumber) - } + // Then we find where the .mpp file is from the stream above + val json = Json.parseToJsonElement(response).jsonObject + val assetArray = json["assets"]?.jsonArray - if (cachedFile != null){ - // If the user mentioned file with that version already exists, return that file location. - // No need to check the prerelease flag because the user has already specified the version. - logger.info("Using cached patch file at ${cachedFile.path}") - patchesFiles = patchesFiles - urlEntry + cachedFile + asset = assetArray?.find { + it.jsonObject["name"]?.jsonPrimitive?.content?.endsWith(".mpp") == true } - else{ - // If it doesn't exist or some other version is present, then we come here. - // Either way we download our version and replace whatever else is present. - logger.info("Downloading patches from ${owner}/${repo} ${versionNumber}....") - repoCacheDir.listFiles()?.filter { - it.name.endsWith(".mpp") - }?.forEach { it.delete() } - repoCacheDir.mkdirs() - - // Now we get the .mpp file from GitHub here - // First we hit the GitHub api - val response = java.net.URI( - "https://api.github.com/repos/${owner}/${repo}/releases/tags/${version}" - ).toURL().openStream().bufferedReader().readText() - - // Then we find where the .mpp file is from the stream above - val json = Json.parseToJsonElement(response).jsonObject - val assetArray = json["assets"]?.jsonArray - - val asset = assetArray?.find { - it.jsonObject["name"]?.jsonPrimitive?.content?.endsWith(".mpp") == true - } - - // Get the .mpp file ready here - val downloadUrl = asset?.jsonObject["browser_download_url"]?.jsonPrimitive?.content - - // Also get the file name ready here - val assetName = asset?.jsonObject["name"]?.jsonPrimitive?.content - if (downloadUrl == null || assetName == null){ - throw CommandLine.ParameterException( - spec.commandLine(), - "No .mpp file found in release $version" - ) - } - - // We finally download and set everything here. - val targetFile = File(repoCacheDir, assetName) - java.net.URI(downloadUrl).toURL().openStream().use { input -> - targetFile.outputStream().use { output -> - input.copyTo(output) - } - } + } else if (!prerelease) { + // Here in this "only repo mentioned" branch, get the latest stable version. + val response = java.net.URI( + "https://api.github.com/repos/${owner}/${repo}/releases/latest" + ).toURL().openStream().bufferedReader().readText() - logger.info("Patch saved at ${targetFile.path}. Use this file for your next runs!") - patchesFiles = patchesFiles - urlEntry + targetFile + // Then we find where the .mpp file is from the stream above + val json = Json.parseToJsonElement(response).jsonObject + val assetArray = json["assets"]?.jsonArray + asset = assetArray?.find { + it.jsonObject["name"]?.jsonPrimitive?.content?.endsWith(".mpp") == true } - }else{ - // Here in this "only repo mentioned" branch check my version with the online version: - // If a file is present, and it is the same version, just return that file. - // If a file is present, and they are not same, then replace my version with the latest stable/ prerelease version depending on the --prerelease flag. - // If no file is present, then download the latest stable/ prelease version depending on the --prerelease flag. - - if (!prerelease) { - // Get latest stable version here. - // Get latest stable version from GitHub immediately to check our local file. - val response = java.net.URI( - "https://api.github.com/repos/${owner}/${repo}/releases/latest" - ).toURL().openStream().bufferedReader().readText() - - // Then we find where the .mpp file is from the stream above - val json = Json.parseToJsonElement(response).jsonObject - val assetArray = json["assets"]?.jsonArray - - val asset = assetArray?.find { - it.jsonObject["name"]?.jsonPrimitive?.content?.endsWith(".mpp") == true - } - - val version = json["tag_name"]?.jsonPrimitive?.content - ?: throw CommandLine.ParameterException( - spec.commandLine(), - "Could not determine version from ${owner}/${repo}" - ) - - val versionNumber = version.removePrefix("v") - - val repoCacheDir = File(getCliCacheDir(), "${owner}-${repo}") - - val cachedFile = repoCacheDir.listFiles()?.find { - it.name.endsWith(".mpp") && it.name.contains(versionNumber) - } - - if (cachedFile != null){ - // If the user mentioned file with that version already exists, return that file location. - // No need to check the prerelease flag because the user has already specified the version. - logger.info("Using cached patch file at ${cachedFile.path}") - patchesFiles = patchesFiles - urlEntry + cachedFile - } - else{ - // If it doesn't exist or some other version is present, then we come here. - // Either way we download our version and replace whatever else is present. - logger.info("Downloading patches from ${owner}/${repo} ${versionNumber}....") - repoCacheDir.listFiles()?.filter { - it.name.endsWith(".mpp") - }?.forEach { it.delete() } - repoCacheDir.mkdirs() - - // Get the .mpp file ready here - val downloadUrl = asset?.jsonObject["browser_download_url"]?.jsonPrimitive?.content - - // Also get the file name ready here - val assetName = asset?.jsonObject["name"]?.jsonPrimitive?.content - - if (downloadUrl == null || assetName == null){ - throw CommandLine.ParameterException( - spec.commandLine(), - "No .mpp file found in release $version" - ) - } - - // We finally download and set everything here. - val targetFile = File(repoCacheDir, assetName) - java.net.URI(downloadUrl).toURL().openStream().use { input -> - targetFile.outputStream().use { output -> - input.copyTo(output) - } - } - logger.info("Patch saved at ${targetFile.path}. Use this file for your next runs!") - patchesFiles = patchesFiles - urlEntry + targetFile - - } + version = json["tag_name"]?.jsonPrimitive?.content + ?: throw CommandLine.ParameterException( + spec.commandLine(), + "Could not determine version from ${owner}/${repo}" + ) + } else { + // Get latest dev version here. + // Get latest dev version from GitHub immediately to check our local file. + val response = java.net.URI( + "https://api.github.com/repos/${owner}/${repo}/releases" + ).toURL().openStream().bufferedReader().readText() + + val releases = Json.parseToJsonElement(response).jsonArray + val release = releases.firstOrNull { + it.jsonObject["prerelease"]?.jsonPrimitive?.content == "true" } - else { - // Get latest dev version here. - // Get latest dev version from GitHub immediately to check our local file. - val response = java.net.URI( - "https://api.github.com/repos/${owner}/${repo}/releases" - ).toURL().openStream().bufferedReader().readText() - - val releases = Json.parseToJsonElement(response).jsonArray - val release = releases.firstOrNull { - it.jsonObject["prerelease"]?.jsonPrimitive?.content == "true" - } - ?: throw CommandLine.ParameterException( - spec.commandLine(), - "Could not get dev release from ${owner}/${repo}" - ) - - val assetArray = release.jsonObject["assets"]?.jsonArray - - val asset = assetArray?.find { - it.jsonObject["name"]?.jsonPrimitive?.content?.endsWith(".mpp") == true - } - - val version = release.jsonObject["tag_name"]?.jsonPrimitive?.content - ?: throw CommandLine.ParameterException( - spec.commandLine(), - "Could not determine version from ${owner}/${repo}" - ) - - val versionNumber = version.removePrefix("v") - - val repoCacheDir = File(getCliCacheDir(), "${owner}-${repo}") - - val cachedFile = repoCacheDir.listFiles()?.find { - it.name.endsWith(".mpp") && it.name.contains(versionNumber) - } - - if (cachedFile != null){ - // If the user mentioned file with that version already exists, return that file location. - // No need to check the prerelease flag because the user has already specified the version. - logger.info("Using cached patch file at ${cachedFile.path}") - patchesFiles = patchesFiles - urlEntry + cachedFile - } - else{ - // If it doesn't exist or some other version is present, then we come here. - // Either way we download our version and replace whatever else is present. - logger.info("Downloading patches from ${owner}/${repo} ${version}....") - repoCacheDir.listFiles()?.filter { - it.name.endsWith(".mpp") - }?.forEach { it.delete() } - repoCacheDir.mkdirs() - - // Get the .mpp file ready here - val downloadUrl = asset?.jsonObject["browser_download_url"]?.jsonPrimitive?.content - - // Also get the file name ready here - val assetName = asset?.jsonObject["name"]?.jsonPrimitive?.content - - if (downloadUrl == null || assetName == null){ - throw CommandLine.ParameterException( - spec.commandLine(), - "No .mpp file found in release $version" - ) - } - - // We finally download and set everything here. - val targetFile = File(repoCacheDir, assetName) - java.net.URI(downloadUrl).toURL().openStream().use { input -> - targetFile.outputStream().use { output -> - input.copyTo(output) - } - } + ?: throw CommandLine.ParameterException( + spec.commandLine(), + "Could not get dev release from ${owner}/${repo}" + ) - logger.info("Patch saved at ${targetFile.path}. Use this file for your next runs!") - patchesFiles = patchesFiles - urlEntry + targetFile + val assetArray = release.jsonObject["assets"]?.jsonArray - } + asset = assetArray?.find { + it.jsonObject["name"]?.jsonPrimitive?.content?.endsWith(".mpp") == true } + + version = release.jsonObject["tag_name"]?.jsonPrimitive?.content + ?: throw CommandLine.ParameterException( + spec.commandLine(), + "Could not determine version from ${owner}/${repo}" + ) } + + // Use the helper to check cache or download the .mpp file + val resolvedFile = fetchRemotePatchFile(owner, repo, version, asset, temporaryFilesPath) + patchesFiles = patchesFiles - urlEntry + resolvedFile } catch (e: Exception) { throw CommandLine.ParameterException( spec.commandLine(), @@ -1081,19 +936,64 @@ internal object PatchCommand : Callable { } logger.info(result) } -} -private class PatchFailedException(message: String, cause: Throwable) : Exception(message, cause) + // This is the helper function that can be called to do the patch files downlaoding. + // The caller resolves the version and asset from the GitHub API before calling this. + private fun fetchRemotePatchFile( + owner: String, + repo: String, + version: String, + asset: JsonElement?, + temporaryFilesPath: File + ): File { + val versionNumber = version.removePrefix("v") + + val repoCacheDir = temporaryFilesPath.resolve("download").resolve("${owner}-${repo}") + + val cachedFile = repoCacheDir.listFiles()?.find { + it.name.endsWith(".mpp") && it.name.contains(versionNumber) + } -private fun getCliCacheDir(): File { - val userHome = System.getProperty("user.home") - val osName = System.getProperty("os.name").lowercase() - val base = when { - osName.contains("win") -> File(System.getenv("APPDATA") ?: - "$userHome/AppData/Roaming", "morphe-cli") - osName.contains("mac") -> - File("$userHome/Library/Application Support", "morphe-cli") - else -> File("$userHome/.config", "morphe-cli") + if (cachedFile != null){ + // If the user mentioned file with that version already exists, return that file location. + logger.info("Using cached patch file at ${cachedFile.path}") + return cachedFile + } + else{ + // If it doesn't exist or some other version is present, then we come here. + // Either way we download our version and replace whatever else is present. + repoCacheDir.listFiles()?.filter { + it.name.endsWith(".mpp") + }?.forEach { it.delete() } + repoCacheDir.mkdirs() + + // Get the .mpp file ready here + val downloadUrl = asset?.jsonObject?.get("browser_download_url")?.jsonPrimitive?.content + + // Also get the file name ready here + val assetName = asset?.jsonObject?.get("name")?.jsonPrimitive?.content + + if (downloadUrl == null || assetName == null){ + throw CommandLine.ParameterException( + spec.commandLine(), + "No .mpp file found in release $version" + ) + } + + // We finally download and set everything here. + logger.info("Downloading patches from ${owner}/${repo} ${versionNumber}...") + val targetFile = File(repoCacheDir, assetName) + java.net.URI(downloadUrl).toURL().openStream().use { input -> + targetFile.outputStream().use { output -> + input.copyTo(output) + } + } + + logger.info("Patch saved at ${targetFile.path}. Use this file for your next runs!") + + return targetFile + } } - return File(base, "patches").also { it.mkdirs() } -} \ No newline at end of file +} + +private class PatchFailedException(message: String, cause: Throwable) : Exception(message, cause) From d790e0bec3f279cebf733deecd18952f5923e2af Mon Sep 17 00:00:00 2001 From: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com> Date: Sun, 8 Mar 2026 12:14:25 +0100 Subject: [PATCH 3/8] refactor: Adjust logging output --- src/main/kotlin/app/morphe/cli/command/PatchCommand.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index fcb7034..d87bc08 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -955,8 +955,9 @@ internal object PatchCommand : Callable { } if (cachedFile != null){ + val relativePath = cachedFile.relativeTo(temporaryFilesPath.parentFile).path // If the user mentioned file with that version already exists, return that file location. - logger.info("Using cached patch file at ${cachedFile.path}") + logger.info("Using cached patch file at $relativePath") return cachedFile } else{ @@ -989,7 +990,8 @@ internal object PatchCommand : Callable { } } - logger.info("Patch saved at ${targetFile.path}. Use this file for your next runs!") + val relativePath = targetFile.relativeTo(temporaryFilesPath.parentFile).path + logger.info("Patches mpp saved to $relativePath") return targetFile } From a80c27da1aedc0d89292db1c3fbafd6cd7255e52 Mon Sep 17 00:00:00 2001 From: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com> Date: Sun, 8 Mar 2026 12:16:50 +0100 Subject: [PATCH 4/8] docs: Mention --patches can use a url --- .../app/morphe/cli/command/PatchCommand.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index d87bc08..e0bc880 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -230,13 +230,6 @@ internal object PatchCommand : Callable { ) private var purge: Boolean = false - @CommandLine.Option( - names = ["--prerelease"], - description = ["Get the latest dev release instead of the stable release from the repo provided."], - showDefaultValue = ALWAYS, - ) - private var prerelease: Boolean = false - @CommandLine.Parameters( description = ["APK file to patch."], arity = "1", @@ -256,7 +249,7 @@ internal object PatchCommand : Callable { @CommandLine.Option( names = ["-p", "--patches"], - description = ["One or more path to MPP files."], + description = ["One or more path to MPP files or GitHub repo urls such as https://github.com/MorpheApp/morphe-patches"], required = true, ) @Suppress("unused") @@ -276,6 +269,13 @@ internal object PatchCommand : Callable { private var patchesFiles = emptySet() + @CommandLine.Option( + names = ["--prerelease"], + description = ["Fetch the latest dev pre-release instead of the stable main release from the repo provided in --patches."], + showDefaultValue = ALWAYS, + ) + private var prerelease: Boolean = false + @CommandLine.Option( names = ["--custom-aapt2-binary"], description = ["Path to a custom AAPT binary to compile resources with. Only valid when --use-arsclib is not specified."], From 9560a62b24211552c5c32c5ac6028537e74b87cb Mon Sep 17 00:00:00 2001 From: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com> Date: Sun, 8 Mar 2026 12:20:06 +0100 Subject: [PATCH 5/8] chore: Mention when Morphe divorced itself from problematic upstream RV --- src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt | 2 +- src/main/kotlin/app/morphe/cli/command/PatchCommand.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt b/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt index 05ef74b..e1a08e7 100644 --- a/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt @@ -3,7 +3,7 @@ * https://github.com/MorpheApp/morphe-cli * * Original hard forked code: - * https://github.com/revanced/revanced-cli + * https://github.com/ReVanced/revanced-cli/tree/731865e167ee449be15fff3dde7a476faea0c2de */ package app.morphe.cli.command diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index e0bc880..5d0f3bf 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -3,7 +3,7 @@ * https://github.com/MorpheApp/morphe-cli * * Original hard forked code: - * https://github.com/revanced/revanced-cli + * https://github.com/ReVanced/revanced-cli/tree/731865e167ee449be15fff3dde7a476faea0c2de */ package app.morphe.cli.command From 35af2d4250c994edc9a2c9dd20755ffc56b4002c Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:02:31 +0530 Subject: [PATCH 6/8] Major, (possible breaking) Changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shared PatchFileResolver — no duplicated URL logic. All four commands support --patches with URLs. `--prerelease` on all commands `--temporary-files-path` with shared `morphe-temporary-files/` default Cache reuse across commands ListCompatibleVersions upgraded from positional arg to `--patches` flag --- .DS_Store | Bin 0 -> 8196 bytes .../cli/command/ListCompatibleVersions.kt | 66 ++++++- .../morphe/cli/command/ListPatchesCommand.kt | 55 +++++- .../app/morphe/cli/command/OptionsCommand.kt | 47 ++++- .../app/morphe/cli/command/PatchCommand.kt | 171 +---------------- .../morphe/cli/command/PatchFileResolver.kt | 181 ++++++++++++++++++ 6 files changed, 342 insertions(+), 178 deletions(-) create mode 100644 .DS_Store create mode 100644 src/main/kotlin/app/morphe/cli/command/PatchFileResolver.kt diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..d52bc8735b63da88d81405cd4f418a009d4b2fb2 GIT binary patch literal 8196 zcmeHM&u<$=6n^8lu}#uu-NX=zkgWQG)Dl8lMIeM~oLH^J4@Yqb32kwA?TNd1vt#Y9 zW7;T^&v1e}+_`c>;=+*&S8j+u0j`|rH#6&Icby1M5mI-gnfG?)eecbDvoo_}6A`No zyKfRL6Hy*nN@EViDM_}=Je4wGO$m|#PtQkK5Yx(SQFi}c5FUs8%yiL4NTkRo&wli4wL(z*lt&hYGUYieT8l5hI}DkXdBB% zM>p4QTrb~Rd+Ydm`RMH%tI%(*9Um9+rOF%cY_sZj3jc$~Y+Uvq!o=fQ47 z-)kESe#aAzvnhN#u!XyK-v`r;fFCs6eSwIFV7WPvP5ut|e8?%x?w;v+rrYDS#}6FS z;!e}8i69UMyLLyoy5|eZEgm;LE?rh`|D}nnRNtQ{uW7}gidOJ#xiry2$tpiv50$>{ z3YQlu)lJ<}zyWZL-lGp`hx+t{zNT;Kd-{ofq2K9G`kUpL!LG7v>=t{M-DY=LgMG?6 z>=Cn>ryXP_)BLJ_o|Mdo7MbS5mghRc?V(m8jFXq2R;WiFnbgHn;CSj&Kz`&WEx9Cq zK#nend0MBt==Vo-hwAhJH6d5%Ai0#-)dX$@y_ zh-iWPAdXVJMNwJIjSb7hyfDd7)Gmj)Xn^B7IBiiq?p5&=wKm{Y@@Yc;hmkApqWD=v zX{s4S#CR;p61x;(sR&L@#OkB$fz{W;*nLF)oP6}dO?bdN7xOTm@NfoBau>rD@UGSI zu5MtJ)e&v_Ed`4)#3K5`G5d1R;E0WH;tz1~`v8@li<*V^)~JG@T~{GdkF^ zy#LqF{{8##*Y6E)>#4)~o9{ j + @Suppress("unused") + private fun setPatchesFile(patchesFiles: Set) { + patchesFiles.firstOrNull { + !it.exists() && + !it.toString().startsWith("http:/") && + !it.toString().startsWith("https:/") + }?.let { + throw CommandLine.ParameterException( + spec.commandLine(), + "${it.name} can't be found" + ) + } + this.patchesFiles = patchesFiles + } + private var patchesFiles = emptySet() - @CommandLine.Option( + @Option( + names = ["--prerelease"], + description = ["Fetch the latest dev pre-release instead of the stable main release from the repo provided in --patches."], + showDefaultValue = ALWAYS, + ) + private var prerelease: Boolean = false + + @Option( names = ["-f", "--filter-package-names"], description = ["Filter patches by package name."], ) private var packageNames: Set? = null - @CommandLine.Option( + @Option( names = ["-u", "--count-unused-patches"], description = ["Count patches that are not used by default."], - showDefaultValue = CommandLine.Help.Visibility.ALWAYS, + showDefaultValue = ALWAYS, ) private var countUnusedPatches: Boolean = false + @Option( + names = ["-t", "--temporary-files-path"], + description = ["Path to store temporary files."], + ) + private var temporaryFilesPath: File? = null + + @Spec + private lateinit var spec: CommandSpec + override fun run() { fun VersionMap.buildVersionsString(): String { if (isEmpty()) return "Any" @@ -56,6 +93,21 @@ internal class ListCompatibleVersions : Runnable { appendLine(versions.buildVersionsString().prependIndent("\t")) } + val temporaryFilesPath = temporaryFilesPath ?: File("").absoluteFile.resolve("morphe-temporary-files") + + try { + patchesFiles = PatchFileResolver.resolve( + patchesFiles, + prerelease, + temporaryFilesPath + ) + } catch (e: IllegalArgumentException) { + throw CommandLine.ParameterException( + spec.commandLine(), + e.message ?: "Failed to resolve patch URL" + ) + } + val patches = loadPatchesFromJar(patchesFiles) patches.mostCommonCompatibleVersions( diff --git a/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt b/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt index e1a08e7..a8fb3b3 100644 --- a/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt @@ -11,8 +11,11 @@ package app.morphe.cli.command import app.morphe.patcher.patch.Package import app.morphe.patcher.patch.Patch import app.morphe.patcher.patch.loadPatchesFromJar +import picocli.CommandLine import picocli.CommandLine.Command import picocli.CommandLine.Option +import picocli.CommandLine.Spec +import picocli.CommandLine.Model.CommandSpec import picocli.CommandLine.Help.Visibility.ALWAYS import java.io.File import java.util.logging.Logger @@ -32,7 +35,28 @@ internal object ListPatchesCommand : Runnable { arity = "1..*", required = true ) - private lateinit var patchFiles: Set + @Suppress("unused") + private fun setPatchesFile(patchesFiles: Set) { + patchesFiles.firstOrNull { + !it.exists() && + !it.toString().startsWith("http:/") && + !it.toString().startsWith("https:/") + }?.let { + throw CommandLine.ParameterException( + spec.commandLine(), + "${it.name} can't be found" + ) + } + this.patchesFiles = patchesFiles + } + private var patchesFiles = emptySet() + + @Option( + names = ["--prerelease"], + description = ["Fetch the latest dev pre-release instead of the stable main release from the repo provided in --patches."], + showDefaultValue = ALWAYS, + ) + private var prerelease: Boolean = false @Option( names = ["--out"], @@ -47,6 +71,12 @@ internal object ListPatchesCommand : Runnable { ) private var withDescriptions: Boolean = true + @Option( + names = ["-t", "--temporary-files-path"], + description = ["Path to store temporary files."], + ) + private var temporaryFilesPath: File? = null + @Option( names = ["-p", "--with-packages"], description = ["List the packages the patches are compatible with."], @@ -88,6 +118,9 @@ internal object ListPatchesCommand : Runnable { ) private var packageName: String? = null + @Spec + private lateinit var spec: CommandSpec + override fun run() { fun Package.buildString(): String { val (name, versions) = this @@ -157,7 +190,23 @@ internal object ListPatchesCommand : Runnable { compatiblePackageName == name } ?: withUniversalPatches - val patches = loadPatchesFromJar(patchFiles).withIndex().toList() + + val temporaryFilesPath = temporaryFilesPath ?: File("").absoluteFile.resolve("morphe-temporary-files") + + try { + patchesFiles = PatchFileResolver.resolve( + patchesFiles, + prerelease, + temporaryFilesPath + ) + } catch (e: IllegalArgumentException) { + throw CommandLine.ParameterException( + spec.commandLine(), + e.message ?: "Failed to resolve patch URL" + ) + } + + val patches = loadPatchesFromJar(patchesFiles).withIndex().toList() val filtered = packageName?.let { patches.filter { (_, patch) -> @@ -172,7 +221,7 @@ internal object ListPatchesCommand : Runnable { val finalOutput = filtered.joinToString("\n\n") {it.buildString()} if (filtered.isEmpty()) { - logger.warning("No compatible patches found in: $patchFiles") + logger.warning("No compatible patches found in: $patchesFiles") } else { if (outputFile == null) { logger.info(finalOutput) diff --git a/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt b/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt index 7320a4e..f606591 100644 --- a/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt @@ -8,6 +8,8 @@ import app.morphe.patcher.patch.loadPatchesFromJar import kotlinx.serialization.json.Json import picocli.CommandLine import picocli.CommandLine.Command +import picocli.CommandLine.Option +import picocli.CommandLine.Help.Visibility.ALWAYS import picocli.CommandLine.Model.CommandSpec import picocli.CommandLine.Spec import java.io.File @@ -28,29 +30,49 @@ internal object OptionsCommand : Callable { @Spec private lateinit var spec: CommandSpec - @CommandLine.Option( + @Option( names = ["-p", "--patches"], description = ["One or more paths to MPP files."], required = true, ) @Suppress("unused") private fun setPatchesFile(patchesFiles: Set) { - patchesFiles.firstOrNull { !it.exists() }?.let { - throw CommandLine.ParameterException(spec.commandLine(), "${it.name} can't be found") + patchesFiles.firstOrNull { + !it.exists() && + !it.toString().startsWith("http:/") && + !it.toString().startsWith("https:/") + }?.let { + throw CommandLine.ParameterException( + spec.commandLine(), + "${it.name} can't be found" + ) } this.patchesFiles = patchesFiles } private var patchesFiles = emptySet() - @CommandLine.Option( + @Option( names = ["-o", "--out"], description = ["Path to the output JSON file."], required = true, ) private lateinit var outputFile: File - @CommandLine.Option( + @Option( + names = ["--prerelease"], + description = ["Fetch the latest dev pre-release instead of the stable main release from the repo provided in --patches."], + showDefaultValue = ALWAYS, + ) + private var prerelease: Boolean = false + + @Option( + names = ["-t", "--temporary-files-path"], + description = ["Path to store temporary files."], + ) + private var temporaryFilesPath: File? = null + + @Option( names = ["-f", "--filter-package-name"], description = ["Filter patches by compatible package name."], ) @@ -59,6 +81,21 @@ internal object OptionsCommand : Callable { private val json = Json { prettyPrint = true } override fun call(): Int { + val temporaryFilesPath = temporaryFilesPath ?: File("").absoluteFile.resolve("morphe-temporary-files") + + try { + patchesFiles = PatchFileResolver.resolve( + patchesFiles, + prerelease, + temporaryFilesPath + ) + } catch (e: IllegalArgumentException) { + throw CommandLine.ParameterException( + spec.commandLine(), + e.message ?: "Failed to resolve patch URL" + ) + } + return try { logger.info("Loading patches") diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index 5d0f3bf..250034e 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -34,10 +34,6 @@ import kotlinx.coroutines.runBlocking import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import kotlinx.serialization.json.encodeToStream -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import org.jetbrains.annotations.VisibleForTesting import picocli.CommandLine import picocli.CommandLine.ArgGroup @@ -345,9 +341,7 @@ internal object PatchCommand : Callable { ) val temporaryFilesPath = - temporaryFilesPath ?: outputFilePath.parentFile.resolve( - "${outputFilePath.nameWithoutExtension}-temporary-files", - ) + temporaryFilesPath ?: File("").absoluteFile.resolve("morphe-temporary-files") val keystoreFilePath = keyStoreFilePath ?: outputFilePath.parentFile @@ -391,104 +385,15 @@ internal object PatchCommand : Callable { // The heavy Patch objects hold DEX classloaders and must not leak into finally. var patchesSnapshot: PatchBundle? = null - // We try to download our patch file here if the user passed a link - if (patchesFiles.any { - it.path.startsWith("http:/") || - it.path.startsWith("https:/") - }) { - try { - val urlEntry = patchesFiles.first{ - it.path.startsWith("http:/") || it.path.startsWith("https:/") - } - - val url = urlEntry.path - - val urlParts = url.split("/") - val owner = urlParts[2] - val repo = urlParts[3] - - // Resolve the version and asset from the GitHub API, then use the helper to cache/download. - val version: String - val asset: JsonElement? - - if (url.contains("releases/tag/")){ - // We have the release version in this branch. - version = urlParts[6] // version part of the url - - // First we hit the GitHub api for this specific release - val response = java.net.URI( - "https://api.github.com/repos/${owner}/${repo}/releases/tags/${version}" - ).toURL().openStream().bufferedReader().readText() - - // Then we find where the .mpp file is from the stream above - val json = Json.parseToJsonElement(response).jsonObject - val assetArray = json["assets"]?.jsonArray - - asset = assetArray?.find { - it.jsonObject["name"]?.jsonPrimitive?.content?.endsWith(".mpp") == true - } - - } else if (!prerelease) { - // Here in this "only repo mentioned" branch, get the latest stable version. - val response = java.net.URI( - "https://api.github.com/repos/${owner}/${repo}/releases/latest" - ).toURL().openStream().bufferedReader().readText() - - // Then we find where the .mpp file is from the stream above - val json = Json.parseToJsonElement(response).jsonObject - val assetArray = json["assets"]?.jsonArray - - asset = assetArray?.find { - it.jsonObject["name"]?.jsonPrimitive?.content?.endsWith(".mpp") == true - } - - version = json["tag_name"]?.jsonPrimitive?.content - ?: throw CommandLine.ParameterException( - spec.commandLine(), - "Could not determine version from ${owner}/${repo}" - ) - - } else { - // Get latest dev version here. - // Get latest dev version from GitHub immediately to check our local file. - val response = java.net.URI( - "https://api.github.com/repos/${owner}/${repo}/releases" - ).toURL().openStream().bufferedReader().readText() - - val releases = Json.parseToJsonElement(response).jsonArray - val release = releases.firstOrNull { - it.jsonObject["prerelease"]?.jsonPrimitive?.content == "true" - } - ?: throw CommandLine.ParameterException( - spec.commandLine(), - "Could not get dev release from ${owner}/${repo}" - ) - - val assetArray = release.jsonObject["assets"]?.jsonArray - - asset = assetArray?.find { - it.jsonObject["name"]?.jsonPrimitive?.content?.endsWith(".mpp") == true - } - - version = release.jsonObject["tag_name"]?.jsonPrimitive?.content - ?: throw CommandLine.ParameterException( - spec.commandLine(), - "Could not determine version from ${owner}/${repo}" - ) - } - - // Use the helper to check cache or download the .mpp file - val resolvedFile = fetchRemotePatchFile(owner, repo, version, asset, temporaryFilesPath) - patchesFiles = patchesFiles - urlEntry + resolvedFile - } catch (e: Exception) { - throw CommandLine.ParameterException( - spec.commandLine(), - "Failed to download patches from URL: ${e.message}" - ) - } + try { + patchesFiles = PatchFileResolver.resolve(patchesFiles, prerelease, temporaryFilesPath) + } catch (e: IllegalArgumentException) { + throw CommandLine.ParameterException( + spec.commandLine(), + e.message ?: "Failed to resolve patch URL" + ) } - try { logger.info("Loading patches") val patches: MutableSet> = loadPatchesFromJar(patchesFiles).toMutableSet() @@ -936,66 +841,6 @@ internal object PatchCommand : Callable { } logger.info(result) } - - // This is the helper function that can be called to do the patch files downlaoding. - // The caller resolves the version and asset from the GitHub API before calling this. - private fun fetchRemotePatchFile( - owner: String, - repo: String, - version: String, - asset: JsonElement?, - temporaryFilesPath: File - ): File { - val versionNumber = version.removePrefix("v") - - val repoCacheDir = temporaryFilesPath.resolve("download").resolve("${owner}-${repo}") - - val cachedFile = repoCacheDir.listFiles()?.find { - it.name.endsWith(".mpp") && it.name.contains(versionNumber) - } - - if (cachedFile != null){ - val relativePath = cachedFile.relativeTo(temporaryFilesPath.parentFile).path - // If the user mentioned file with that version already exists, return that file location. - logger.info("Using cached patch file at $relativePath") - return cachedFile - } - else{ - // If it doesn't exist or some other version is present, then we come here. - // Either way we download our version and replace whatever else is present. - repoCacheDir.listFiles()?.filter { - it.name.endsWith(".mpp") - }?.forEach { it.delete() } - repoCacheDir.mkdirs() - - // Get the .mpp file ready here - val downloadUrl = asset?.jsonObject?.get("browser_download_url")?.jsonPrimitive?.content - - // Also get the file name ready here - val assetName = asset?.jsonObject?.get("name")?.jsonPrimitive?.content - - if (downloadUrl == null || assetName == null){ - throw CommandLine.ParameterException( - spec.commandLine(), - "No .mpp file found in release $version" - ) - } - - // We finally download and set everything here. - logger.info("Downloading patches from ${owner}/${repo} ${versionNumber}...") - val targetFile = File(repoCacheDir, assetName) - java.net.URI(downloadUrl).toURL().openStream().use { input -> - targetFile.outputStream().use { output -> - input.copyTo(output) - } - } - - val relativePath = targetFile.relativeTo(temporaryFilesPath.parentFile).path - logger.info("Patches mpp saved to $relativePath") - - return targetFile - } - } } private class PatchFailedException(message: String, cause: Throwable) : Exception(message, cause) diff --git a/src/main/kotlin/app/morphe/cli/command/PatchFileResolver.kt b/src/main/kotlin/app/morphe/cli/command/PatchFileResolver.kt new file mode 100644 index 0000000..f7d48dc --- /dev/null +++ b/src/main/kotlin/app/morphe/cli/command/PatchFileResolver.kt @@ -0,0 +1,181 @@ +package app.morphe.cli.command + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File +import java.util.logging.Logger + + +object PatchFileResolver { + private val logger = Logger.getLogger(this::class.java.name) + + /** + * Takes the user's provided Patch Files and resolves any URLs that might be present. + * Returns a new Set with URLs replaced by downloaded/cached .mpp files. + */ + + fun resolve( + patchFiles: Set, + prerelease: Boolean, + cacheDir: File + ): Set { + // We try to download our patch file here if the user passed a link + if (patchFiles.any { + it.path.startsWith("http:/") || + it.path.startsWith("https:/") + }) { + try { + val urlEntry = patchFiles.first{ + it.path.startsWith("http:/") || it.path.startsWith("https:/") + } + + val url = urlEntry.path + + val urlParts = url.split("/") + val owner = urlParts[2] + val repo = urlParts[3] + + // Resolve the version and asset from the GitHub API, then use the helper to cache/download. + val version: String + val asset: JsonElement? + + if (url.contains("releases/tag/")){ + // We have the release version in this branch. + version = urlParts[6] // version part of the url + + // First we hit the GitHub api for this specific release + val response = java.net.URI( + "https://api.github.com/repos/${owner}/${repo}/releases/tags/${version}" + ).toURL().openStream().bufferedReader().readText() + + // Then we find where the .mpp file is from the stream above + val json = Json.parseToJsonElement(response).jsonObject + val assetArray = json["assets"]?.jsonArray + + asset = assetArray?.find { + it.jsonObject["name"]?.jsonPrimitive?.content?.endsWith(".mpp") == true + } + + } else if (!prerelease) { + // Here in this "only repo mentioned" branch, get the latest stable version. + val response = java.net.URI( + "https://api.github.com/repos/${owner}/${repo}/releases/latest" + ).toURL().openStream().bufferedReader().readText() + + // Then we find where the .mpp file is from the stream above + val json = Json.parseToJsonElement(response).jsonObject + val assetArray = json["assets"]?.jsonArray + + asset = assetArray?.find { + it.jsonObject["name"]?.jsonPrimitive?.content?.endsWith(".mpp") == true + } + + version = json["tag_name"]?.jsonPrimitive?.content + ?: throw IllegalArgumentException( + "Could not determine version from ${owner}/${repo}" + ) + + } else { + // Get latest dev version here. + // Get latest dev version from GitHub immediately to check our local file. + val response = java.net.URI( + "https://api.github.com/repos/${owner}/${repo}/releases" + ).toURL().openStream().bufferedReader().readText() + + val releases = Json.parseToJsonElement(response).jsonArray + val release = releases.firstOrNull { + it.jsonObject["prerelease"]?.jsonPrimitive?.content == "true" + } + ?: throw IllegalArgumentException( + "Could not get dev release from ${owner}/${repo}" + ) + + val assetArray = release.jsonObject["assets"]?.jsonArray + + asset = assetArray?.find { + it.jsonObject["name"]?.jsonPrimitive?.content?.endsWith(".mpp") == true + } + + version = release.jsonObject["tag_name"]?.jsonPrimitive?.content + ?: throw IllegalArgumentException( + "Could not determine version from ${owner}/${repo}" + ) + } + + // Use the helper to check cache or download the .mpp file + val resolvedFile = fetchRemotePatchFile( + owner, + repo, + version, + asset, + cacheDir + ) + return patchFiles - urlEntry + resolvedFile + + } catch (e: Exception) { + throw IllegalArgumentException("Failed to download patches from URL: ${e.message}") + } + } + return patchFiles + } + + // This is the helper function that can be called to do the patch files downloading. + // The caller resolves the version and asset from the GitHub API before calling this. + private fun fetchRemotePatchFile( + owner: String, + repo: String, + version: String, + asset: JsonElement?, + cacheDir: File + ): File { + val versionNumber = version.removePrefix("v") + + val repoCacheDir = cacheDir.resolve("download").resolve("${owner}-${repo}") + + val cachedFile = repoCacheDir.listFiles()?.find { + it.name.endsWith(".mpp") && it.name.contains(versionNumber) + } + + if (cachedFile != null){ + val relativePath = cachedFile.relativeTo(cacheDir.parentFile).path + // If the user mentioned file with that version already exists, return that file location. + logger.info("Using cached patch file at $relativePath") + return cachedFile + } + else{ + // If it doesn't exist or some other version is present, then we come here. + // Either way we download our version and replace whatever else is present. + repoCacheDir.listFiles()?.filter { + it.name.endsWith(".mpp") + }?.forEach { it.delete() } + repoCacheDir.mkdirs() + + // Get the .mpp file ready here + val downloadUrl = asset?.jsonObject?.get("browser_download_url")?.jsonPrimitive?.content + + // Also get the file name ready here + val assetName = asset?.jsonObject?.get("name")?.jsonPrimitive?.content + + if (downloadUrl == null || assetName == null){ + throw IllegalArgumentException("No .mpp file found in release $version") + } + + // We finally download and set everything here. + logger.info("Downloading patches from ${owner}/${repo} ${versionNumber}...") + val targetFile = File(repoCacheDir, assetName) + java.net.URI(downloadUrl).toURL().openStream().use { input -> + targetFile.outputStream().use { output -> + input.copyTo(output) + } + } + + val relativePath = targetFile.relativeTo(cacheDir.parentFile).path + logger.info("Patches mpp saved to $relativePath. This file will be used on your next run as long as it is not deleted!") + + return targetFile + } + } +} \ No newline at end of file From 4a14a1602c56fdfbf42cd8a903895b725488462d Mon Sep 17 00:00:00 2001 From: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:26:48 +0100 Subject: [PATCH 7/8] refactor: Consolidate code --- .../kotlin/app/morphe/cli/command/CommandUtils.kt | 14 +++++++++++++- .../morphe/cli/command/ListCompatibleVersions.kt | 14 ++------------ .../app/morphe/cli/command/ListPatchesCommand.kt | 12 +----------- .../app/morphe/cli/command/OptionsCommand.kt | 12 +----------- .../kotlin/app/morphe/cli/command/PatchCommand.kt | 12 +----------- 5 files changed, 18 insertions(+), 46 deletions(-) diff --git a/src/main/kotlin/app/morphe/cli/command/CommandUtils.kt b/src/main/kotlin/app/morphe/cli/command/CommandUtils.kt index e982a6c..fda20cb 100644 --- a/src/main/kotlin/app/morphe/cli/command/CommandUtils.kt +++ b/src/main/kotlin/app/morphe/cli/command/CommandUtils.kt @@ -1,7 +1,19 @@ package app.morphe.cli.command import picocli.CommandLine -import kotlin.text.iterator +import picocli.CommandLine.Model.CommandSpec +import java.io.File + +internal fun checkFileExistsOrIsUrl(files: Set, spec: CommandSpec) : Set { + files.firstOrNull { + !it.exists() && !it.toString().let { + it.startsWith("http://") || it.startsWith("https://") + } + }?.let { + throw CommandLine.ParameterException(spec.commandLine(), "${it.name} can not be found") + } + return files +} class OptionKeyConverter : CommandLine.ITypeConverter { override fun convert(value: String): String = value diff --git a/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt b/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt index 99671f2..85c0e8d 100644 --- a/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt +++ b/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt @@ -6,9 +6,9 @@ import app.morphe.patcher.patch.loadPatchesFromJar import app.morphe.patcher.patch.mostCommonCompatibleVersions import picocli.CommandLine import picocli.CommandLine.Command -import picocli.CommandLine.Option import picocli.CommandLine.Help.Visibility.ALWAYS import picocli.CommandLine.Model.CommandSpec +import picocli.CommandLine.Option import picocli.CommandLine.Spec import java.io.File import java.util.logging.Logger @@ -31,17 +31,7 @@ internal class ListCompatibleVersions : Runnable { ) @Suppress("unused") private fun setPatchesFile(patchesFiles: Set) { - patchesFiles.firstOrNull { - !it.exists() && - !it.toString().startsWith("http:/") && - !it.toString().startsWith("https:/") - }?.let { - throw CommandLine.ParameterException( - spec.commandLine(), - "${it.name} can't be found" - ) - } - this.patchesFiles = patchesFiles + this.patchesFiles = checkFileExistsOrIsUrl(patchesFiles, spec) } private var patchesFiles = emptySet() diff --git a/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt b/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt index a8fb3b3..4859bd2 100644 --- a/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt @@ -37,17 +37,7 @@ internal object ListPatchesCommand : Runnable { ) @Suppress("unused") private fun setPatchesFile(patchesFiles: Set) { - patchesFiles.firstOrNull { - !it.exists() && - !it.toString().startsWith("http:/") && - !it.toString().startsWith("https:/") - }?.let { - throw CommandLine.ParameterException( - spec.commandLine(), - "${it.name} can't be found" - ) - } - this.patchesFiles = patchesFiles + this.patchesFiles = checkFileExistsOrIsUrl(patchesFiles, spec) } private var patchesFiles = emptySet() diff --git a/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt b/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt index f606591..3981bdb 100644 --- a/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt @@ -37,17 +37,7 @@ internal object OptionsCommand : Callable { ) @Suppress("unused") private fun setPatchesFile(patchesFiles: Set) { - patchesFiles.firstOrNull { - !it.exists() && - !it.toString().startsWith("http:/") && - !it.toString().startsWith("https:/") - }?.let { - throw CommandLine.ParameterException( - spec.commandLine(), - "${it.name} can't be found" - ) - } - this.patchesFiles = patchesFiles + this.patchesFiles = checkFileExistsOrIsUrl(patchesFiles, spec) } private var patchesFiles = emptySet() diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index 250034e..0aa3be4 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -250,17 +250,7 @@ internal object PatchCommand : Callable { ) @Suppress("unused") private fun setPatchesFile(patchesFiles: Set) { - patchesFiles.firstOrNull { - !it.exists() && - !it.toString().startsWith("http:/") && - !it.toString().startsWith("https:/") - }?.let { - throw CommandLine.ParameterException( - spec.commandLine(), - "${it.name} can't be found" - ) - } - this.patchesFiles = patchesFiles + this.patchesFiles = checkFileExistsOrIsUrl(patchesFiles, spec) } private var patchesFiles = emptySet() From df34c4d324e1094952062dbc24c81ef285c16ade Mon Sep 17 00:00:00 2001 From: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:29:36 +0100 Subject: [PATCH 8/8] chore: Update command line descriptions --- .../kotlin/app/morphe/cli/command/ListCompatibleVersions.kt | 2 +- src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt | 2 +- src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt | 2 +- src/main/kotlin/app/morphe/cli/command/PatchCommand.kt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt b/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt index 85c0e8d..78fcf0f 100644 --- a/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt +++ b/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt @@ -25,7 +25,7 @@ internal class ListCompatibleVersions : Runnable { @Option( names = ["--patches"], - description = ["One or more paths to MPP files."], + description = ["Path to a MPP file or a GitHub repo url such as https://github.com/MorpheApp/morphe-patches"], arity = "1..*", required = true ) diff --git a/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt b/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt index 4859bd2..94384ea 100644 --- a/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt @@ -31,7 +31,7 @@ internal object ListPatchesCommand : Runnable { // Patches is now flag based rather than position based @Option( names = ["--patches"], - description = ["One or more paths to MPP files."], + description = ["Path to a MPP file or a GitHub repo url such as https://github.com/MorpheApp/morphe-patches"], arity = "1..*", required = true ) diff --git a/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt b/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt index 3981bdb..00ac126 100644 --- a/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt @@ -32,7 +32,7 @@ internal object OptionsCommand : Callable { @Option( names = ["-p", "--patches"], - description = ["One or more paths to MPP files."], + description = ["Path to a MPP file or a GitHub repo url such as https://github.com/MorpheApp/morphe-patches"], required = true, ) @Suppress("unused") diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index 0aa3be4..079efdc 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -245,7 +245,7 @@ internal object PatchCommand : Callable { @CommandLine.Option( names = ["-p", "--patches"], - description = ["One or more path to MPP files or GitHub repo urls such as https://github.com/MorpheApp/morphe-patches"], + description = ["Path to a MPP file or a GitHub repo url such as https://github.com/MorpheApp/morphe-patches"], required = true, ) @Suppress("unused")