Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ repositories {
mavenLocal()
mavenCentral()
google()
maven { url = uri("https://maven.pkg.jetbrains.space/public/p/compose/dev") }
maven {
// A repository must be specified for some reason. "registry" is a dummy.
url = uri("https://maven.pkg.github.com/MorpheApp/registry")
Expand Down Expand Up @@ -85,15 +86,14 @@ dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.swing)
implementation(libs.kotlinx.serialization.json)
// testImplementation(libs.kotlin.test)
//}

// -- Networking (GUI) --------------------------------------------------
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktor.client.logging)
implementation(libs.slf4j.nop)

// -- DI / Navigation (GUI) ---------------------------------------------
implementation(platform(libs.koin.bom))
Expand All @@ -109,9 +109,7 @@ dependencies {
implementation(libs.jna)
implementation(libs.jna.platform)

// -- APK Parsing (GUI) -------------------------------------------------
implementation(libs.apk.parser)

// -- License attribution UI (About / Licenses screen) -----------------
implementation(libs.about.libraries.core)
implementation(libs.about.libraries.m3)

Expand Down Expand Up @@ -209,12 +207,15 @@ tasks {
exclude(dependency("app.morphe:morphe-patcher"))
// Ktor uses ServiceLoader
exclude(dependency("io.ktor:.*"))
exclude(dependency("org.slf4j:.*"))
// Koin uses reflection
exclude(dependency("io.insert-koin:.*"))
// Coroutines Swing provides Dispatchers.Main via ServiceLoader
exclude(dependency("org.jetbrains.kotlinx:kotlinx-coroutines-swing"))
// JNA uses reflection + native loading for DWM title bar tinting
exclude(dependency("net.java.dev.jna:.*"))
// Skiko uses ServiceLoader for native registration. Same class of problem as Ktor / Koin / JNA above.
exclude(dependency("org.jetbrains.skiko:.*"))
}

mergeServiceFiles()
Expand Down
12 changes: 6 additions & 6 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@ kotlinx-serialization = "1.11.0"
# JNA (Windows DWM title bar tinting)
jna = "5.18.1"

# APK
apk-parser = "2.6.10"

# Testing
mockk = "1.14.9"

# Logging
slf4j = "2.0.18"

# Libraries
about-libraries = "14.1.0"

Expand Down Expand Up @@ -74,13 +74,13 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" }

# APK
apk-parser = { module = "net.dongliu:apk-parser", version.ref = "apk-parser" }

# Testing
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }

# Logging
slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" }

# About Libraries
about-libraries-core = { group = "com.mikepenz", name = "aboutlibraries-compose-core", version.ref = "about-libraries" }
about-libraries-m3 = { group = "com.mikepenz", name = "aboutlibraries-compose-m3", version.ref = "about-libraries" }
Expand Down
25 changes: 25 additions & 0 deletions src/main/kotlin/app/morphe/cli/command/CliHttpClient.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-cli
*/

package app.morphe.cli.command

import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json

