Skip to content

Commit 8cb5250

Browse files
fix: Resilient patch bundle loading + quieter ADB logging + unique version output naming (#224)
- Failing to load a patch source now properly throws an error instead of loading forever. We also show a banner mentioning that a patch source has failed to load. - abd logs are now quieter. Now only connect, disconnect, and status transitions are logged. - Output names already tried to encode the app version, but it came from the input filename, so a renamed input fell back to "patched" and different app versions overwrote each other. The app version is now read from the APK manifest (versionName) properly. - Also changed how the LEDs work on the source sheet and the source pill. The LEDs now properly reflect the state of the patch source instead of just showing what is active or inactive. - Patcher version is now properly read from classpath instead of trusting the libs.toml file. - Patch load failures no longer surface as a blank "Failed to load". Full chain is logged and shown in the UI. (Even when the .message is null (might need more fixing in other places.) - Log the patcher and library info in the logs and the tools dialog. --------- Co-authored-by: semantic-release-bot <semantic-release-bot@martynus.net>
1 parent f5ad5a0 commit 8cb5250

22 files changed

Lines changed: 646 additions & 42 deletions

build.gradle.kts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -237,14 +237,46 @@ tasks {
237237
}
238238
}
239239

240+
// Write the *resolved* morphe library version (not the catalog pin). Patcher ships
241+
// its own version.properties.
242+
val writeMorpheComponents = register("writeMorpheComponents") {
243+
description = "Writes the resolved morphe-library version to components.properties"
244+
val outFile = layout.buildDirectory.file(
245+
"generated/morphe-components/app/morphe/cli/components.properties",
246+
)
247+
val runtimeCp = configurations.named("runtimeClasspath")
248+
inputs.files(runtimeCp)
249+
outputs.file(outFile)
250+
doLast {
251+
val libraryVersion = runtimeCp.get().incoming.resolutionResult.allComponents
252+
.mapNotNull { it.moduleVersion }
253+
.firstOrNull { it.group == "app.morphe" && it.name.startsWith("morphe-library") }
254+
?.version
255+
?: "unknown"
256+
outFile.get().asFile.apply {
257+
parentFile.mkdirs()
258+
writeText(
259+
"# Generated from resolved runtimeClasspath — do not edit\n" +
260+
"libraryVersion=$libraryVersion\n",
261+
)
262+
}
263+
}
264+
}
265+
240266
processResources {
241267
// Make sure the licenses are generated before the resources are processed
242-
dependsOn("exportLibraryDefinitions")
268+
dependsOn("exportLibraryDefinitions", writeMorpheComponents)
243269
from(layout.buildDirectory.file("generated/aboutLibraries/aboutlibraries.json"))
270+
from(layout.buildDirectory.dir("generated/morphe-components"))
244271

245-
// Only expand properties files, not binary files like PNG/ICO
272+
// Only expand properties files, not binary files like PNG/ICO.
273+
// Patcher is read from its jar at runtime. libraryVersion lives in
274+
// components.properties generated above, skip token expansion for it.
246275
filesMatching("**/*.properties") {
247-
expand("projectVersion" to project.version)
276+
if (path.contains("components.properties")) return@filesMatching
277+
expand(
278+
"projectVersion" to project.version,
279+
)
248280
}
249281
// Bundle the project's NOTICE (GPL 7b/7c) and LICENSE into META-INF so they
250282
// land in the main JAR before the Shadow merge. Source of truth stays at the
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
* Copyright 2026 Morphe.
3+
* https://github.com/MorpheApp/morphe-desktop
4+
*/
5+
6+
package app.morphe.engine
7+
8+
import java.util.Properties
9+
10+
/**
11+
* Versions of Morphe ecosystem components that this desktop build is running against.
12+
*
13+
* Prefer each library's own embedded resource when present (so composite/local
14+
* substitutions report the truth). Fall back to build-time resolved metadata only
15+
* when a library does not ship a version resource (currently: morphe-library).
16+
*/
17+
object MorpheComponents {
18+
19+
/** morphe-patcher on the classpath (`app/morphe/patcher/version.properties`). */
20+
val patcherVersion: String? by lazy {
21+
readVersionProperty("/app/morphe/patcher/version.properties", "version")
22+
}
23+
24+
/**
25+
* morphe-library on the classpath.
26+
* Prefers `app/morphe/library/version.properties` if the library starts shipping it;
27+
* otherwise the build-time resolved version in `app/morphe/cli/components.properties`.
28+
*/
29+
val libraryVersion: String? by lazy {
30+
readVersionProperty("/app/morphe/library/version.properties", "version")
31+
?: readVersionProperty("/app/morphe/cli/components.properties", "libraryVersion")
32+
}
33+
34+
private fun readVersionProperty(resourcePath: String, key: String): String? =
35+
runCatching {
36+
MorpheComponents::class.java
37+
.getResourceAsStream(resourcePath)
38+
?.use { Properties().apply { load(it) }.getProperty(key) }
39+
?.trim()
40+
?.takeUnless { it.isEmpty() || it.startsWith("\${") }
41+
}.getOrNull()
42+
}

src/main/kotlin/app/morphe/engine/MultiSourceLoader.kt

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import kotlinx.coroutines.awaitAll
1313
import kotlinx.coroutines.coroutineScope
1414
import kotlinx.coroutines.withContext
1515
import java.io.File
16+
import java.util.logging.Level
1617
import java.util.logging.Logger
1718

1819
/**
@@ -94,13 +95,36 @@ object MultiSourceLoader {
9495
sourceName = input.sourceName,
9596
patches = patches,
9697
)
97-
} catch (e: Exception) {
98-
logger.warning("MultiSourceLoader: failed to load '${input.sourceName}': ${e.message}")
98+
} catch (e: Throwable) {
99+
// Catch Throwable, not just Exception: a bundle built against a newer patcher
100+
// throws java.lang.Error (NoSuchMethodError / NoClassDefFoundError / LinkageError)
101+
// because it references patcher APIs missing here. As an Error it would escape a
102+
// catch(Exception), sink the whole load, and hang the UI on "loading" forever.
103+
// Isolating it per source keeps one bad bundle from taking the others down, and
104+
// lets us surface a clear "update Morphe" message (mirrors morphe-manager).
105+
//
106+
// Do NOT swallow the real failure: many Errors have a null .message (e.g.
107+
// ExceptionInInitializerError) with the useful text on .cause. We always store an
108+
// exception whose message walks the full cause chain, and log the full stack.
109+
val versionMsg = PatcherCompatibility.incompatibilityMessage(input.patchFile)
110+
val error: Throwable = if (versionMsg != null) {
111+
PatchBundleIncompatibleException(versionMsg)
112+
} else {
113+
PatchSourceLoadException(e.readableMessage(), e)
114+
}
115+
logger.log(
116+
Level.WARNING,
117+
"MultiSourceLoader: failed to load '${input.sourceName}': ${error.message}",
118+
e,
119+
)
120+
// Also stderr so IDE runs / headless CLI see the stack even if JUL is unconfigured.
121+
System.err.println("MultiSourceLoader: failed to load '${input.sourceName}': ${error.message}")
122+
e.printStackTrace(System.err)
99123
LoadedSource(
100124
sourceId = input.sourceId,
101125
sourceName = input.sourceName,
102126
patches = emptySet(),
103-
error = e,
127+
error = error,
104128
)
105129
} finally {
106130
tempCopy.deleteOnExit()
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Copyright 2026 Morphe.
3+
* https://github.com/MorpheApp/morphe-desktop
4+
*/
5+
6+
package app.morphe.engine
7+
8+
import java.io.File
9+
import java.util.zip.ZipInputStream
10+
import java.util.logging.Logger
11+
12+
/**
13+
* Detects when a patch bundle (.mpp) was built against a newer morphe-patcher than this
14+
* build ships. Such a bundle fails to load with a `java.lang.Error` (NoSuchMethodError,
15+
* NoClassDefFoundError, LinkageError) because it references patcher APIs that do not exist
16+
* here, which otherwise surfaces as a cryptic failure. This turns it into a clear "update
17+
* Morphe" message, mirroring how morphe-manager reads the bundle's `Patcher-Version`
18+
* manifest attribute and compares it to the patcher it ships.
19+
*
20+
* The "ships" version is read from the **patcher library on the classpath**
21+
* (`app/morphe/patcher/version.properties`), not from the desktop app's version catalog.
22+
* That way a composite/local `morphe-patcher` (or any resolved artifact) reports its own
23+
* version instead of a stale catalog pin in `libs.versions.toml`.
24+
*
25+
* Deliberately lenient: any doubt (missing attribute, unparseable version, unreadable file)
26+
* returns null so a valid bundle is never wrongly rejected.
27+
*/
28+
object PatcherCompatibility {
29+
30+
private val logger = Logger.getLogger(PatcherCompatibility::class.java.name)
31+
32+
/**
33+
* The morphe-patcher version on the classpath (see [MorpheComponents.patcherVersion]).
34+
*/
35+
val currentPatcherVersion: String? get() = MorpheComponents.patcherVersion
36+
37+
/**
38+
* The morphe-patcher version [mpp] requires, from its `Patcher-Version` manifest
39+
* attribute. Null when the bundle predates that attribute or can't be read.
40+
*/
41+
fun requiredPatcherVersion(mpp: File): String? = runCatching {
42+
mpp.inputStream().use { fis ->
43+
ZipInputStream(fis).use { zip ->
44+
var entry = zip.nextEntry
45+
while (entry != null) {
46+
if (entry.name == "META-INF/MANIFEST.MF") {
47+
return@runCatching manifestAttr(zip.bufferedReader().readText(), "Patcher-Version")
48+
}
49+
entry = zip.nextEntry
50+
}
51+
null
52+
}
53+
}
54+
}.getOrNull()
55+
56+
/**
57+
* A user-facing explanation when [mpp] needs a newer patcher than this build ships, or
58+
* null when compatible / unknown (never blocks on doubt).
59+
*/
60+
fun incompatibilityMessage(mpp: File): String? {
61+
val required = requiredPatcherVersion(mpp) ?: return null
62+
val current = currentPatcherVersion ?: return null
63+
if (!isNewer(required, current)) return null
64+
logger.info("Bundle ${mpp.name} needs patcher $required, this build ships $current")
65+
return "This patch bundle needs Morphe patcher $required, but this build ships $current. " +
66+
"Update Morphe to use it."
67+
}
68+
69+
private fun manifestAttr(manifest: String, key: String): String? =
70+
manifest.lineSequence()
71+
.firstOrNull { it.substringBefore(':', "").trim().equals(key, ignoreCase = true) }
72+
?.substringAfter(':')?.trim()
73+
?.takeUnless { it.isBlank() || it.equals("na", ignoreCase = true) }
74+
75+
/** True when version [a] is newer than [b]. Compares numeric components, ignores pre-release. */
76+
private fun isNewer(a: String, b: String): Boolean {
77+
val pa = numericParts(a)
78+
val pb = numericParts(b)
79+
for (i in 0 until maxOf(pa.size, pb.size)) {
80+
val x = pa.getOrElse(i) { 0 }
81+
val y = pb.getOrElse(i) { 0 }
82+
if (x != y) return x > y
83+
}
84+
return false
85+
}
86+
87+
private fun numericParts(v: String): List<Int> =
88+
v.substringBefore('-').split('.').map { it.trim().toIntOrNull() ?: 0 }
89+
}
90+
91+
/**
92+
* A patch bundle that could not be loaded because it needs a newer patcher than this build
93+
* ships. Its [message] is already user-facing (from [PatcherCompatibility.incompatibilityMessage]).
94+
*/
95+
class PatchBundleIncompatibleException(message: String) : Exception(message)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright 2026 Morphe.
3+
* https://github.com/MorpheApp/morphe-desktop
4+
*/
5+
6+
package app.morphe.engine
7+
8+
import java.io.PrintWriter
9+
import java.io.StringWriter
10+
11+
/**
12+
* Helpers for turning throwables into something a human (or log file) can actually use.
13+
*
14+
* Many JVM errors, notably [ExceptionInInitializerError] have a **null** [Throwable.message]
15+
* and bury the real explanation on [Throwable.cause]. Call sites that only read `.message`
16+
* then surface useless copy like "Failed to load".
17+
*/
18+
19+
/**
20+
* Quick description of this throwable and its cause chain.
21+
*
22+
* Example:
23+
* `ExceptionInInitializerError:
24+
* Cause by: UninitializedPropertyAccessException: lateinit property x has not been initialized`
25+
*/
26+
fun Throwable.readableMessage(): String {
27+
val chain = generateSequence(this) { it.cause }.toList()
28+
val parts = chain.map { t ->
29+
val msg = t.message?.trim()?.takeIf { it.isNotEmpty() }
30+
if (msg != null) "${t.javaClass.simpleName}: $msg" else t.javaClass.simpleName
31+
}
32+
// Drop pure type-only prefixes once we hit a frame that has a message, but keep the
33+
// whole chain so nested init/reflection failures stay diagnosable.
34+
return parts.joinToString("\nCaused by: ").ifBlank { javaClass.name }
35+
}
36+
37+
/** Full stack trace as a string (for file / stderr logging). */
38+
fun Throwable.stackTraceString(): String {
39+
val sw = StringWriter()
40+
printStackTrace(PrintWriter(sw))
41+
return sw.toString()
42+
}
43+
44+
/**
45+
* Load failure for one patch source. Always carries a non-blank [message] so UI layers
46+
* never fall back to generic "Failed to load". The original throwable is [cause].
47+
*/
48+
class PatchSourceLoadException(
49+
message: String,
50+
cause: Throwable? = null,
51+
) : Exception(message, cause)

src/main/kotlin/app/morphe/engine/util/ApkOutputNaming.kt

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,15 @@ object ApkOutputNaming {
6767
fun resolveAppDisplayName(apkFile: File): String? =
6868
ApkManifestReader.read(apkFile)?.applicationLabel?.takeIf { it.isNotBlank() }
6969

70+
/**
71+
* The app's versionName from its manifest (engine [ApkManifestReader]), or null when it
72+
* can't be read or is blank. The reliable source of the app version, unlike
73+
* [extractApkVersionFromFilename], which only works when the input file follows the
74+
* APKMirror naming convention.
75+
*/
76+
fun resolveAppVersion(apkFile: File): String? =
77+
ApkManifestReader.read(apkFile)?.versionName?.takeIf { it.isNotBlank() }
78+
7079
/**
7180
* Compute the unified output APK path. Layout:
7281
* `<base>/<appName>/<appName>-Morphe-{apkVer}-patches-{patchesVer}.apk`
@@ -94,16 +103,34 @@ object ApkOutputNaming {
94103
patchesFile: File? = null,
95104
baseOutputDir: File? = null,
96105
appDisplayName: String? = null,
106+
appVersion: String? = null,
97107
): File {
98108
val appFolderName = (appDisplayName ?: inputApk.nameWithoutExtension)
99109
.replace(" ", "-")
100110
val base = baseOutputDir
101111
?: inputApk.absoluteFile.parentFile
102112
?: File("").absoluteFile
103113
val outputDir = File(base, appFolderName).also { it.mkdirs() }
104-
val version = extractApkVersionFromFilename(inputApk.name) ?: "patched"
114+
// App version, most reliable first: a version the caller already resolved, then the
115+
// APK manifest's versionName, then the input filename (APKMirror convention), then a
116+
// constant. Reading it from the manifest is what keeps the output unique by app
117+
// version even when the input file was renamed (e.g. base.apk) — the filename-only
118+
// path fell back to "patched" and collided across versions.
119+
val version = sanitizeForFilename(
120+
appVersion?.takeIf { it.isUsableVersion() }
121+
?: resolveAppVersion(inputApk)
122+
?: extractApkVersionFromFilename(inputApk.name)
123+
?: "patched"
124+
)
105125
val patchesVersion = patchesFile?.name?.let { extractPatchesVersion(it) }
106126
val patchesSuffix = if (patchesVersion != null) "-patches-$patchesVersion" else ""
107127
return File(outputDir, "${appFolderName}-Morphe-${version}${patchesSuffix}.apk")
108128
}
129+
130+
private fun String.isUsableVersion(): Boolean =
131+
isNotBlank() && !equals("unknown", ignoreCase = true)
132+
133+
/** Keep a versionName filename-safe: some apps put spaces or symbols in versionName. */
134+
private fun sanitizeForFilename(v: String): String =
135+
v.trim().replace(Regex("""[^A-Za-z0-9.\-_]"""), "-").ifBlank { "patched" }
109136
}

src/main/kotlin/app/morphe/gui/di/AppModule.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ val appModule = module {
110110
params.get(),
111111
params.get(),
112112
params.get(),
113+
params.get(),
113114
)
114115
}
115116
factory { params ->

0 commit comments

Comments
 (0)