diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..d52bc87 Binary files /dev/null and b/.DS_Store differ 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 8634884..78fcf0f 100644 --- a/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt +++ b/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt @@ -5,10 +5,15 @@ import app.morphe.patcher.patch.VersionMap import app.morphe.patcher.patch.loadPatchesFromJar import app.morphe.patcher.patch.mostCommonCompatibleVersions import picocli.CommandLine +import picocli.CommandLine.Command +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 -@CommandLine.Command( +@Command( name = "list-versions", description = [ "List the most common compatible versions of apps that are compatible " + @@ -18,25 +23,47 @@ import java.util.logging.Logger internal class ListCompatibleVersions : Runnable { private val logger = Logger.getLogger(this::class.java.name) - @CommandLine.Parameters( - description = ["Paths to MPP files."], + @Option( + names = ["--patches"], + description = ["Path to a MPP file or a GitHub repo url such as https://github.com/MorpheApp/morphe-patches"], arity = "1..*", + required = true ) - private lateinit var patchesFiles: Set + @Suppress("unused") + private fun setPatchesFile(patchesFiles: Set) { + this.patchesFiles = checkFileExistsOrIsUrl(patchesFiles, spec) + } + 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 - @CommandLine.Option( + @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 +83,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 05ef74b..94384ea 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 @@ -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 @@ -28,11 +31,22 @@ 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 ) - private lateinit var patchFiles: Set + @Suppress("unused") + private fun setPatchesFile(patchesFiles: Set) { + this.patchesFiles = checkFileExistsOrIsUrl(patchesFiles, spec) + } + 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 +61,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 +108,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 +180,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 +211,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..00ac126 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,39 @@ internal object OptionsCommand : Callable { @Spec private lateinit var spec: CommandSpec - @CommandLine.Option( + @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") private fun setPatchesFile(patchesFiles: Set) { - patchesFiles.firstOrNull { !it.exists() }?.let { - throw CommandLine.ParameterException(spec.commandLine(), "${it.name} can't be found") - } - this.patchesFiles = patchesFiles + this.patchesFiles = checkFileExistsOrIsUrl(patchesFiles, spec) } 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 +71,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 1e3ddb2..079efdc 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 @@ -45,6 +45,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 @@ -244,19 +245,23 @@ internal object PatchCommand : Callable { @CommandLine.Option( names = ["-p", "--patches"], - description = ["One or more path 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") private fun setPatchesFile(patchesFiles: Set) { - patchesFiles.firstOrNull { !it.exists() }?.let { - throw CommandLine.ParameterException(spec.commandLine(), "${it.name} can't be found") - } - this.patchesFiles = patchesFiles + this.patchesFiles = checkFileExistsOrIsUrl(patchesFiles, spec) } 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."], @@ -326,9 +331,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 @@ -372,6 +375,15 @@ internal object PatchCommand : Callable { // The heavy Patch objects hold DEX classloaders and must not leak into finally. var patchesSnapshot: PatchBundle? = null + 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() 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