Skip to content
Merged
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
## [1.9.1-dev.3](https://github.com/MorpheApp/morphe-cli/compare/v1.9.1-dev.2...v1.9.1-dev.3) (2026-06-02)


### Bug Fixes

* signing improvements ([#160](https://github.com/MorpheApp/morphe-cli/issues/160)) ([166f940](https://github.com/MorpheApp/morphe-cli/commit/166f9409b1cbe00af7663545c41548ead2c189c5))

## [1.9.1-dev.2](https://github.com/MorpheApp/morphe-cli/compare/v1.9.1-dev.1...v1.9.1-dev.2) (2026-05-31)


### Bug Fixes

* Update dependencies ([83d3969](https://github.com/MorpheApp/morphe-cli/commit/83d39692541ca81b7bb555dfd60a001fbb97b3f1))

## [1.9.1-dev.1](https://github.com/MorpheApp/morphe-cli/compare/v1.9.0...v1.9.1-dev.1) (2026-05-31)


### Bug Fixes

* Update to latest ARSCLib ([f62a179](https://github.com/MorpheApp/morphe-cli/commit/f62a1793601fcfc489f54c558265115530ab6b8d))

# [1.9.0](https://github.com/MorpheApp/morphe-cli/compare/v1.8.1...v1.9.0) (2026-05-29)


Expand Down
6 changes: 6 additions & 0 deletions docs/1_usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ You can combine the option `-e`, `-d`, `--ei`, `--di` and `--exclusive`. Here is
java -jar morphe-cli.jar patch --patches patches.mpp --exclusive -e "Patch name" --ei 123 input.apk
```

You can also use multiple MPP files. Enable/disable and other bundle specific arguments are applied to the last `--patches` argument:

```bash
java -jar morphe-cli.jar patch --patches patches-a.mpp -e "patch a" --patches patches-b.mpp -e "patch b" input.apk
```


> [!TIP]
> You can use the option `-i` to automatically install the patched app after patching.
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 💻 Documentation and guides of Morphe CLI

This documentation contains topics around [Morphe CLI](https://github.com/MorpheApp/morphes-cli).
This documentation contains topics around [Morphe CLI](https://github.com/MorpheApp/morphe-cli).

## 📖 Table of contents

Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
org.gradle.parallel = true
org.gradle.caching = true
kotlin.code.style = official
version = 1.9.0
version = 1.9.1-dev.3
8 changes: 4 additions & 4 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ kotlin = "2.3.21"

# CLI
picocli = "4.7.7"
arsclib = "d78a66bcee"
morphe-patcher = "1.5.1"
arsclib = "a28c6fb2a7"
morphe-patcher = "1.5.2-dev.2" # TODO: Change to stable release
morphe-library = "1.3.0"

# Compose Desktop
compose = "1.10.3"

# Networking
ktor = "3.4.3"
ktor = "3.5.0"

# DI
koin-bom = "4.2.1"
Expand All @@ -29,7 +29,7 @@ kotlinx-serialization = "1.11.0"
jna = "5.18.1"

# Testing
mockk = "1.14.9"
mockk = "1.14.11"

# Logging
slf4j = "2.0.18"
Expand Down
42 changes: 13 additions & 29 deletions src/main/kotlin/app/morphe/cli/command/PatchCommand.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,8 @@ import app.morphe.engine.isWindows
import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS
import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD
import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_SIGNER_NAME
import app.morphe.engine.PatchEngine.Config.Companion.LEGACY_KEYSTORE_ALIAS
import app.morphe.engine.PatchEngine.Config.Companion.LEGACY_KEYSTORE_PASSWORD
import app.morphe.engine.UpdateChecker
import app.morphe.engine.util.signWithLegacyFallback
import app.morphe.engine.patches.LoadedBundle
import app.morphe.engine.patches.PatchBundleLoader
import app.morphe.library.installation.installer.*
Expand Down Expand Up @@ -836,33 +835,18 @@ internal object PatchCommand : Callable<Int> {
patchingResult.addStepResult(
PatchingStep.SIGNING,
{
fun signApk(alias: String, password: String) {
ApkUtils.signApk(
patchedApkFile,
outputFilePath,
signer,
ApkUtils.KeyStoreDetails(
keystoreFilePath,
keyStorePassword,
alias,
password,
)
)
}
try {
signApk(keyStoreEntryAlias, keyStoreEntryPassword)
} catch (e: Exception){
// Retry with legacy keystore defaults.
if (keyStoreEntryAlias == DEFAULT_KEYSTORE_ALIAS &&
keyStoreEntryPassword == DEFAULT_KEYSTORE_PASSWORD &&
keystoreFilePath.exists()
) {
logger.info("Using legacy keystore credentials")

signApk(LEGACY_KEYSTORE_ALIAS, LEGACY_KEYSTORE_PASSWORD)
} else {
throw e
}
signWithLegacyFallback(
primary = ApkUtils.KeyStoreDetails(
keystoreFilePath,
keyStorePassword,
keyStoreEntryAlias,
keyStoreEntryPassword,
),
allowLegacyFallback = keyStoreEntryAlias == DEFAULT_KEYSTORE_ALIAS &&
keyStoreEntryPassword == DEFAULT_KEYSTORE_PASSWORD,
logger = logger,
) { details ->
ApkUtils.signApk(patchedApkFile, outputFilePath, signer, details)
}
}
)
Expand Down
26 changes: 6 additions & 20 deletions src/main/kotlin/app/morphe/engine/PatchEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

package app.morphe.engine

import app.morphe.engine.util.signWithLegacyFallback
import app.morphe.patcher.Patcher
import app.morphe.patcher.PatcherConfig
import app.morphe.patcher.apk.ApkMerger
Expand Down Expand Up @@ -244,33 +245,18 @@ object PatchEngine {
}

try {
fun signApk(details: ApkUtils.KeyStoreDetails) {
signWithLegacyFallback(
primary = keystoreDetails,
allowLegacyFallback = config.keystoreDetails == null,
logger = logger,
) { details ->
ApkUtils.signApk(
rebuiltApk,
tempOutput,
config.signerName,
details,
)
}

try {
signApk(keystoreDetails)
} catch (e: Exception) {
// Retry with legacy keystore defaults.
if (config.keystoreDetails == null && keystoreDetails.keyStore.exists()) {
logger.info("Using legacy keystore credentials")

val legacyKeystoreDetails = ApkUtils.KeyStoreDetails(
keystoreDetails.keyStore,
null,
Config.LEGACY_KEYSTORE_ALIAS,
Config.LEGACY_KEYSTORE_PASSWORD,
)
signApk(legacyKeystoreDetails)
} else {
throw e
}
}
stepResults.add(StepResult(PatchStep.SIGNING, true))
} catch (e: Exception) {
stepResults.add(StepResult(PatchStep.SIGNING, false, e.toString()))
Expand Down
54 changes: 54 additions & 0 deletions src/main/kotlin/app/morphe/engine/util/KeystoreSigner.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-cli
*/

package app.morphe.engine.util

import app.morphe.engine.PatchEngine
import app.morphe.patcher.apk.ApkUtils
import java.util.logging.Logger

/**
* Signs an APK with [primary] credentials, falling back to the legacy ("Morphe Key" / empty password) entry.
* The legacy retry only fires when [allowLegacyFallback] is true AND the keystore file already exists,
* i.e. the user is on default credentials and we're reading a pre-existing keystore that might predate the current alias.
* This preserves the exact condition both call sites (CLI + engine) used before.
*
* On double failure the PRIMARY exception is thrown (legacy attached as suppressed).
* The primary error is the meaningful one: the user expects the current Morphe key,
* so "no 'Morphe' entry" is more actionable than whatever the legacy retry hit.
* The old behavior threw the *legacy* failure, which surfaced confusing errors.
*
* [sign] performs the actual signing; callers wrap this call with their own progress / step-result reporting.
*/
fun signWithLegacyFallback(
primary: ApkUtils.KeyStoreDetails,
allowLegacyFallback: Boolean,
logger: Logger,
sign: (ApkUtils.KeyStoreDetails) -> Unit,
) {
try {
sign(primary)
} catch (primaryError: Exception) {
if (!allowLegacyFallback || !primary.keyStore.exists()) throw primaryError

// Never silently swallow the real cause. Always log it before the back-compat path.
logger.info(
"Default keystore credentials failed (${primaryError.message}). Retrying with legacy credentials"
)

val legacy = ApkUtils.KeyStoreDetails(
primary.keyStore,
primary.keyStorePassword,
PatchEngine.Config.LEGACY_KEYSTORE_ALIAS,
PatchEngine.Config.LEGACY_KEYSTORE_PASSWORD,
)
try {
sign(legacy)
} catch (legacyError: Exception) {
primaryError.addSuppressed(legacyError)
throw primaryError
}
}
}
Loading