Skip to content

Commit 44ed6c6

Browse files
authored
feat: Apply patches from multiple patch bundles, add GUI patch source selector (#145)
1 parent 1d45089 commit 44ed6c6

50 files changed

Lines changed: 6800 additions & 2453 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

build.gradle.kts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ repositories {
4949
mavenLocal()
5050
mavenCentral()
5151
google()
52+
maven { url = uri("https://maven.pkg.jetbrains.space/public/p/compose/dev") }
5253
maven {
5354
// A repository must be specified for some reason. "registry" is a dummy.
5455
url = uri("https://maven.pkg.github.com/MorpheApp/registry")
@@ -85,15 +86,14 @@ dependencies {
8586
implementation(libs.kotlinx.coroutines.core)
8687
implementation(libs.kotlinx.coroutines.swing)
8788
implementation(libs.kotlinx.serialization.json)
88-
// testImplementation(libs.kotlin.test)
89-
//}
9089

9190
// -- Networking (GUI) --------------------------------------------------
9291
implementation(libs.ktor.client.core)
9392
implementation(libs.ktor.client.cio)
9493
implementation(libs.ktor.client.content.negotiation)
9594
implementation(libs.ktor.serialization.kotlinx.json)
9695
implementation(libs.ktor.client.logging)
96+
implementation(libs.slf4j.nop)
9797

9898
// -- DI / Navigation (GUI) ---------------------------------------------
9999
implementation(platform(libs.koin.bom))
@@ -109,9 +109,7 @@ dependencies {
109109
implementation(libs.jna)
110110
implementation(libs.jna.platform)
111111

112-
// -- APK Parsing (GUI) -------------------------------------------------
113-
implementation(libs.apk.parser)
114-
112+
// -- License attribution UI (About / Licenses screen) -----------------
115113
implementation(libs.about.libraries.core)
116114
implementation(libs.about.libraries.m3)
117115

@@ -209,12 +207,15 @@ tasks {
209207
exclude(dependency("app.morphe:morphe-patcher"))
210208
// Ktor uses ServiceLoader
211209
exclude(dependency("io.ktor:.*"))
210+
exclude(dependency("org.slf4j:.*"))
212211
// Koin uses reflection
213212
exclude(dependency("io.insert-koin:.*"))
214213
// Coroutines Swing provides Dispatchers.Main via ServiceLoader
215214
exclude(dependency("org.jetbrains.kotlinx:kotlinx-coroutines-swing"))
216215
// JNA uses reflection + native loading for DWM title bar tinting
217216
exclude(dependency("net.java.dev.jna:.*"))
217+
// Skiko uses ServiceLoader for native registration. Same class of problem as Ktor / Koin / JNA above.
218+
exclude(dependency("org.jetbrains.skiko:.*"))
218219
}
219220

220221
mergeServiceFiles()

gradle/libs.versions.toml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,12 @@ kotlinx-serialization = "1.11.0"
2828
# JNA (Windows DWM title bar tinting)
2929
jna = "5.18.1"
3030

31-
# APK
32-
apk-parser = "2.6.10"
33-
3431
# Testing
3532
mockk = "1.14.9"
3633

34+
# Logging
35+
slf4j = "2.0.18"
36+
3737
# Libraries
3838
about-libraries = "14.1.0"
3939

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

77-
# APK
78-
apk-parser = { module = "net.dongliu:apk-parser", version.ref = "apk-parser" }
79-
8077
# Testing
8178
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
8279
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
8380

81+
# Logging
82+
slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" }
83+
8484
# About Libraries
8585
about-libraries-core = { group = "com.mikepenz", name = "aboutlibraries-compose-core", version.ref = "about-libraries" }
8686
about-libraries-m3 = { group = "com.mikepenz", name = "aboutlibraries-compose-m3", version.ref = "about-libraries" }
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
* Copyright 2026 Morphe.
3+
* https://github.com/MorpheApp/morphe-cli
4+
*/
5+
6+
package app.morphe.cli.command
7+
8+
import io.ktor.client.HttpClient
9+
import io.ktor.client.engine.cio.CIO
10+
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
11+
import io.ktor.serialization.kotlinx.json.json
12+
13+
/**
14+
* Lazy initialized HttpClient for CLI commands. One client per process is fine for short-lived
15+
* `morhpe-cli ....` invocations. Engine remote sources (like GitHub and GitLab) require this to be passed in.
16+
*
17+
* We could later swap `by lazy` for `fun create()` if we ever want the CLI to share lifecycle with anything else.
18+
*/
19+
object CliHttpClient {
20+
val instance: HttpClient by lazy {
21+
HttpClient(CIO) {
22+
install(ContentNegotiation) { json() }
23+
}
24+
}
25+
}

src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package app.morphe.cli.command
22

3+
import app.morphe.engine.MorpheData
34
import app.morphe.patcher.patch.PackageName
45
import app.morphe.patcher.patch.VersionMap
56
import app.morphe.patcher.patch.loadPatchesFromJar
@@ -83,13 +84,14 @@ internal class ListCompatibleVersions : Runnable {
8384
appendLine(versions.buildVersionsString().prependIndent("\t"))
8485
}
8586

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

8889
try {
8990
patchesFiles = PatchFileResolver.resolve(
9091
patchesFiles,
9192
prerelease,
92-
temporaryFilesPath
93+
temporaryFilesPath,
94+
CliHttpClient.instance
9395
)
9496
} catch (e: IllegalArgumentException) {
9597
throw CommandLine.ParameterException(

src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
package app.morphe.cli.command
1010

11+
import app.morphe.engine.MorpheData
1112
import app.morphe.patcher.patch.Package
1213
import app.morphe.patcher.patch.Patch
1314
import app.morphe.patcher.patch.loadPatchesFromJar
@@ -181,13 +182,14 @@ internal object ListPatchesCommand : Runnable {
181182
} ?: withUniversalPatches
182183

183184

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

186187
try {
187188
patchesFiles = PatchFileResolver.resolve(
188189
patchesFiles,
189190
prerelease,
190-
temporaryFilesPath
191+
temporaryFilesPath,
192+
CliHttpClient.instance
191193
)
192194
} catch (e: IllegalArgumentException) {
193195
throw CommandLine.ParameterException(

src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt

Lines changed: 62 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package app.morphe.cli.command
22

33
import app.morphe.cli.command.model.PatchBundle
4+
import app.morphe.engine.MorpheData
5+
import app.morphe.engine.patches.LoadedBundle
6+
import app.morphe.engine.patches.PatchBundleLoader
47
import app.morphe.cli.command.model.findMatchingBundle
58
import app.morphe.cli.command.model.mergeWithBundle
69
import app.morphe.cli.command.model.withUpdatedBundle
7-
import app.morphe.patcher.patch.loadPatchesFromJar
810
import kotlinx.serialization.json.Json
911
import picocli.CommandLine
1012
import picocli.CommandLine.Command
@@ -71,14 +73,19 @@ internal object OptionsCommand : Callable<Int> {
7173
private val json = Json { prettyPrint = true }
7274

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

7678
try {
77-
patchesFiles = PatchFileResolver.resolve(
78-
patchesFiles,
79-
prerelease,
80-
temporaryFilesPath
81-
)
79+
// Since we could have many URLs, we resolve each of them separately
80+
patchesFiles = patchesFiles.map { file ->
81+
val resolved = PatchFileResolver.resolve(
82+
setOf(file),
83+
prerelease,
84+
temporaryFilesPath,
85+
CliHttpClient.instance
86+
)
87+
resolved.single()
88+
}.toSet()
8289
} catch (e: IllegalArgumentException) {
8390
throw CommandLine.ParameterException(
8491
spec.commandLine(),
@@ -87,51 +94,64 @@ internal object OptionsCommand : Callable<Int> {
8794
}
8895

8996
return try {
90-
logger.info("Loading patches")
91-
92-
val patches = loadPatchesFromJar(patchesFiles)
97+
logger.info("Loading patches...")
9398

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

100-
// Read existing bundles list if the file already exists
101-
val existingBundles: List<PatchBundle>? = if (outputFile.exists()) {
103+
// Read existing bundles list if the file already exists.
104+
val existingBundles: List<PatchBundle> = if (outputFile.exists())
105+
{
102106
try {
103107
Json.decodeFromString<List<PatchBundle>>(outputFile.readText())
104108
} catch (e: Exception) {
105-
logger.warning("Could not parse existing file, creating fresh: ${e.message}")
106-
null
109+
logger.warning(
110+
"Could not parse existing file, creating fresh: ${e.message}"
111+
)
112+
emptyList()
107113
}
108-
} else null
109-
110-
// Find the bundle matching the current .mpp file(s), merge with it (or create fresh)
111-
val existingBundle = existingBundles?.findMatchingBundle(patchesFiles)
112-
val updatedBundle = filtered.mergeWithBundle(
113-
existing = existingBundle,
114-
sourceFiles = patchesFiles,
115-
)
116-
117-
// Replace the matching entry in the list (or start a new list)
118-
val updatedBundles = existingBundles?.withUpdatedBundle(updatedBundle)
119-
?: listOf(updatedBundle)
114+
} else emptyList()
115+
116+
// For each bundle: apply optional package filter, find its matching JSON
117+
// entry (by source filename), merge, splice updated entry back into the running list.
118+
var updatedBundles = existingBundles
119+
loadedBundles.forEach { lb ->
120+
val filtered = packageName?.let { pkg ->
121+
lb.patches.filter { patch ->
122+
patch.compatiblePackages?.any { (name, _) -> name == pkg } ?: true
123+
}.toSet()
124+
} ?: lb.patches
125+
126+
val existingBundle = updatedBundles.findMatchingBundle(setOf(lb.sourceFile))
127+
val updatedBundle = filtered.mergeWithBundle(
128+
existing = existingBundle,
129+
sourceFiles = setOf(lb.sourceFile),
130+
)
131+
updatedBundles = updatedBundles.withUpdatedBundle(updatedBundle)
132+
133+
// Per-bundle log line so users can see what changed for each .mpp
134+
if (existingBundle != null) {
135+
val existingNames = existingBundle.patches.keys.map { it.lowercase() }.toSet()
136+
val newNames = updatedBundle.patches.keys.map { it.lowercase() }.toSet()
137+
val added = newNames - existingNames
138+
val removed = existingNames - newNames
139+
val kept = newNames.intersect(existingNames)
140+
141+
logger.info(
142+
"Updated bundle for ${lb.sourceFile.name}: ${kept.size} preserved, ${added.size} added, ${removed.size} removed"
143+
)
144+
} else {
145+
logger.info(
146+
"Created new bundle for ${lb.sourceFile.name} with ${updatedBundle.patches.size} patches"
147+
)
148+
}
149+
}
120150

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

124-
if (existingBundle != null) {
125-
val existingNames = existingBundle.patches.keys.map { it.lowercase() }.toSet()
126-
val newNames = updatedBundle.patches.keys.map { it.lowercase() }.toSet()
127-
val added = newNames - existingNames
128-
val removed = existingNames - newNames
129-
val kept = newNames.intersect(existingNames)
130-
logger.info("Updated bundle in options file at ${outputFile.path}")
131-
logger.info(" ${kept.size} patches preserved, ${added.size} added, ${removed.size} removed")
132-
} else {
133-
logger.info("Created new bundle in options file at ${outputFile.path} with ${updatedBundle.patches.size} patches")
134-
}
154+
logger.info("Options file saved to ${outputFile.path}")
135155

136156
EXIT_CODE_SUCCESS
137157
} catch (e: Exception) {

0 commit comments

Comments
 (0)