/**
* Lazy initialized HttpClient for CLI commands. One client per process is fine for short-lived
* `morhpe-cli ....` invocations. Engine remote sources (like GitHub and GitLab) require this to be passed in.
*
* We could later swap `by lazy` for `fun create()` if we ever want the CLI to share lifecycle with anything else.
*/
object CliHttpClient {
val instance: HttpClient by lazy {
HttpClient(CIO) {
install(ContentNegotiation) { json() }
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package app.morphe.cli.command

import app.morphe.engine.MorpheData
import app.morphe.patcher.patch.PackageName
import app.morphe.patcher.patch.VersionMap
import app.morphe.patcher.patch.loadPatchesFromJar
Expand Down Expand Up @@ -83,13 +84,14 @@ internal class ListCompatibleVersions : Runnable {
appendLine(versions.buildVersionsString().prependIndent("\t"))
}

val temporaryFilesPath = temporaryFilesPath ?: File("").absoluteFile.resolve("morphe-temporary-files")
val temporaryFilesPath = temporaryFilesPath ?: MorpheData.tmpDir

try {
patchesFiles = PatchFileResolver.resolve(
patchesFiles,
prerelease,
temporaryFilesPath
temporaryFilesPath,
CliHttpClient.instance
)
} catch (e: IllegalArgumentException) {
throw CommandLine.ParameterException(
Expand Down
6 changes: 4 additions & 2 deletions src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

package app.morphe.cli.command

import app.morphe.engine.MorpheData
import app.morphe.patcher.patch.Package
import app.morphe.patcher.patch.Patch
import app.morphe.patcher.patch.loadPatchesFromJar
Expand Down Expand Up @@ -181,13 +182,14 @@ internal object ListPatchesCommand : Runnable {
} ?: withUniversalPatches


val temporaryFilesPath = temporaryFilesPath ?: File("").absoluteFile.resolve("morphe-temporary-files")
val temporaryFilesPath = temporaryFilesPath ?: MorpheData.tmpDir

try {
patchesFiles = PatchFileResolver.resolve(
patchesFiles,
prerelease,
temporaryFilesPath
temporaryFilesPath,
CliHttpClient.instance
)
} catch (e: IllegalArgumentException) {
throw CommandLine.ParameterException(
Expand Down
104 changes: 62 additions & 42 deletions src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package app.morphe.cli.command

import app.morphe.cli.command.model.PatchBundle
import app.morphe.engine.MorpheData
import app.morphe.engine.patches.LoadedBundle
import app.morphe.engine.patches.PatchBundleLoader
import app.morphe.cli.command.model.findMatchingBundle
import app.morphe.cli.command.model.mergeWithBundle
import app.morphe.cli.command.model.withUpdatedBundle
import app.morphe.patcher.patch.loadPatchesFromJar
import kotlinx.serialization.json.Json
import picocli.CommandLine
import picocli.CommandLine.Command
Expand Down Expand Up @@ -71,14 +73,19 @@ internal object OptionsCommand : Callable<Int> {
private val json = Json { prettyPrint = true }

override fun call(): Int {
val temporaryFilesPath = temporaryFilesPath ?: File("").absoluteFile.resolve("morphe-temporary-files")
val temporaryFilesPath = temporaryFilesPath ?: MorpheData.tmpDir

try {
patchesFiles = PatchFileResolver.resolve(
patchesFiles,
prerelease,
temporaryFilesPath
)
// Since we could have many URLs, we resolve each of them separately
patchesFiles = patchesFiles.map { file ->
val resolved = PatchFileResolver.resolve(
setOf(file),
prerelease,
temporaryFilesPath,
CliHttpClient.instance
)
resolved.single()
}.toSet()
} catch (e: IllegalArgumentException) {
throw CommandLine.ParameterException(
spec.commandLine(),
Expand All @@ -87,51 +94,64 @@ internal object OptionsCommand : Callable<Int> {
}

return try {
logger.info("Loading patches")

val patches = loadPatchesFromJar(patchesFiles)
logger.info("Loading patches...")

val filtered = packageName?.let { pkg ->
patches.filter { patch ->
patch.compatiblePackages?.any { (name, _) -> name == pkg } ?: true
}.toSet()
} ?: patches
// Load each bundle separately so we produce one JSON entry per .mpp
// matches the shape PatchCommand expects when reading --options-file.
val loadedBundles: List<LoadedBundle> = PatchBundleLoader.loadEach(patchesFiles)

// Read existing bundles list if the file already exists
val existingBundles: List<PatchBundle>? = if (outputFile.exists()) {
// Read existing bundles list if the file already exists.
val existingBundles: List<PatchBundle> = if (outputFile.exists())
{
try {
Json.decodeFromString<List<PatchBundle>>(outputFile.readText())
} catch (e: Exception) {
logger.warning("Could not parse existing file, creating fresh: ${e.message}")
null
logger.warning(
"Could not parse existing file, creating fresh: ${e.message}"
)
emptyList()
}
} else null

// Find the bundle matching the current .mpp file(s), merge with it (or create fresh)
val existingBundle = existingBundles?.findMatchingBundle(patchesFiles)
val updatedBundle = filtered.mergeWithBundle(
existing = existingBundle,
sourceFiles = patchesFiles,
)

// Replace the matching entry in the list (or start a new list)
val updatedBundles = existingBundles?.withUpdatedBundle(updatedBundle)
?: listOf(updatedBundle)
} else emptyList()

// For each bundle: apply optional package filter, find its matching JSON
// entry (by source filename), merge, splice updated entry back into the running list.
var updatedBundles = existingBundles
loadedBundles.forEach { lb ->
val filtered = packageName?.let { pkg ->
lb.patches.filter { patch ->
patch.compatiblePackages?.any { (name, _) -> name == pkg } ?: true
}.toSet()
} ?: lb.patches

val existingBundle = updatedBundles.findMatchingBundle(setOf(lb.sourceFile))
val updatedBundle = filtered.mergeWithBundle(
existing = existingBundle,
sourceFiles = setOf(lb.sourceFile),
)
updatedBundles = updatedBundles.withUpdatedBundle(updatedBundle)

// Per-bundle log line so users can see what changed for each .mpp
if (existingBundle != null) {
val existingNames = existingBundle.patches.keys.map { it.lowercase() }.toSet()
val newNames = updatedBundle.patches.keys.map { it.lowercase() }.toSet()
val added = newNames - existingNames
val removed = existingNames - newNames
val kept = newNames.intersect(existingNames)

logger.info(
"Updated bundle for ${lb.sourceFile.name}: ${kept.size} preserved, ${added.size} added, ${removed.size} removed"
)
} else {
logger.info(
"Created new bundle for ${lb.sourceFile.name} with ${updatedBundle.patches.size} patches"
)
}
}

outputFile.absoluteFile.parentFile?.mkdirs()
outputFile.writeText(json.encodeToString(updatedBundles))

if (existingBundle != null) {
val existingNames = existingBundle.patches.keys.map { it.lowercase() }.toSet()
val newNames = updatedBundle.patches.keys.map { it.lowercase() }.toSet()
val added = newNames - existingNames
val removed = existingNames - newNames
val kept = newNames.intersect(existingNames)
logger.info("Updated bundle in options file at ${outputFile.path}")
logger.info(" ${kept.size} patches preserved, ${added.size} added, ${removed.size} removed")
} else {
logger.info("Created new bundle in options file at ${outputFile.path} with ${updatedBundle.patches.size} patches")
}
logger.info("Options file saved to ${outputFile.path}")

EXIT_CODE_SUCCESS
} catch (e: Exception) {
Expand Down
Loading
Loading