|
| 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) |
0 commit comments