diff --git a/build.gradle.kts b/build.gradle.kts
index 78ee0665..08264c53 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -77,9 +77,6 @@ dependencies {
api(libs.morphe.patcher)
implementation(libs.arsclib)
implementation(libs.morphe.library)
- implementation(libs.jadb) {
- exclude(group = "org.mockito")
- }
implementation(libs.picocli)
// -- Bootstrap (Code Generation) ---------------------------------------
diff --git a/docs/documentation.md b/docs/documentation.md
index 9ce42a94..4530714a 100644
--- a/docs/documentation.md
+++ b/docs/documentation.md
@@ -568,36 +568,36 @@ morphe patch [flag/s]
Here is a quick lookup for all the flags under this subcommand:
-| Flag | Description |
-|--------------------------------|--------------------------------------------------------------------------------|
-| `-p`, `--patches` | Paths to .mpp files or GitHub or GitLab repo URLs (repeatable, one per bundle) |
-| `--prerelease` | Fetch latest dev pre-release instead of stable release |
-| *(positional arg)* | APK file to patch |
-| `-o`, `--out` | Path to save the patched APK to |
-| `-e`, `--enable` | Enable a patch by name |
-| `--ei` | Enable a patch by index |
-| `-d`, `--disable` | Disable a patch by name |
-| `--di` | Disable a patch by index |
-| `-O`, `--options` | Set patch option values (e.g.`-Okey=value`) |
-| `--exclusive` | Disable all patches except explicitly enabled ones |
-| `-f`, `--force` | Skip APK version compatibility check |
-| `-i`, `--install` | Install to ADB device (optional serial) |
-| `--mount` | Install by mounting over existing app (requires root) |
-| `--keystore` | Path to keystore file for signing |
-| `--keystore-password` | Keystore password |
-| `--keystore-entry-alias` | Alias of the key pair in the keystore |
-| `--keystore-entry-password` | Password for the keystore entry |
-| `--signer` | Signer name in the APK signature |
-| `--unsigned` | Skip signing the final APK |
-| `-t`, `--temporary-files-path` | Path to store temp files |
-| `--disable-purge` | Keep this run's scratch files (deleted after patching by default) |
-| `--striplibs` | Architectures to keep, comma-separated (e.g.`arm64-v8a,x86`) |
-| `--bytecode-mode` | Bytecode mode:`FULL`, `STRIP_SAFE`, or `STRIP_FAST` |
-| `--verify-with-sdk` | Verify the patched DEX/APK using an Android SDK |
-| `--continue-on-error` | Continue patching if a patch fails |
-| `--options-file` | Path to options JSON file |
-| `--options-update` | Auto-update options JSON file after patching |
-| `-r`, `--result-file` | Path to save patching result JSON |
+| Flag | Description |
+|--------------------------------|------------------------------------------------------------------------------------|
+| `-p`, `--patches` | Paths to .mpp files, directories, or remote repos/PRs (repeatable, one per bundle) |
+| `--prerelease` | Fetch latest dev pre-release instead of stable release |
+| *(positional arg)* | APK file to patch |
+| `-o`, `--out` | Path to save the patched APK to |
+| `-e`, `--enable` | Enable a patch by name |
+| `--ei` | Enable a patch by index |
+| `-d`, `--disable` | Disable a patch by name |
+| `--di` | Disable a patch by index |
+| `-O`, `--options` | Set patch option values (e.g.`-Okey=value`) |
+| `--exclusive` | Disable all patches except explicitly enabled ones |
+| `-f`, `--force` | Skip APK version compatibility check |
+| `-i`, `--install` | Install to ADB device (optional serial) |
+| `--mount` | Install by mounting over existing app (requires root) |
+| `--keystore` | Path to keystore file for signing |
+| `--keystore-password` | Keystore password |
+| `--keystore-entry-alias` | Alias of the key pair in the keystore |
+| `--keystore-entry-password` | Password for the keystore entry |
+| `--signer` | Signer name in the APK signature |
+| `--unsigned` | Skip signing the final APK |
+| `-t`, `--temporary-files-path` | Path to store temp files |
+| `--disable-purge` | Keep this run's scratch files (deleted after patching by default) |
+| `--striplibs` | Architectures to keep, comma-separated (e.g.`arm64-v8a,x86`) |
+| `--bytecode-mode` | Bytecode mode:`FULL`, `STRIP_SAFE`, or `STRIP_FAST` |
+| `--verify-with-sdk` | Verify the patched DEX/APK using an Android SDK |
+| `--continue-on-error` | Continue patching if a patch fails |
+| `--options-file` | Path to options JSON file |
+| `--options-update` | Auto-update options JSON file after patching |
+| `-r`, `--result-file` | Path to save patching result JSON |
> [!NOTE]
> The examples used for each flag below only show the usage of that specific flag, but in practice, you'll almost always combine multiple flags together to customize your patching. Here's an example of a more complete command:
@@ -612,10 +612,12 @@ Required: Yes
Default: -
-This flag specifies the patch file(s) to apply to your APK. You can pass local `.mpp` file paths or a GitHub or GitLab repository URL, and you can repeat `-p` to combine several bundles in one run (see [Using multiple patch bundles](#using-multiple-bundles) below). When a URL is provided, Morphe automatically downloads the `.mpp` file from the latest release and caches it for future runs.
+This flag specifies the patch file(s) to apply to your APK. You can pass local `.mpp` file paths, directories containing `.mpp` files, or remote repository URLs (GitHub/GitLab repos or GitHub pull requests), and you can repeat `-p` to combine several bundles in one run (see [Using multiple patch bundles](#using-multiple-bundles) below). When a directory is provided, Morphe automatically detects and resolves the newest `.mpp` bundle inside it. When a remote URL is provided, Morphe downloads the bundle and caches it on disk for future runs.
```
+# Local file or directory:
java -jar morphe-desktop-*-all.jar patch --patches patches-*.mpp your_app.apk
+java -jar morphe-desktop-*-all.jar patch --patches /path/to/patches/ your_app.apk
```
You can also pass a GitHub or GitLab repo URL:
@@ -625,12 +627,21 @@ java -jar morphe-desktop-*-all.jar patch --patches https://github.com/MorpheApp/
java -jar morphe-desktop-*-all.jar patch --patches https://gitlab.com/MorpheApp/morphe-patches your_app.apk
```
-Or a specific release URL:
+Or a specific release tag or Pull Request URL:
```
java -jar morphe-desktop-*-all.jar patch --patches https://github.com/MorpheApp/morphe-patches/releases/tag/v1.0.0 your_app.apk
+java -jar morphe-desktop-*-all.jar patch --patches https://github.com/MorpheApp/morphe-patches/pull/123 your_app.apk
```
+> [!NOTE]
+> Downloading patch bundles from GitHub Pull Requests requires a GitHub Personal Access Token (PAT) with `public_repo` scope to access GitHub Actions artifacts.
+>
+> You can create a token at [github.com/settings/tokens](https://github.com/settings/tokens) (classic token with `public_repo` scope) and configure it via:
+>
+> - **Environment variable** (CLI): `export GITHUB_TOKEN="ghp_your_token"` (or `GH_TOKEN`)
+> - **Settings UI / Config**: In the GUI under **Settings → GitHub Personal Access Token**
+
Using multiple patch bundles
`-p` can be repeated to combine several `.mpp` bundles in a single run:
@@ -645,10 +656,10 @@ The **name-based** selection flags (`-e`, `-d`, `-O`) apply to the bundle they f
java -jar morphe-desktop-*-all.jar patch -p patches-a.mpp -e "A patch from bundle A" -p patches-b.mpp -d "A patch from bundle B" your_app.apk
```
-**Index-based** selections (`--ei`/`--di`) are the exception. Their numbers refer to the combined list across all supplied bundles, not a single one. Run `list-patches` with the same set of `-p` files to see the combined numbering:
+**Index-based** selections (`--ei`/`--di`) are the exception. Their numbers refer to the combined list across all supplied bundles, not a single one. Run `list-patches` with the same set of `--patches` files to see the combined numbering:
```
-java -jar morphe-desktop-*-all.jar list-patches -p patches-a.mpp -p patches-b.mpp
+java -jar morphe-desktop-*-all.jar list-patches --patches patches-a.mpp --patches patches-b.mpp
```
Then enable/disable by those combined indices. The index points to a patch no matter which bundle it came from, so its position relative to each `-p` doesn't matter:
@@ -663,7 +674,7 @@ Required: No
Default: `false`
-When using a GitHub or GitLab repo URL with `--patches`, fetch the latest dev pre-release instead of the latest stable release.
+When using a GitHub or GitLab repo URL with `--patches`, fetch the latest dev pre-release instead of the latest stable release. If no dev pre-release is found on the repository, Morphe falls back to the latest stable release automatically.
```
java -jar morphe-desktop-*-all.jar patch --patches https://github.com/MorpheApp/morphe-patches --prerelease your_app.apk
@@ -688,7 +699,7 @@ java -jar morphe-desktop-*-all.jar patch -p patches.mpp your_app.apk
Required: No
-Default: a subfolder named after the app, created next to the input APK – `/-Morphe--patches-.apk`
+Default: a subfolder named after the app, created next to the input APK – `/--patches-.apk`
Specify a custom output path for the patched APK.
@@ -773,9 +784,9 @@ java -jar morphe-desktop-*-all.jar patch -p patches.mpp -e "Patch name" -Okey1 y
```
> [!WARNING]
-> Option values are typed. Setting a value with the wrong type can cause the patch to fail. Use `list-patches --with-options` to see the expected types.
+> Option values are typed. Setting a value with the wrong type can cause the patch to fail. Use `list-patches --with-options` to inspect expected option types.
>
-> Common types: `string`, `true`/`false`, `123` (integer), `1.0` (double), `[item1,item2]` (list)
+> Common types: `Boolean` (`true`/`false`), `String` (`"text"`), `Int` (`123`), `Long` (`123L`), `Float` (`1.0` / `1.0f`), `List` (`[item1,item2]`), and `File` (`/path/to/file`).
#### `--exclusive`:
@@ -813,13 +824,14 @@ Default: -
Automatically install the patched APK to a connected ADB device after patching.
-```
-java -jar morphe-desktop-*-all.jar patch -p patches.mpp -i your_app.apk
-```
-
-If no serial is provided, it installs to the first connected device. You can optionally specify a device serial.
+If no serial is provided, Morphe installs to the first connected device. You can optionally specify a device serial.
```
+# Install to default connected device:
+java -jar morphe-desktop-*-all.jar patch -p patches.mpp your_app.apk -i
+java -jar morphe-desktop-*-all.jar patch -p patches.mpp -i --mount your_app.apk
+
+# Install to a specific device by serial:
java -jar morphe-desktop-*-all.jar patch -p patches.mpp -i SERIAL123 your_app.apk
```
@@ -945,7 +957,7 @@ Required: No
Default: `false`
-By default, this run's scratch files are deleted once patching finishes. Pass this flag to keep them instead — useful for debugging a failed patch. Does not affect cached patches, other sessions, or config.
+By default, this run's scratch files are deleted once patching finishes. Pass this flag to keep them instead - useful for debugging a failed patch. Does not affect cached patches, other sessions, or config.
```
java -jar morphe-desktop-*-all.jar patch -p patches.mpp --disable-purge your_app.apk
@@ -994,11 +1006,15 @@ Required: No
Default: - (verification is skipped)
-Verify the patched DEX and APK files using a local Android SDK after patching. Helpful for catching corrupt output before installing on a device. Pass a path to your SDK install directory, or pass the flag with no value to let Morphe auto-discover one.
+Verify the patched DEX and APK files using a local Android SDK after patching. Helpful for catching corrupt output before installing on a device. Pass a path to your SDK install directory, or pass the flag without a path to let Morphe auto-discover one.
```
+# Specify SDK path explicitly:
java -jar morphe-desktop-*-all.jar patch -p patches.mpp --verify-with-sdk /path/to/android-sdk your_app.apk
-java -jar morphe-desktop-*-all.jar patch -p patches.mpp --verify-with-sdk your_app.apk
+
+# Auto-discover SDK (place the flag after the APK or before another flag):
+java -jar morphe-desktop-*-all.jar patch -p patches.mpp your_app.apk --verify-with-sdk
+java -jar morphe-desktop-*-all.jar patch -p patches.mpp --verify-with-sdk --unsigned your_app.apk
```
> [!NOTE]
@@ -1040,6 +1056,9 @@ java -jar morphe-desktop-*-all.jar patch -p patches.mpp --options-file options.j
> [!NOTE]
> If the file you specify doesn't exist yet, Morphe will automatically generate one with default values at that path and use it for the current patch. This means you can skip `options-create` entirely - just pass a path to a non-existent file and Morphe will create it for you.
+> [!NOTE]
+> Command-line flags (`-e`, `-d`, `--ei`, `--di`, `-O`) take precedence over the options and selections defined in `--options-file`.
+
> [!TIP]
> The options file is great for repeatable patching. Generate it once (either with `options-create` or by letting `--options-file` auto-generate it), tweak it, and reuse it every time you patch.
@@ -1088,7 +1107,7 @@ Here is a quick lookup for all the flags under this subcommand:
| Flag | Description |
|----------------------------------|--------------------------------------------------------|
-| `--patches` | Paths to .mpp files or GitHub or GitLab repo URLs |
+| `--patches` | Paths to .mpp files, directories, or remote repos/PRs |
| `--prerelease` | Fetch latest dev pre-release instead of stable release |
| `--out` | Write patch list to a file instead of stdout |
| `-d`, `--with-descriptions` | Show patch descriptions |
@@ -1106,10 +1125,11 @@ Required: Yes
Default: -
-One or more paths to .mpp patch files or GitHub or GitLab repository URLs to list patches from. When a URL is provided, Morphe downloads the .mpp file and caches it for future runs.
+One or more paths to `.mpp` patch files, directories containing `.mpp` files, or remote repository URLs (GitHub/GitLab repos or GitHub pull requests) to list patches from. When a directory is provided, Morphe automatically detects and resolves the newest `.mpp` bundle inside it. When a remote URL is provided, Morphe downloads the bundle and caches it on disk for future runs.
```
java -jar morphe-desktop-*-all.jar list-patches --patches patches.mpp
+java -jar morphe-desktop-*-all.jar list-patches --patches /path/to/patches/
java -jar morphe-desktop-*-all.jar list-patches --patches https://github.com/MorpheApp/morphe-patches
```
@@ -1119,7 +1139,7 @@ Required: No
Default: `false`
-When using a GitHub or GitLab repo URL with `--patches`, fetch the latest dev pre-release instead of the latest stable release.
+When using a GitHub or GitLab repo URL with `--patches`, fetch the latest dev pre-release instead of the latest stable release (automatically falling back to the other channel if the preferred channel is not available).
```
java -jar morphe-desktop-*-all.jar list-patches --patches https://github.com/MorpheApp/morphe-patches --prerelease
@@ -1179,7 +1199,7 @@ Required: No
Default: `false`
-Show the configurable options for each patch, including their keys, types, default values, and possible values.
+Show the configurable options for each patch, including their keys, types (`Boolean`, `String`, `Int`, `Long`, `Float`, `List`, `File`), default values, and possible values.
```
java -jar morphe-desktop-*-all.jar list-patches --patches patches.mpp --with-options
@@ -1248,7 +1268,7 @@ Here is a quick lookup for all the flags under this subcommand:
| Flag | Description |
|--------------------------------|--------------------------------------------------------|
-| `--patches` | Paths to .mpp files or GitHub or GitLab repo URLs |
+| `--patches` | Paths to .mpp files, directories, or remote repos/PRs |
| `--prerelease` | Fetch latest dev pre-release instead of stable release |
| `-f`, `--filter-package-names` | Filter by package names |
| `-u`, `--count-unused-patches` | Include unused patches in the version count |
@@ -1260,10 +1280,11 @@ Required: Yes
Default: -
-One or more paths to .mpp patch files or GitHub or GitLab repository URLs. When a URL is provided, Morphe downloads the .mpp file and caches it for future runs.
+One or more paths to `.mpp` patch files, directories containing `.mpp` files, or remote repository URLs (GitHub/GitLab repos or GitHub pull requests). When a directory is provided, Morphe automatically detects and resolves the newest `.mpp` bundle inside it. When a URL is provided, Morphe downloads the bundle and caches it on disk for future runs.
```
java -jar morphe-desktop-*-all.jar list-versions --patches patches.mpp
+java -jar morphe-desktop-*-all.jar list-versions --patches /path/to/patches/
java -jar morphe-desktop-*-all.jar list-versions --patches https://github.com/MorpheApp/morphe-patches
```
@@ -1273,7 +1294,7 @@ Required: No
Default: `false`
-When using a GitHub or GitLab repo URL with `--patches`, fetch the latest dev pre-release instead of the latest stable release.
+When using a GitHub or GitLab repo URL with `--patches`, fetch the latest dev pre-release instead of the latest stable release (automatically falling back to the other channel if the preferred channel is not available).
```
java -jar morphe-desktop-*-all.jar list-versions --patches https://github.com/MorpheApp/morphe-patches --prerelease
@@ -1285,10 +1306,11 @@ Required: No
Default: -
-Only show versions for the specified package names. Can be used to check versions for a specific app.
+Only show versions for the specified package names. Can be used to check versions for a specific app, or specified multiple times to filter for multiple apps.
```
java -jar morphe-desktop-*-all.jar list-versions --patches patches.mpp -f com.google.android.youtube
+java -jar morphe-desktop-*-all.jar list-versions --patches patches.mpp -f com.google.android.youtube -f com.google.android.apps.youtube.music
```
#### `-u`, `--count-unused-patches`:
@@ -1330,7 +1352,7 @@ Here is a quick lookup for all the flags under this subcommand:
| Flag | Description |
|--------------------------------|--------------------------------------------------------|
-| `-p`, `--patches` | Paths to .mpp files or GitHub or GitLab repo URLs |
+| `--patches` | Paths to .mpp files, directories, or remote repos/PRs |
| `--prerelease` | Fetch latest dev pre-release instead of stable release |
| `-o`, `--out` | Path to the output JSON file |
| `-f`, `--filter-package-name` | Filter patches by package name |
@@ -1341,10 +1363,11 @@ Required: Yes
Default: -
-One or more paths to .mpp patch files or GitHub or GitLab repository URLs to generate options from. When a URL is provided, Morphe downloads the .mpp file and caches it for future runs.
+One or more paths to `.mpp` patch files, directories containing `.mpp` files, or remote repository URLs (GitHub/GitLab repos or GitHub pull requests) to generate options from. When a directory is provided, Morphe automatically detects and resolves the newest `.mpp` bundle inside it. Multiple `-p` flags can be supplied to include options from multiple bundles in a single file. When a URL is provided, Morphe downloads the bundle and caches it on disk for future runs.
```
java -jar morphe-desktop-*-all.jar options-create -p patches.mpp -o options.json
+java -jar morphe-desktop-*-all.jar options-create -p /path/to/patches/ -o options.json
java -jar morphe-desktop-*-all.jar options-create -p https://github.com/MorpheApp/morphe-patches -o options.json
```
@@ -1354,7 +1377,7 @@ Required: No
Default: `false`
-When using a GitHub or GitLab repo URL with `--patches`, fetch the latest dev pre-release instead of the latest stable release.
+When using a GitHub or GitLab repo URL with `--patches`, fetch the latest dev pre-release instead of the latest stable release (automatically falling back to the other channel if the preferred channel is not available).
```
java -jar morphe-desktop-*-all.jar options-create -p https://github.com/MorpheApp/morphe-patches --prerelease -o options.json
@@ -1366,7 +1389,7 @@ Required: Yes
Default: -
-Path to the output JSON file. If the file already exists, Morphe will merge the current patches into it - preserving your existing settings, adding new patches, and removing patches that no longer exist.
+Path to the output JSON file. If the file already exists, Morphe merges the current patches into it - preserving your existing settings, adding new patches, and removing patches that no longer exist. If the existing file is corrupted or contains invalid JSON, Morphe warns and regenerates a fresh options file.
```
java -jar morphe-desktop-*-all.jar options-create -p patches.mpp -o options.json
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e65ef7ad..428d64d3 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -8,7 +8,6 @@ picocli = "4.7.7"
arsclib = "9b742c412d"
morphe-patcher = "1.15.1"
morphe-library = "1.4.0"
-jadb = "1.2.3"
# Compose Desktop
compose = "1.12.1"
@@ -53,7 +52,6 @@ junit-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref
picocli = { module = "info.picocli:picocli", version.ref = "picocli" }
morphe-patcher = { module = "app.morphe:morphe-patcher", version.ref = "morphe-patcher" }
morphe-library = { module = "app.morphe:morphe-library-jvm", version.ref = "morphe-library" }
-jadb = { module = "app.morphe:jadb", version.ref = "jadb" }
# Ktor Client
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
diff --git a/src/main/kotlin/app/morphe/MorpheLauncher.kt b/src/main/kotlin/app/morphe/MorpheLauncher.kt
index f1ee4eb1..7758e947 100644
--- a/src/main/kotlin/app/morphe/MorpheLauncher.kt
+++ b/src/main/kotlin/app/morphe/MorpheLauncher.kt
@@ -7,10 +7,9 @@ package app.morphe
import app.morphe.desktop.command.MainCommand
import app.morphe.engine.BootstrapDownloader
-import app.morphe.library.logging.Logger
+import app.morphe.engine.util.Logger
import java.awt.GraphicsEnvironment
import java.io.File
-import java.util.logging.Logger.getLogger as JVLogger
import kotlin.system.exitProcess
import picocli.CommandLine
@@ -74,11 +73,8 @@ fun main(args: Array) {
}
}
} else {
- Logger.setDefault()
-
- if (GraphicsEnvironment.isHeadless() && args.isEmpty()){
- val logger = JVLogger("app.morphe.MorpheLauncher")
- logger.info("Running in Headless environment, falling back to CLI mode.")
+ if (GraphicsEnvironment.isHeadless() && args.isEmpty()) {
+ Logger.info("Running in Headless environment, falling back to CLI mode.")
}
CommandLine(MainCommand)
diff --git a/src/main/kotlin/app/morphe/desktop/command/CliHttpClient.kt b/src/main/kotlin/app/morphe/desktop/command/CliHttpClient.kt
deleted file mode 100644
index 2d92c538..00000000
--- a/src/main/kotlin/app/morphe/desktop/command/CliHttpClient.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * Copyright 2026 Morphe.
- * https://github.com/MorpheApp/morphe-desktop
- */
-
-package app.morphe.desktop.command
-
-import io.ktor.client.HttpClient
-import io.ktor.client.engine.okhttp.OkHttp
-import io.ktor.client.plugins.HttpTimeout
-import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
-import io.ktor.serialization.kotlinx.json.json
-import kotlinx.serialization.json.Json
-import okhttp3.Dns
-import okhttp3.Protocol
-import java.net.Inet4Address
-
-/**
- * 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(OkHttp) {
- engine {
- config {
- // Prefer IPv4 so an advertised but unroutable IPv6 address does not
- // burn the connect timeout before the working A record is tried.
- dns { hostname ->
- val all = Dns.SYSTEM.lookup(hostname)
- all.filterIsInstance().ifEmpty { all }
- }
- // Pin HTTP/1.1 to avoid intermittent HTTP/2 stream resets against
- // GitHub-backed download endpoints.
- protocols(listOf(Protocol.HTTP_1_1))
- followRedirects(true)
- followSslRedirects(true)
- }
- }
- install(ContentNegotiation) {
- json(Json { ignoreUnknownKeys = true })
- }
- // Idle/socket timeouts (not a total cap) so large .mpp downloads don't fail
- // for being big. Only genuine stalls or issues fail.
- install(HttpTimeout) {
- connectTimeoutMillis = 30_000
- socketTimeoutMillis = 60_000
- }
- // Retry/429 handling lives in HttpService (single layer), not a client plugin.
- }
- }
-}
diff --git a/src/main/kotlin/app/morphe/desktop/command/CommandUtils.kt b/src/main/kotlin/app/morphe/desktop/command/CommandUtils.kt
index c97e1040..e6e3f046 100644
--- a/src/main/kotlin/app/morphe/desktop/command/CommandUtils.kt
+++ b/src/main/kotlin/app/morphe/desktop/command/CommandUtils.kt
@@ -5,119 +5,16 @@
package app.morphe.desktop.command
+import app.morphe.engine.patches.RemotePatchSourceFactory
+import java.io.File
import picocli.CommandLine
import picocli.CommandLine.Model.CommandSpec
-import java.io.File
-internal fun checkFileExistsOrIsUrl(files: Set, spec: CommandSpec) : Set {
+internal fun checkFileExistsOrIsUrl(files: Set, spec: CommandSpec): Set {
files.firstOrNull {
- !it.exists() && !it.invariantSeparatorsPath.let { url ->
- url.startsWith("http:/") || url.startsWith("https:/")
- }
+ !it.exists() && RemotePatchSourceFactory.parse(it.invariantSeparatorsPath) == null
}?.let {
throw CommandLine.ParameterException(spec.commandLine(), "${it.name} can not be found")
}
return files
}
-
-class OptionKeyConverter : CommandLine.ITypeConverter {
- override fun convert(value: String): String = value
-}
-
-class OptionValueConverter : CommandLine.ITypeConverter {
- override fun convert(value: String?): Any? {
- value ?: return null
-
- return when {
- value.startsWith("[") && value.endsWith("]") -> {
- val innerValue = value.substring(1, value.length - 1)
-
- buildList {
- var nestLevel = 0
- var insideQuote = false
- var escaped = false
-
- val item = buildString {
- for (char in innerValue) {
- when (char) {
- '\\' -> {
- if (escaped || nestLevel != 0) {
- append(char)
- }
-
- escaped = !escaped
- }
-
- '"', '\'' -> {
- if (!escaped) {
- insideQuote = !insideQuote
- } else {
- escaped = false
- }
-
- append(char)
- }
-
- '[' -> {
- if (!insideQuote) {
- nestLevel++
- }
-
- append(char)
- }
-
- ']' -> {
- if (!insideQuote) {
- nestLevel--
-
- if (nestLevel == -1) {
- return value
- }
- }
-
- append(char)
- }
-
- ',' -> if (nestLevel == 0) {
- if (insideQuote) {
- append(char)
- } else {
- add(convert(toString()))
- setLength(0)
- }
- } else {
- append(char)
- }
-
- else -> append(char)
- }
- }
- }
-
- if (item.isNotEmpty()) {
- add(convert(item))
- }
- }
- }
-
- value.startsWith("\"") && value.endsWith("\"") -> value.substring(1, value.length - 1)
- value.startsWith("'") && value.endsWith("'") -> value.substring(1, value.length - 1)
- value.endsWith("f") -> value.dropLast(1).toFloat()
- value.endsWith("L") -> value.dropLast(1).toLong()
- value.equals("true", ignoreCase = true) -> true
- value.equals("false", ignoreCase = true) -> false
- value.toIntOrNull() != null -> value.toInt()
- value.toLongOrNull() != null -> value.toLong()
- value.toDoubleOrNull() != null -> value.toDouble()
- value.toFloatOrNull() != null -> value.toFloat()
- value == "null" -> null
- value == "int[]" -> emptyList()
- value == "long[]" -> emptyList()
- value == "double[]" -> emptyList()
- value == "float[]" -> emptyList()
- value == "boolean[]" -> emptyList()
- value == "string[]" -> emptyList()
- else -> value
- }
- }
-}
diff --git a/src/main/kotlin/app/morphe/desktop/command/ListCompatibleVersions.kt b/src/main/kotlin/app/morphe/desktop/command/ListCompatibleVersions.kt
index 32427997..b354461d 100644
--- a/src/main/kotlin/app/morphe/desktop/command/ListCompatibleVersions.kt
+++ b/src/main/kotlin/app/morphe/desktop/command/ListCompatibleVersions.kt
@@ -5,19 +5,19 @@
package app.morphe.desktop.command
-import app.morphe.desktop.command.CliHttpClient
-import app.morphe.engine.VersionMap
-import app.morphe.engine.mostCommonCompatibleVersions
-import app.morphe.engine.versionCodesFor
-import app.morphe.patcher.patch.loadPatchesFromJar
+import app.morphe.engine.patches.PatchBundleLoader
+import app.morphe.engine.patches.PatchResolver
+import app.morphe.engine.patches.SupportedAppCatalog
+import app.morphe.engine.patches.VersionMap
+import app.morphe.engine.patches.versionCodesFor
+import app.morphe.engine.util.Logger
+import java.io.File
import picocli.CommandLine
import picocli.CommandLine.Command
import picocli.CommandLine.Help.Visibility.ALWAYS
import picocli.CommandLine.Model.CommandSpec
import picocli.CommandLine.Option
import picocli.CommandLine.Spec
-import java.io.File
-import java.util.logging.Logger
@Command(
name = "list-versions",
@@ -27,7 +27,6 @@ import java.util.logging.Logger
],
)
internal class ListCompatibleVersions : Runnable {
- private val logger = Logger.getLogger(this::class.java.name)
@Option(
names = ["--patches"],
@@ -73,10 +72,9 @@ internal class ListCompatibleVersions : Runnable {
override fun run() {
try {
- patchesFiles = PatchFileResolver.resolve(
+ patchesFiles = PatchResolver.resolveCliFiles(
patchesFiles,
prerelease,
- CliHttpClient.instance
)
} catch (e: IllegalArgumentException) {
throw CommandLine.ParameterException(
@@ -85,7 +83,7 @@ internal class ListCompatibleVersions : Runnable {
)
}
- val patches = loadPatchesFromJar(patchesFiles)
+ val patches = PatchBundleLoader.loadFlat(patchesFiles)
fun getVersionCodesString(pkgName: String, versionName: String): String {
return patches.versionCodesFor(pkgName, versionName)?.let { codes ->
@@ -112,10 +110,17 @@ internal class ListCompatibleVersions : Runnable {
appendLine(versions.buildVersionsString(name).prependIndent("\t"))
}
- patches.mostCommonCompatibleVersions(
- packageNames,
- countUnusedPatches,
- includeExperimental,
- ).entries.joinToString("\n", transform = ::buildString).let(logger::info)
+ val compatibilityMap = SupportedAppCatalog.getCompatibilityMap(
+ packageNames = packageNames,
+ countUnusedPatches = countUnusedPatches,
+ includeExperimental = includeExperimental,
+ patches = patches,
+ )
+
+ if (compatibilityMap.isEmpty()) {
+ Logger.warn("No compatible versions found in: ${patchesFiles.joinToString(", ") { it.path }}")
+ } else {
+ Logger.info(compatibilityMap.entries.joinToString("\n", transform = ::buildString))
+ }
}
}
diff --git a/src/main/kotlin/app/morphe/desktop/command/ListPatchesCommand.kt b/src/main/kotlin/app/morphe/desktop/command/ListPatchesCommand.kt
index 97a94293..53cdd4d7 100644
--- a/src/main/kotlin/app/morphe/desktop/command/ListPatchesCommand.kt
+++ b/src/main/kotlin/app/morphe/desktop/command/ListPatchesCommand.kt
@@ -8,28 +8,28 @@
package app.morphe.desktop.command
-import app.morphe.desktop.command.CliHttpClient
-import app.morphe.engine.compatibleVersionsForDisplay
-import app.morphe.engine.isCompatibleWith
-import app.morphe.engine.versionCodesFor
+import app.morphe.engine.options.toPatchOption
+import app.morphe.engine.patches.compatibleVersionsForDisplay
+import app.morphe.engine.patches.isCompatibleWith
+import app.morphe.engine.patches.versionCodesFor
+import app.morphe.engine.patches.PatchResolver
+import app.morphe.engine.patches.PatchBundleLoader
+import app.morphe.engine.util.Logger
+import app.morphe.patcher.patch.Option as PatchOption
import app.morphe.patcher.patch.Patch
-import app.morphe.patcher.patch.loadPatchesFromJar
+import java.io.File
import picocli.CommandLine
import picocli.CommandLine.Command
import picocli.CommandLine.Option
import picocli.CommandLine.Spec
import picocli.CommandLine.Model.CommandSpec
import picocli.CommandLine.Help.Visibility.ALWAYS
-import java.io.File
-import java.util.logging.Logger
-import app.morphe.patcher.patch.Option as PatchOption
@Command(
name = "list-patches",
description = ["List patches from supplied MPP files."],
)
internal object ListPatchesCommand : Runnable {
- private val logger = Logger.getLogger(this::class.java.name)
// Patches is now flag based rather than position based
@Option(
@@ -131,7 +131,7 @@ internal object ListPatchesCommand : Runnable {
append(values.map { "${it.value} (${it.key})" }.joinToString("\n").prependIndent("\t"))
}
- append("\nType: $type")
+ append("\nType: ${toPatchOption().type.displayName}")
}
fun getVersionCodesString(patch: Patch<*>, pkgName: String, versionName: String): String {
@@ -193,19 +193,10 @@ internal object ListPatchesCommand : Runnable {
}
}
- fun Patch<*>.filterCompatiblePackages(name: String) =
- isCompatibleWith(
- packageName = name,
- includeExperimental = includeExperimental,
- includeUniversalPatches = withUniversalPatches,
- )
-
-
try {
- patchesFiles = PatchFileResolver.resolve(
+ patchesFiles = PatchResolver.resolveCliFiles(
patchesFiles,
prerelease,
- CliHttpClient.instance
)
} catch (e: IllegalArgumentException) {
throw CommandLine.ParameterException(
@@ -214,12 +205,14 @@ internal object ListPatchesCommand : Runnable {
)
}
- val patches = loadPatchesFromJar(patchesFiles).withIndex().toList()
+ val patches = PatchBundleLoader.loadFlat(patchesFiles).withIndex().toList()
- val filtered = packageName?.let {
+ val filtered = packageName?.let { name ->
patches.filter { (_, patch) ->
- patch.filterCompatiblePackages(
- it
+ patch.isCompatibleWith(
+ packageName = name,
+ includeExperimental = includeExperimental,
+ includeUniversalPatches = withUniversalPatches,
)
}
} ?: patches
@@ -229,12 +222,12 @@ internal object ListPatchesCommand : Runnable {
val finalOutput = filtered.joinToString("\n\n") {it.buildString()}
if (filtered.isEmpty()) {
- logger.warning("No compatible patches found in: $patchesFiles")
+ Logger.warn("No compatible patches found in: ${patchesFiles.joinToString(", ") { it.path }}")
} else {
if (outputFile == null) {
- logger.info(finalOutput)
+ Logger.info(finalOutput)
} else {
- logger.info("Created new output file at ${outputFile!!.path}")
+ Logger.info("Created new output file at ${outputFile!!.path}")
outputFile!!.writeText(finalOutput)
}
}
diff --git a/src/main/kotlin/app/morphe/desktop/command/MainCommand.kt b/src/main/kotlin/app/morphe/desktop/command/MainCommand.kt
index ef3c7896..7f376bf7 100644
--- a/src/main/kotlin/app/morphe/desktop/command/MainCommand.kt
+++ b/src/main/kotlin/app/morphe/desktop/command/MainCommand.kt
@@ -6,16 +6,16 @@
package app.morphe.desktop.command
import app.morphe.desktop.command.utility.UtilityCommand
-import app.morphe.library.logging.Logger
+import app.morphe.engine.workspace.WorkspaceManager
+import java.util.Properties
+import kotlin.system.exitProcess
import org.jetbrains.annotations.VisibleForTesting
import picocli.CommandLine
import picocli.CommandLine.Command
import picocli.CommandLine.IVersionProvider
-import java.util.Properties
-import kotlin.system.exitProcess
fun desktopMain(args: Array) {
- Logger.setDefault()
+ runCatching { WorkspaceManager.reapStaleWorkspaces() }
val exitCode = CommandLine(MainCommand).execute(*args)
exitProcess(exitCode)
}
diff --git a/src/main/kotlin/app/morphe/desktop/command/OptionsCommand.kt b/src/main/kotlin/app/morphe/desktop/command/OptionsCommand.kt
index 03d1abc5..ec8283e4 100644
--- a/src/main/kotlin/app/morphe/desktop/command/OptionsCommand.kt
+++ b/src/main/kotlin/app/morphe/desktop/command/OptionsCommand.kt
@@ -5,24 +5,25 @@
package app.morphe.desktop.command
-import app.morphe.desktop.command.model.PatchBundle
-import app.morphe.engine.isCompatibleWith
+import app.morphe.engine.options.PatchBundle
+import app.morphe.engine.options.findMatchingBundle
+import app.morphe.engine.options.mergeWithBundle
+import app.morphe.engine.options.readPatchBundles
+import app.morphe.engine.options.withUpdatedBundle
+import app.morphe.engine.options.writePatchBundles
import app.morphe.engine.patches.LoadedBundle
import app.morphe.engine.patches.PatchBundleLoader
-import app.morphe.desktop.command.model.findMatchingBundle
-import app.morphe.desktop.command.model.mergeWithBundle
-import app.morphe.desktop.command.model.withUpdatedBundle
-import app.morphe.desktop.command.CliHttpClient
-import kotlinx.serialization.json.Json
+import app.morphe.engine.patches.PatchResolver
+import app.morphe.engine.patches.isCompatibleWith
+import app.morphe.engine.util.Logger
+import java.io.File
+import java.util.concurrent.Callable
import picocli.CommandLine
import picocli.CommandLine.Command
import picocli.CommandLine.Option
import picocli.CommandLine.Help.Visibility.ALWAYS
import picocli.CommandLine.Model.CommandSpec
import picocli.CommandLine.Spec
-import java.io.File
-import java.util.concurrent.Callable
-import java.util.logging.Logger
@Command(
name = "options-create",
@@ -33,8 +34,6 @@ internal object OptionsCommand : Callable {
private const val EXIT_CODE_SUCCESS = 0
private const val EXIT_CODE_ERROR = 1
- private val logger = Logger.getLogger(this::class.java.name)
-
@Spec
private lateinit var spec: CommandSpec
@@ -70,19 +69,12 @@ internal object OptionsCommand : Callable {
)
private var packageName: String? = null
- private val json = Json { prettyPrint = true }
-
override fun call(): Int {
try {
- // Since we could have many URLs, we resolve each of them separately
- patchesFiles = patchesFiles.map { file ->
- val resolved = PatchFileResolver.resolve(
- setOf(file),
- prerelease,
- CliHttpClient.instance
- )
- resolved.single()
- }.toSet()
+ patchesFiles = PatchResolver.resolveCliFiles(
+ patchesFiles,
+ prerelease,
+ )
} catch (e: IllegalArgumentException) {
throw CommandLine.ParameterException(
spec.commandLine(),
@@ -91,7 +83,7 @@ internal object OptionsCommand : Callable {
}
return try {
- logger.info("Loading patches...")
+ Logger.info("Loading patches...")
// Load each bundle separately so we produce one JSON entry per .mpp
// matches the shape PatchCommand expects when reading --options-file.
@@ -101,10 +93,10 @@ internal object OptionsCommand : Callable {
val existingBundles: List = if (outputFile.exists())
{
try {
- Json.decodeFromString>(outputFile.readText())
- } catch (e: Exception) {
- logger.warning(
- "Could not parse existing file, creating fresh: ${e.message}"
+ readPatchBundles(outputFile)
+ } catch (_: Exception) {
+ Logger.warn(
+ "Could not parse existing '${outputFile.name}' (invalid JSON), creating fresh."
)
emptyList()
}
@@ -138,24 +130,23 @@ internal object OptionsCommand : Callable {
val removed = existingNames - newNames
val kept = newNames.intersect(existingNames)
- logger.info(
+ Logger.info(
"Updated bundle for ${lb.sourceFile.name}: ${kept.size} preserved, ${added.size} added, ${removed.size} removed"
)
} else {
- logger.info(
+ Logger.info(
"Created new bundle for ${lb.sourceFile.name} with ${updatedBundle.patches.size} patches"
)
}
}
- outputFile.absoluteFile.parentFile?.mkdirs()
- outputFile.writeText(json.encodeToString(updatedBundles))
+ writePatchBundles(outputFile, updatedBundles)
- logger.info("Options file saved to ${outputFile.path}")
+ Logger.info("Options file saved to ${outputFile.path}")
EXIT_CODE_SUCCESS
} catch (e: Exception) {
- logger.severe("Failed to export options: ${e.message}")
+ Logger.error("Failed to export options: ${e.message}")
EXIT_CODE_ERROR
}
}
diff --git a/src/main/kotlin/app/morphe/desktop/command/PatchCommand.kt b/src/main/kotlin/app/morphe/desktop/command/PatchCommand.kt
index 39e72f56..28bb55e3 100644
--- a/src/main/kotlin/app/morphe/desktop/command/PatchCommand.kt
+++ b/src/main/kotlin/app/morphe/desktop/command/PatchCommand.kt
@@ -8,33 +8,25 @@
package app.morphe.desktop.command
-import app.morphe.desktop.command.model.*
-import app.morphe.desktop.command.CliHttpClient
-import app.morphe.engine.isCompatibleWith
-import app.morphe.engine.compatibleVersionsForDisplay
-import app.morphe.engine.MorpheData
-import app.morphe.engine.supportedVersionsFor
+import app.morphe.engine.PatchEngine
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.UpdateChecker
-import app.morphe.engine.util.deleteRecursivelyInParallel
-import app.morphe.engine.util.signWithLegacyFallback
-import app.morphe.engine.patches.LoadedBundle
-import app.morphe.engine.patches.PatchBundleLoader
-import app.morphe.library.installation.installer.*
-import app.morphe.patcher.Patcher
-import app.morphe.patcher.PatcherConfig
-import app.morphe.patcher.apk.ApkMerger
+import app.morphe.engine.apk.ApkOutputNaming
+import app.morphe.engine.config.EngineConfigRepository
+import app.morphe.engine.options.parseCliOptionValue
+import app.morphe.engine.patches.PatchResolver
+import app.morphe.engine.util.AdbErrorCode
+import app.morphe.engine.util.AdbException
+import app.morphe.engine.util.AdbManager
+import app.morphe.engine.util.KeystoreService
+import app.morphe.engine.util.Logger
import app.morphe.patcher.apk.ApkUtils
-import app.morphe.patcher.apk.ApkUtils.applyTo
import app.morphe.patcher.dex.BytecodeMode
-import app.morphe.patcher.dex.NoOpDexVerifier
-import app.morphe.patcher.dex.SdkDexVerifier
-import app.morphe.patcher.logging.toMorpheLogger
-import app.morphe.patcher.patch.Patch
-import app.morphe.patcher.patch.setOptions
import app.morphe.patcher.resource.CpuArchitecture
+import java.io.File
+import java.util.concurrent.Callable
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
@@ -45,12 +37,6 @@ import picocli.CommandLine.ArgGroup
import picocli.CommandLine.Help.Visibility.ALWAYS
import picocli.CommandLine.Model.CommandSpec
import picocli.CommandLine.Spec
-import java.io.File
-import java.io.PrintWriter
-import java.io.StringWriter
-import java.util.concurrent.Callable
-import java.util.logging.Logger
-import kotlin.collections.plus
@OptIn(ExperimentalSerializationApi::class)
@VisibleForTesting
@@ -63,8 +49,6 @@ internal object PatchCommand : Callable {
private const val EXIT_CODE_SUCCESS = 0
private const val EXIT_CODE_ERROR = 1
- private val logger = Logger.getLogger(this::class.java.name)
-
@Spec
private lateinit var spec: CommandSpec
@@ -113,7 +97,7 @@ internal object PatchCommand : Callable {
mapFallbackValue = CommandLine.Option.NULL_VALUE,
converter = [OptionKeyConverter::class, OptionValueConverter::class],
)
- internal var options = mutableMapOf()
+ internal var options: MutableMap? = null
}
@ArgGroup(exclusive = false)
@@ -388,665 +372,180 @@ internal object PatchCommand : Callable {
override fun call(): Int {
// Check for any newer version
- UpdateChecker.check(logger)?.let { logger.info(it) }
-
- // region Setup
-
- // Default output uses the unified scheme shared with the GUI:
- // //-Morphe-{apkVer}-patches-{patchesVer}.apk
- // The folder name uses the APK's human-friendly label (e.g. "Youtube")
- // when readable from the manifest, falling back to the filename for
- // corrupt or unparseable APKs. GUI populates this from apkInfo;
- // CLI parses the APK here so both surfaces produce identical paths.
- // Users who want the legacy `./-patched.apk` layout pass --out.
- val outputFilePath = outputFilePath ?: run {
- val displayName = app.morphe.engine.util.ApkOutputNaming.resolveAppDisplayName(apk)
- app.morphe.engine.util.ApkOutputNaming.outputApkPath(
- inputApk = apk,
- patchesFile = bundles.firstOrNull()?.patchesFile,
- appDisplayName = displayName,
- )
- }
-
- val temporaryFilesPath = temporaryFilesPath ?: MorpheData.tmpDir
-
- // Resolve and possibly convert the keystore. The patcher only loads BKS,
- // but users frequently pass PKCS12 (Android Studio default) or JKS (older
- // projects, URV exports). KeystoreImporter sniffs by magic bytes (not
- // extension — URV ships the same bytes under multiple suffixes), short-
- // circuits when already BKS so existing setups are zero-risk, and writes
- // converted bytes to a separate file so the user's source is never mutated.
- val keystoreFilePath = run {
- val source = keyStoreFilePath ?: return@run MorpheData.defaultKeystoreFile
- if (!source.exists()) return@run source // patcher will produce a clearer error
- val importResult = app.morphe.engine.util.KeystoreImporter.ensureBks(
- source = source,
- convertedOutput = MorpheData.importedKeystoreFile,
- alias = keyStoreEntryAlias,
- password = keyStoreEntryPassword,
- )
- when (importResult) {
- is app.morphe.engine.util.KeystoreImporter.Result.AlreadyBks -> importResult.file
- is app.morphe.engine.util.KeystoreImporter.Result.Converted -> {
- logger.info(
- "Converted ${importResult.sourceFormat.displayName} keystore → BKS: ${importResult.file.absolutePath}"
- )
- importResult.file
- }
- is app.morphe.engine.util.KeystoreImporter.Result.Failed -> {
- logger.severe("Keystore conversion failed: ${importResult.reason}")
- importResult.cause?.let { logger.severe(it.stackTraceToString()) }
- throw IllegalArgumentException(
- "Could not use keystore '${source.absolutePath}': ${importResult.reason}"
- )
- }
- }
- }
-
- val installer = if (deviceSerial != null) {
- val deviceSerial = deviceSerial!!.ifEmpty { null }
-
- try {
- if (mount) {
- AdbRootInstaller(deviceSerial)
- } else {
- AdbInstaller(deviceSerial)
+ UpdateChecker.check()?.let { Logger.info(it) }
+
+ val adbManager = if (deviceSerial != null) AdbManager() else null
+ val targetDeviceId = if (deviceSerial != null) {
+ val requestedSerial = deviceSerial!!.ifEmpty { null }
+ val verifiedDevice = runBlocking {
+ adbManager!!.startServer().onFailure { e ->
+ Logger.error(e.message ?: "Failed to start ADB server.")
+ return@runBlocking null
}
- } catch (_: DeviceNotFoundException) {
- if (deviceSerial?.isNotEmpty() == true) {
- logger.severe(
- "Device with serial $deviceSerial not found to install to. " +
- "Ensure the device is connected and the serial is correct when using the --install option.",
- )
- } else {
- logger.severe(
- "No device has been found to install to. " +
- "Ensure a device is connected when using the --install option.",
- )
+ adbManager.verifyTargetDevice(requestedSerial, mount = mount).getOrElse { e ->
+ when ((e as? AdbException)?.errorCode) {
+ AdbErrorCode.DEVICE_NOT_FOUND -> Logger.error(
+ "Device with serial $requestedSerial not found to install to. " +
+ "Ensure the device is connected and the serial is correct when using the --install option.",
+ )
+ AdbErrorCode.NO_DEVICES,
+ AdbErrorCode.UNAUTHORIZED_DEVICE -> Logger.error(
+ "No device has been found to install to. " +
+ "Ensure a device is connected when using the --install option.",
+ )
+ else -> Logger.error(e.message ?: e.toString())
+ }
+ adbManager.killServerIfOwned()
+ return@runBlocking null
}
-
- return EXIT_CODE_ERROR
- }
+ } ?: return EXIT_CODE_ERROR
+ verifiedDevice.id
} else {
null
}
- // endregion
-
- val patchingResult = PatchingResult()
- var mergedApkToCleanup: File? = null
- val verifier = if (sdkToolsPath == null) NoOpDexVerifier else SdkDexVerifier(sdkToolsPath!!)
-
- // Lightweight snapshot of patch metadata for use in finally block (auto-update).
- // Lightweight snapshot of current bundle metadata for use in finally block (auto-update).
- // The heavy Patch objects hold DEX classloaders and must not leak into finally.
- var patchesSnapshotForFinally: List = emptyList()
-
try {
- // We resolve each bundle's URL separately.
bundles.forEach { bundle ->
- val resolved = PatchFileResolver.resolve(
+ val resolved = PatchResolver.resolveCliFiles(
setOf(bundle.patchesFile),
prerelease,
- CliHttpClient.instance
)
bundle.patchesFile = resolved.single()
}
} catch (e: IllegalArgumentException) {
throw CommandLine.ParameterException(
spec.commandLine(),
- e.message ?: "Failed to resolve patch URL"
+ e.message ?: "Failed to resolve patch URL",
)
}
- // Per-session scratch dir. Hoisted out of the patching `try` block so
- // the `finally` block can reference it for the auto-purge (unless --disable-purge).
- // Naming matches the GUI's FileUtils.createPatchingTempDir() so the
- // tmp/ folder shows consistent siblings across CLI + GUI sessions.
- val patcherTemporaryFilesPath =
- temporaryFilesPath.resolve("patching-${System.currentTimeMillis()}").also { it.mkdirs() }
-
- try {
- logger.info("Loading patches...")
-
- // We load each bundle separately so each bundle's options can be scoped correctly.
- val loadedBundles: List = PatchBundleLoader.loadEach(
- bundles.map { it.patchesFile }
+ val bundleScopes = bundles.map { bundleArg ->
+ val enabledNames = bundleArg.selections.mapNotNull { it.enabled?.selector?.name }.toSet()
+ val enabledIndices = bundleArg.selections.mapNotNull { it.enabled?.selector?.index }.toSet()
+ val disabledNames = bundleArg.selections.mapNotNull { it.disable?.selector?.name }.toSet()
+ val disabledIndices = bundleArg.selections.mapNotNull { it.disable?.selector?.index }.toSet()
+ val patchOptionsByName = bundleArg.selections
+ .filter { it.enabled?.selector?.name != null && !it.enabled!!.options.isNullOrEmpty() }
+ .associate { it.enabled!!.selector.name!! to it.enabled!!.options!! }
+ val patchOptionIndices = bundleArg.selections
+ .filter { it.enabled?.selector?.index != null && !it.enabled!!.options.isNullOrEmpty() }
+ .associate { it.enabled!!.selector.index!! to it.enabled!!.options!! }
+
+ PatchEngine.BundleScope(
+ bundleFile = bundleArg.patchesFile,
+ enabledPatchNames = enabledNames,
+ enabledPatchIndices = enabledIndices,
+ disabledPatchNames = disabledNames,
+ disabledPatchIndices = disabledIndices,
+ patchOptions = patchOptionsByName,
+ patchOptionIndices = patchOptionIndices,
)
+ }
+ val savedConfig = runBlocking { EngineConfigRepository.shared.loadConfig() }
- val patches: MutableSet> = loadedBundles.flatMap { it.patches }.toMutableSet()
- val patchSnapshots: List = loadedBundles.map { lb ->
- lb.patches.toPatchBundle(sourceFiles = setOf(lb.sourceFile))
- }
-
- // region Parse options JSON
- val patchOptionsByFile: Map = optionsFilePath?.let { file ->
- if (file.exists()){
- logger.info("Reading options from ${file.path}")
- val jsonBundles = Json.decodeFromString>(file.readText())
- loadedBundles.associate { lb ->
- lb.sourceFile to jsonBundles.findMatchingBundle(setOf(lb.sourceFile))
- }
- } else {
- logger.info("Options file ${file.path} does not exist, generating with defaults")
- val json = Json { prettyPrint = true }
- file.absoluteFile.parentFile?.mkdirs()
- file.writeText(json.encodeToString(patchSnapshots))
- logger.info("Generated options file at ${file.path}")
- loadedBundles.zip(patchSnapshots).associate { (lb, b) ->
- lb.sourceFile to b
- }
- }
- } ?: emptyMap()
-
- // Per-bundle JSON-sourced enable/disable. Same patch name in two bundles can
- // have different enabled states across mpps.
- val jsonEnabledByFile: Map> = patchOptionsByFile.mapValues { (_, bundle) ->
- bundle?.patches?.filter { it.value.enabled }?.keys?.map {
- it.lowercase() }?.toSet()
- ?: emptySet()
- }
-
- val jsonDisabledByFile: Map> = patchOptionsByFile.mapValues { (_, bundle) ->
- bundle?.patches?.filter { !it.value.enabled }?.keys?.map {
- it.lowercase() }?.toSet()
- ?: emptySet()
- }
-
- // Per-bundle options map. Same as before but indexed by source file.
- // Option values are deserialized using the patch types from that file's bundle, not the global pool.
- val jsonOptionsByFile: Map>> =
- loadedBundles.associate { lb ->
- val bundle = patchOptionsByFile[lb.sourceFile]
- val opts = bundle?.patches?.mapNotNull { (patchName, entry) ->
- if (entry.options.isEmpty()) return@mapNotNull null
- val patch = lb.patches.firstOrNull {
- it.name.equals(patchName, ignoreCase = true)
- } ?: return@mapNotNull null
- val resolvedName = patch.name ?: return@mapNotNull null
- val deserializedOptions = entry.options.mapNotNull { (key, element) ->
- if (!patch.options.containsKey(key)) return@mapNotNull null
- val option = patch.options[key]
- try {
- key to deserializeOptionValue(element, option.type)
- } catch (e: Exception) {
- logger.warning(
- "Failed to deserialize option $key for $patchName in ${lb.sourceFile.name}: ${e.message}"
- )
- null
- }
- }.toMap()
-
- if (deserializedOptions.isEmpty()) null
- else resolvedName to deserializedOptions
- }?.toMap() ?: emptyMap()
- lb.sourceFile to opts
- }
-
- // Hand the per-bundle snapshots off to the finally block before we
- // enter the Patcher use{} (which holds DEX classloaders we don't
- // want leaking into finally).
- patchesSnapshotForFinally = patchSnapshots
-
- // endregion
-
- // (patcherTemporaryFilesPath is declared above the outer try
- // block so it's visible to the auto-purge in the finally clause.)
-
- // We need to check for apkm (like reddit), xapk and apks formats here
-
- val inputApk = if (app.morphe.engine.util.BundleFormats.isBundle(apk)) {
-
- logger.info("Merging split APK bundle")
-
- // Save merged APK to output directory (will be cleaned up after patching)
- val outputApk = outputFilePath.parentFile.resolve("${apk.nameWithoutExtension}-merged.apk")
-
- // Use APKEditor's Merger directly (handles extraction and merging)
- ApkMerger(logger.toMorpheLogger()).merge(
- inputFile = apk,
- outputFile = outputApk,
- cleanMetaInf = false
- )
-
- mergedApkToCleanup = outputApk
- outputApk
- } else {
- apk
- }
-
- logger.info("Initializing patcher...")
- val (packageName, patcherResult) = Patcher(
- PatcherConfig(
- inputApk,
- patcherTemporaryFilesPath,
- useArsclib = true,
- keepArchitectures = keepArchitectures,
- useBytecodeMode = bytecodeMode,
- verifier = verifier
- ),
- ).use { patcher ->
- val packageName = patcher.context.packageMetadata.packageName
- val packageVersion = patcher.context.packageMetadata.versionName
-
- patchingResult.packageName = packageName
- patchingResult.packageVersion = packageVersion
-
- // Warn if options file is out of date — checked PER BUNDLE so
- // each .mpp's drift is reported against its own JSON entry.
- if (optionsFilePath?.exists() == true && !updateOptions) {
- loadedBundles.forEachIndexed { i, lb ->
- val bundleOpts = patchOptionsByFile[lb.sourceFile] ?: return@forEachIndexed
- val bundleSnapshot = patchSnapshots[i]
- val bundlePatches = lb.patches
-
- val compatiblePatchNames = bundlePatches
- .filter { patch ->
- patch.isCompatibleWith(
- packageName = packageName,
- includeExperimental = true,
- includeUniversalPatches = true,
- )
- }
- .mapNotNull { it.name?.lowercase() }
- .toSet()
- // All patch names in this bundle regardless of app compatibility.
- // Used for "removed" detection: a patch is only truly removed if
- // it's gone from the .mpp entirely, not just incompatible with
- // this app.
- val allMppPatchNames = bundlePatches.mapNotNull { it.name?.lowercase() }.toSet()
- val jsonPatchNames = bundleOpts.patches.keys.map { it.lowercase() }.toSet()
-
- val newPatches = compatiblePatchNames - jsonPatchNames
- val oldPatches = jsonPatchNames - compatiblePatchNames
- val removedPatches = jsonPatchNames - allMppPatchNames
-
- // Per-patch option-key drift.
- val patchesWithNewOptions = mutableMapOf>()
- val patchesWithOldOptions = mutableMapOf>()
-
- for ((patchName, _) in bundleSnapshot.patches) {
- if (patchName.lowercase() !in compatiblePatchNames) continue
- val jsonEntry = bundleOpts.patches.entries
- .firstOrNull { it.key.equals(patchName, ignoreCase = true) }?.value
- ?: continue
-
- // Compare against the live patch in this bundle (not the snapshot)
- // so multi-app patches with the same name aren't merged together.
- val actualPatch = bundlePatches.find { patch ->
- if (!patch.name.equals(patchName, ignoreCase = true)) return@find false
- patch.isCompatibleWith(
- packageName = packageName,
- includeExperimental = true,
- includeUniversalPatches = true,
- )
- }
- val actualOptionKeys = actualPatch?.options?.keys ?: emptySet()
-
- val newOptionKeys = actualOptionKeys - jsonEntry.options.keys
- if (newOptionKeys.isNotEmpty()) patchesWithNewOptions[patchName] = newOptionKeys
-
- val oldOptionKeys = jsonEntry.options.keys - actualOptionKeys
- if (oldOptionKeys.isNotEmpty()) patchesWithOldOptions[patchName] = oldOptionKeys
- }
-
- if (newPatches.isNotEmpty() || oldPatches.isNotEmpty() || removedPatches.isNotEmpty() ||
- patchesWithNewOptions.isNotEmpty() || patchesWithOldOptions.isNotEmpty()
- ) {
- logger.warning("Options file is out of date for ${lb.sourceFile.name}:")
- if (newPatches.isNotEmpty()) {
- logger.warning(" ${newPatches.size} new patches not in your options file, default patch values will be applied. New patches are:")
- newPatches.forEach { logger.warning(" - $it") }
- }
- if (removedPatches.isNotEmpty()) {
- logger.warning(" ${removedPatches.size} patches in your options file no longer exist and will be ignored")
- }
- if (oldPatches.isNotEmpty()) {
- logger.warning(" ${oldPatches.size} patches in your options file are not compatible with the app:")
- oldPatches.forEach { logger.warning(" - $it") }
- }
- if (patchesWithNewOptions.isNotEmpty()) {
- patchesWithNewOptions.forEach { (patch, key) ->
- logger.warning(" \"$patch\" has new options: ${key.joinToString(", ")}")
- }
- }
- if (patchesWithOldOptions.isNotEmpty()) {
- patchesWithOldOptions.forEach { (patch, key) ->
- logger.warning(" \"$patch\" has old options: ${key.joinToString(", ")} that were removed.")
- }
- }
- logger.warning(" Use --options-update parameter to sync, or use 'options-create' command to regenerate.")
- }
- }
- }
-
- logger.info("Filtering patches for $packageName v$packageVersion...")
-
- // Filter + apply options PER BUNDLE so each bundle's selectors
- // and options only touch its own patches. Final patcher input
- // is the union across all bundles.
- val finalPatches = mutableSetOf>()
- loadedBundles.forEachIndexed { i, lb ->
- val bundleArg = bundles[i]
- val jsonEnabled = jsonEnabledByFile[lb.sourceFile] ?: emptySet()
- val jsonDisabled = jsonDisabledByFile[lb.sourceFile] ?: emptySet()
- val jsonOpts = jsonOptionsByFile[lb.sourceFile] ?: emptyMap()
-
- val patchesList = lb.patches.toList()
-
- // CLI options map scoped to this bundle. Name resolution looks
- // up only this bundle's patches; --ei index is interpreted as
- // an index INTO THIS BUNDLE'S patch list.
- val cliOptionsMap = bundleArg.selections.filter { it.enabled != null }.associate { sel ->
- val enabledSel = sel.enabled!!
- val resolvedName = enabledSel.selector.name?.let { userInput ->
- patchesList.firstOrNull { it.name.equals(userInput, ignoreCase = true) }?.name
- ?: userInput
- } ?: patchesList[enabledSel.selector.index!!].name!!
- resolvedName to enabledSel.options
- }
-
- val filtered = lb.patches.filterPatchSelection(
- packageName,
- packageVersion,
- bundleArg.selections,
- jsonEnabled,
- jsonDisabled,
- )
-
- // Merge JSON + CLI options (CLI overrides JSON for same key)
- // and apply to this bundle's filtered patches.
- (jsonOpts.keys + cliOptionsMap.keys).associateWith { patchName ->
- val js = jsonOpts[patchName] ?: emptyMap()
- val cl = cliOptionsMap[patchName] ?: emptyMap()
- for ((key, cliValue) in cl) {
- val jsonValue = js[key]
- if (jsonValue != null && jsonValue != cliValue) {
- logger.info(
- "CLI option overrides JSON for \"$patchName\" " +
- "(${lb.sourceFile.name}) -> \"$key\": $jsonValue -> $cliValue"
- )
- }
- }
- js + cl
- }.let(filtered::setOptions)
-
- finalPatches += filtered
- }
- patcher += finalPatches
-
- // Execute patches. Log lines match the engine's "Applying N
- // patches…" → "Applied: " / "FAILED: " format so
- // CLI and GUI output is consistent. CLI still appends the
- // stacktrace on failure since there's no "View details" UI
- // in a terminal.
- logger.info("Applying ${finalPatches.size} patches...")
- patchingResult.addStepResult(PatchingStep.PATCHING) {
- runBlocking {
- patcher().collect { patchResult ->
- val patchName = patchResult.patch.name ?: "Unknown"
- patchResult.exception?.let { exception ->
- StringWriter().use { writer ->
- exception.printStackTrace(PrintWriter(writer))
-
- logger.severe("FAILED: $patchName\n$writer")
-
- patchingResult.failedPatches.add(
- FailedPatch(
- patchResult.patch.toSerializablePatch(),
- writer.toString()
- )
- )
-
- if (!continueOnError) {
- patchingResult.success = false
- throw PatchFailedException(
- "FAILED: $patchName",
- exception
- )
- }
- }
- } ?: run {
- patchingResult.appliedPatches.add(patchResult.patch.toSerializablePatch())
- logger.info("Applied: $patchName")
- }
- }
- }
- }
-
- // patches lives in the outer try scope (needed for patchesSnapshot and options
- // file generation before the Patcher block). Clear it explicitly now — after
- // patcher() finishes and before patcher.get() — so the JVM can GC the DEX
- // classloaders before the most memory-intensive step.
- patches.clear()
-
- patcher.context.packageMetadata.packageName to patcher.get()
- }
-
- // region Save.
-
- inputApk.copyTo(patcherTemporaryFilesPath.resolve(inputApk.name), overwrite = true).apply {
- patchingResult.addStepResult(PatchingStep.REBUILDING) {
- patcherResult.applyTo(this)
- }
- }.let { patchedApkFile ->
- if (!mount && !unsigned) {
- patchingResult.addStepResult(PatchingStep.SIGNING) {
- 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)
- }
- }
- } else {
- patchedApkFile.copyTo(outputFilePath, overwrite = true)
- }
- verifier.verifyApkFile(outputFilePath)
- }
+ val cliKeystoreDetails = keyStoreFilePath?.let { ks ->
+ ApkUtils.KeyStoreDetails(
+ keyStore = ks,
+ keyStorePassword = keyStorePassword,
+ alias = keyStoreEntryAlias,
+ password = keyStoreEntryPassword,
+ )
+ }
+ val resolvedKeystoreDetails = runBlocking {
+ KeystoreService.shared.resolveSigningDetails(cliKeystoreDetails)
+ }
- logger.info("Saved to $outputFilePath")
+ val effectiveOutputFile = outputFilePath ?: savedConfig.resolvedDefaultOutputDirectory()?.let { defaultDir ->
+ ApkOutputNaming.outputApkPath(
+ inputApk = apk,
+ patchesFile = bundleScopes.firstOrNull()?.bundleFile,
+ baseOutputDir = defaultDir,
+ )
+ }
- // endregion
+ val effectiveArchitectures = keepArchitectures.ifEmpty {
+ savedConfig.keepArchitectures.mapNotNull { CpuArchitecture.valueOfOrNull(it) }.toSet()
+ }
- // region Install.
+ val engineConfig = PatchEngine.Config(
+ inputApk = apk,
+ outputApk = effectiveOutputFile,
+ bundleScopes = bundleScopes,
+ exclusiveMode = exclusive,
+ forceCompatibility = force,
+ unsigned = unsigned || mount,
+ signerName = signer,
+ keystoreDetails = resolvedKeystoreDetails,
+ architecturesToKeep = effectiveArchitectures,
+ tempDir = temporaryFilesPath,
+ failOnError = !continueOnError,
+ bytecodeMode = bytecodeMode,
+ sdkToolsPath = sdkToolsPath,
+ disablePurge = disablePurge,
+ optionsFile = optionsFilePath,
+ updateOptions = updateOptions,
+ recordHistory = true,
+ )
- deviceSerial?.let {
- patchingResult.addStepResult(PatchingStep.INSTALLING) {
- runBlocking {
- when (val result = installer!!.install(Installer.Apk(outputFilePath, packageName))) {
- RootInstallerResult.FAILURE -> {
- logger.severe("Failed to mount the patched APK file")
- throw IllegalStateException("Failed to mount the patched APK file")
- }
- is AdbInstallerResult.Failure -> {
- logger.severe(result.exception.toString())
- throw result.exception
- }
- else -> logger.info("Installed the patched APK file")
+ try {
+ val engineResult = runBlocking {
+ PatchEngine.patch(
+ config = engineConfig,
+ onProgress = { line ->
+ when {
+ line.startsWith("ERROR:", ignoreCase = true) || line.startsWith("FAILED:", ignoreCase = true) -> Logger.error(line)
+ line.startsWith("WARNING:", ignoreCase = true) -> Logger.warn(line)
+ else -> Logger.info(line)
}
- }
- }
+ },
+ )
}
- // endregion
- } catch (e: PatchFailedException) {
- logger.severe("Patching aborted: ${e.message}")
- logger.info(
- "Use --continue-on-error to skip failed patches and continue patching"
- )
- return EXIT_CODE_ERROR
- } catch (e: Exception) {
- // Should never happen.
- logger.severe("An unexpected error occurred: ${e.message}")
- e.printStackTrace()
- return EXIT_CODE_ERROR
- } finally {
patchingResultOutputFilePath?.let { outputFile ->
outputFile.outputStream().use { outputStream ->
- Json.encodeToStream(patchingResult, outputStream)
- }
- logger.info("Patching result saved to $outputFile")
- }
-
- // Auto-update options JSON file using the per-bundle snapshots
- // (no DEX references). One JSON entry per .mpp, matched by source.
- if (optionsFilePath != null && updateOptions && patchesSnapshotForFinally.isNotEmpty()) {
- try {
- val existingBundles = optionsFilePath!!.let { file ->
- if (file.exists()) {
- try { Json.decodeFromString>(file.readText()) }
- catch (e: Exception) { emptyList() }
- } else emptyList()
- }
- // Walk each bundle's snapshot, merge against its matching
- // existing entry (by sha256 / source name), and splice the
- // updated entry back into the list. Bundles without a prior
- // entry get appended.
- var updatedBundles = existingBundles
- patchesSnapshotForFinally.forEach { snapshot ->
- val sourceFile = snapshot.meta.source?.let { File(it) }
- val existing = if (sourceFile != null) {
- updatedBundles.findMatchingBundle(setOf(sourceFile))
- } else null
- val updated = snapshot.mergeWith(existing)
- updatedBundles = updatedBundles.withUpdatedBundle(updated)
- }
- val json = Json { prettyPrint = true }
- optionsFilePath!!.writeText(json.encodeToString(updatedBundles))
- logger.info("Updated options file ${optionsFilePath!!.path}")
- } catch (e: Exception) {
- logger.warning("Failed to update options file: ${e.message}")
+ Json.encodeToStream(engineResult, outputStream)
}
+ Logger.info("Patching result saved to $outputFile")
}
- if (!disablePurge) {
- // Scope: only THIS session's tmp subfolder. Cached patches,
- // logs, config, and other in-flight sessions (CLI or GUI) are
- // never touched.
- if (patcherTemporaryFilesPath.deleteRecursivelyInParallel()) {
- logger.info("Purged this session's temp files: ${patcherTemporaryFilesPath.name}")
- } else {
- logger.warning("Failed to purge ${patcherTemporaryFilesPath.path}")
- }
- }
-
- // Clean up merged apk if we created one from apkm, xapk or apks
- mergedApkToCleanup?.let {
- if (!it.delete()) {
- logger.warning("Could not clean up merged APK: ${it.path}")
- }
- }
- }
-
- return EXIT_CODE_SUCCESS
- }
-
- /**
- * Filter the patches based on the selection.
- *
- * @param packageName The package name of the APK file to be patched.
- * @param packageVersion The version of the APK file to be patched.
- * @param jsonEnabledPatches Patch names enabled via JSON options file (lowercase).
- * @param jsonDisabledPatches Patch names disabled via JSON options file (lowercase).
- * @return The filtered patches.
- */
- private fun Set>.filterPatchSelection(
- packageName: String,
- packageVersion: String,
- bundleSelections: List,
- jsonEnabledPatches: Set = emptySet(),
- jsonDisabledPatches: Set = emptySet(),
- ): Set> = buildSet {
- // CLI flags (take precedence over JSON)
- val cliEnabledByName =
- bundleSelections.mapNotNull { it.enabled?.selector?.name?.lowercase() }.toSet()
- val cliEnabledByIndex =
- bundleSelections.mapNotNull { it.enabled?.selector?.index }.toSet()
- val cliDisabledByName =
- bundleSelections.mapNotNull { it.disable?.selector?.name?.lowercase() }.toSet()
- val cliDisabledByIndex =
- bundleSelections.mapNotNull { it.disable?.selector?.index }.toSet()
-
- this@filterPatchSelection.withIndex().forEach patchLoop@{ (i, patch) ->
- val patchName = patch.name!!
- val patchNameLower = patchName.lowercase()
-
- // Check package compatibility first to avoid duplicate logs for multi-app patches.
- val supportedVersions = patch.supportedVersionsFor(packageName)
- when {
- supportedVersions != null && supportedVersions.isEmpty() -> {
- return@patchLoop logger.fine(
- "Skipping \"$patchName\": incompatible with $packageName " +
- "(only compatible with " +
- patch.compatibleVersionsForDisplay(includeExperimental = true)
- .mapNotNull { (name, _) -> name }
- .joinToString(", ") + ")"
- )
- }
- supportedVersions != null -> {
- val matchesVersion = force || packageVersion in supportedVersions
- if (!matchesVersion) {
- val hint = supportedVersions.joinToString(", ")
- return@patchLoop logger.warning(
- "Skipping \"$patchName\": incompatible with $packageName $packageVersion " +
- "(supported: $packageName $hint)"
- )
+ if (engineResult.success) {
+ if (targetDeviceId != null) {
+ val finalApkFile = File(engineResult.outputPath)
+ val installSuccess = runBlocking {
+ val installResult = if (mount) {
+ adbManager!!.mountApk(finalApkFile, engineResult.packageName, targetDeviceId)
+ } else {
+ adbManager!!.installApk(
+ apkPath = finalApkFile.absolutePath,
+ deviceId = targetDeviceId,
+ )
+ }
+ installResult.isSuccess
}
+ if (!installSuccess) return EXIT_CODE_ERROR
}
- }
- // CLI flags take precedence over JSON, JSON takes precedence over defaults.
- // Log strings match the GUI engine's "Skipping disabled: …" format so
- // surfaces stay consistent. CLI-specific override hints are preserved
- // as parentheticals.
- val isCliDisabled = patchNameLower in cliDisabledByName || i in cliDisabledByIndex
- if (isCliDisabled) {
- if (patchNameLower in jsonEnabledPatches) {
- logger.info("Skipping disabled: $patchName (overrides options file: enabled)")
- } else {
- logger.info("Skipping disabled: $patchName")
+ return EXIT_CODE_SUCCESS
+ } else {
+ Logger.error("Patching aborted: ${engineResult.failureReason ?: "Unknown error"}")
+ if (!continueOnError && engineResult.failedPatches.isNotEmpty()) {
+ Logger.info("Use --continue-on-error to skip failed patches and continue patching")
}
- return@patchLoop
- }
-
- val isCliEnabled = patchNameLower in cliEnabledByName || i in cliEnabledByIndex
- if (isCliEnabled && patchNameLower in jsonDisabledPatches) {
- logger.info("Enabling: $patchName (overrides options file: disabled)")
+ return EXIT_CODE_ERROR
}
-
- // JSON-sourced enable/disable (only applies if no CLI flag for this patch)
- val isJsonDisabled = !isCliEnabled && patchNameLower in jsonDisabledPatches
- if (isJsonDisabled) return@patchLoop logger.info(
- "Skipping disabled: $patchName (from options file)"
- )
-
- val isJsonEnabled = patchNameLower in jsonEnabledPatches
-
- val isEnabled = !exclusive && patch.default
-
- if (!(isEnabled || isCliEnabled || isJsonEnabled)) {
- // Default-disabled patches (the patch ships with use=false and
- // wasn't explicitly enabled). Log at info level — most CLI
- // users want to see WHY each patch was skipped, even the
- // ones that opted-out by default.
- return@patchLoop logger.info("Skipping disabled: $patchName (default)")
+ } finally {
+ adbManager?.let {
+ runBlocking { it.killServerIfOwned() }
}
-
- add(patch)
-
- logger.fine("\"$patchName\" added")
}
}
+}
+class OptionKeyConverter : CommandLine.ITypeConverter {
+ override fun convert(value: String): String = value
}
-private class PatchFailedException(message: String, cause: Throwable) : Exception(message, cause)
+class OptionValueConverter : CommandLine.ITypeConverter {
+ override fun convert(value: String?): Any? = parseCliOptionValue(value)
+}
diff --git a/src/main/kotlin/app/morphe/desktop/command/PatchFileResolver.kt b/src/main/kotlin/app/morphe/desktop/command/PatchFileResolver.kt
deleted file mode 100644
index a33431b6..00000000
--- a/src/main/kotlin/app/morphe/desktop/command/PatchFileResolver.kt
+++ /dev/null
@@ -1,107 +0,0 @@
-/*
- * Copyright 2026 Morphe.
- * https://github.com/MorpheApp/morphe-desktop
- */
-
-package app.morphe.desktop.command
-
-import app.morphe.engine.patches.PatchCache
-import app.morphe.engine.patches.PatchProvider
-import app.morphe.engine.patches.RemotePatchSourceFactory
-import app.morphe.engine.patches.findPatchAsset
-import io.ktor.client.HttpClient
-import kotlinx.coroutines.runBlocking
-import java.io.File
-import java.util.logging.Logger
-
-
-object PatchFileResolver {
- private val logger = Logger.getLogger(this::class.java.name)
-
- /**
- * Takes the user's provided Patch Files and resolves any URLs that might be present.
- * Returns a new Set with URLs replaced by downloaded/cached .mpp files.
- *
- * Provider detection (GitHub vs GitLab) + URL parsing + API talk lives in the engine.
- * This function owns the "which release do we pick" decision. The on-disk cache
- * layout is shared with the GUI via [PatchCache] (one cache for both surfaces).
- */
- fun resolve(
- patchFiles: Set,
- prerelease: Boolean,
- httpClient: HttpClient
- ): Set {
- val urlEntry = patchFiles.firstOrNull {
- it.invariantSeparatorsPath.startsWith("http:/") ||
- it.invariantSeparatorsPath.startsWith("https:/")
- } ?: return patchFiles
-
- val url = urlEntry.invariantSeparatorsPath
-
- return runBlocking {
- try {
- // Parse the URL here, the engine handles github.com, gitlab.com,
- // morphe.software/add-source links, bare owner/repo.
-
- val parsed = RemotePatchSourceFactory.parse(url)
- ?: throw IllegalArgumentException("Unrecognized patch URL: $url")
-
- val source = parsed.instantiate(httpClient)
-
- // List releases and decide which one to use. `releases/tag/` in the URL pins to
- // that exact tag.
-
- val pinnedTag = Regex("release/tag/([^/]+)").find(url)?.groupValues?.get(1)
-
- val release = source.listReleases().getOrThrow()
- val targetRelease = when {
- parsed.provider == PatchProvider.GITHUB_PR -> release.firstOrNull()
- ?: throw IllegalArgumentException("No release artifact found for ${parsed.canonicalUrl}")
-
- pinnedTag != null -> release.firstOrNull {
- it.tagName == pinnedTag
- }
- ?: throw IllegalArgumentException("Version $pinnedTag not found in ${parsed.repoPath}")
-
- prerelease -> release.firstOrNull {
- it.isDevRelease()
- }
- ?: throw IllegalArgumentException("Could not get dev release from ${parsed.repoPath}")
-
- else -> release.firstOrNull {
- !it.isDevRelease()
- }
- ?: throw IllegalArgumentException("Could not get stable release from ${parsed.repoPath}")
- }
-
- // Find the .mpp asset in that release.
- val asset = targetRelease.findPatchAsset()
- ?: throw IllegalArgumentException("No .mpp file found in release ${targetRelease.tagName}")
-
- // Shared on-disk cache (via PatchCache): the same directory and tag-prefixed filename
- // the GUI uses, so a .mpp downloaded by either side is reused by the other.
- // Layout: morphe-data/patches/-/__.mpp
- val repoCacheDir = PatchCache.sourceDir(parsed.repoPath)
- val targetFile = File(repoCacheDir, PatchCache.cachedFileName(targetRelease, asset))
-
- val resolvedFile = if (targetFile.exists() && targetFile.length() > 0L) {
- logger.info("Using cached patch file at ${targetFile.absolutePath}")
- targetFile
- } else {
- logger.info("Downloading patches from ${parsed.repoPath} ${targetRelease.tagName}...")
- source.downloadAsset(asset, targetFile).getOrThrow()
- logger.info("Patches mpp saved to ${targetFile.absolutePath}. This file will be used on your next run as long as it is not deleted!")
- targetFile
- }
-
- buildSet {
- addAll(patchFiles)
- remove(urlEntry)
- add(resolvedFile)
- }
- } catch (e: Exception) {
- throw IllegalArgumentException("Failed to download patches from URL: ${e.message}")
- }
- }
- }
-}
\ No newline at end of file
diff --git a/src/main/kotlin/app/morphe/desktop/command/model/FailedPatch.kt b/src/main/kotlin/app/morphe/desktop/command/model/FailedPatch.kt
deleted file mode 100644
index 64913d29..00000000
--- a/src/main/kotlin/app/morphe/desktop/command/model/FailedPatch.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-/*
- * Copyright 2026 Morphe.
- * https://github.com/MorpheApp/morphe-desktop
- */
-
-package app.morphe.desktop.command.model
-
-import kotlinx.serialization.ExperimentalSerializationApi
-import kotlinx.serialization.Serializable
-
-@ExperimentalSerializationApi
-@Serializable
-data class FailedPatch(
- val patch: SerializablePatch,
- val reason: String
-)
diff --git a/src/main/kotlin/app/morphe/desktop/command/model/PatchingResult.kt b/src/main/kotlin/app/morphe/desktop/command/model/PatchingResult.kt
deleted file mode 100644
index 16871b82..00000000
--- a/src/main/kotlin/app/morphe/desktop/command/model/PatchingResult.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * Copyright 2026 Morphe.
- * https://github.com/MorpheApp/morphe-desktop
- */
-
-package app.morphe.desktop.command.model
-
-import kotlinx.serialization.EncodeDefault
-import kotlinx.serialization.ExperimentalSerializationApi
-import kotlinx.serialization.Serializable
-
-@ExperimentalSerializationApi
-@Serializable
-data class PatchingResult(
- var packageName: String? = null,
- var packageVersion: String? = null,
- var success: Boolean = true,
- @EncodeDefault val patchingSteps: MutableList = mutableListOf(),
- @EncodeDefault val appliedPatches: MutableList = mutableListOf(),
- @EncodeDefault val failedPatches: MutableList = mutableListOf()
-)
-
-@ExperimentalSerializationApi
-fun PatchingResult.addStepResult(
- step: PatchingStep,
- block: () -> R,
-): R {
- try {
- val result = block()
- this.patchingSteps.add(
- PatchingStepResult(
- step = step,
- success = true
- )
- )
- return result
- } catch (e: Exception) {
- this.success = false
- this.patchingSteps.add(
- PatchingStepResult(
- step = step,
- success = false,
- message = e.toString()
- )
- )
- throw e
- }
-}
diff --git a/src/main/kotlin/app/morphe/desktop/command/model/PatchingStep.kt b/src/main/kotlin/app/morphe/desktop/command/model/PatchingStep.kt
deleted file mode 100644
index 42b6b2b9..00000000
--- a/src/main/kotlin/app/morphe/desktop/command/model/PatchingStep.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-/*
- * Copyright 2026 Morphe.
- * https://github.com/MorpheApp/morphe-desktop
- */
-
-package app.morphe.desktop.command.model
-
-enum class PatchingStep {
- PATCHING,
- REBUILDING,
- SIGNING,
- INSTALLING
-}
\ No newline at end of file
diff --git a/src/main/kotlin/app/morphe/desktop/command/model/PatchingStepResult.kt b/src/main/kotlin/app/morphe/desktop/command/model/PatchingStepResult.kt
deleted file mode 100644
index ad970cdb..00000000
--- a/src/main/kotlin/app/morphe/desktop/command/model/PatchingStepResult.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-/*
- * Copyright 2026 Morphe.
- * https://github.com/MorpheApp/morphe-desktop
- */
-
-package app.morphe.desktop.command.model
-
-import kotlinx.serialization.ExperimentalSerializationApi
-import kotlinx.serialization.Serializable
-
-@ExperimentalSerializationApi
-@Serializable
-data class PatchingStepResult(
- val step: PatchingStep,
- val success: Boolean,
- val message: String? = null
-)
diff --git a/src/main/kotlin/app/morphe/desktop/command/model/SerializablePatch.kt b/src/main/kotlin/app/morphe/desktop/command/model/SerializablePatch.kt
deleted file mode 100644
index 575cf71a..00000000
--- a/src/main/kotlin/app/morphe/desktop/command/model/SerializablePatch.kt
+++ /dev/null
@@ -1,107 +0,0 @@
-/*
- * Copyright 2026 Morphe.
- * https://github.com/MorpheApp/morphe-desktop
- */
-
-package app.morphe.desktop.command.model
-
-import app.morphe.patcher.patch.Patch
-import kotlinx.serialization.ExperimentalSerializationApi
-import kotlinx.serialization.KSerializer
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.descriptors.SerialDescriptor
-import kotlinx.serialization.descriptors.buildClassSerialDescriptor
-import kotlinx.serialization.descriptors.element
-import kotlinx.serialization.encoding.Decoder
-import kotlinx.serialization.encoding.Encoder
-import kotlinx.serialization.json.JsonElement
-import kotlinx.serialization.json.JsonEncoder
-import kotlinx.serialization.json.JsonNull
-import kotlinx.serialization.json.JsonPrimitive
-import kotlinx.serialization.json.buildJsonArray
-import kotlinx.serialization.json.buildJsonObject
-
-@ExperimentalSerializationApi
-@Serializable(with = PatchSerializer::class)
-data class SerializablePatch(
- val name: String? = null,
- val index: Int? = null,
- val options: Map = emptyMap()
-)
-
-@ExperimentalSerializationApi
-fun Patch<*>.toSerializablePatch(): SerializablePatch {
- return SerializablePatch(
- name = this.name,
- options = this.options.mapValues { PatchSerializer.serializeValue(it.value.value) }
- )
-}
-
-@ExperimentalSerializationApi
-object PatchSerializer : KSerializer {
- fun serializeValue(value: Any?): JsonElement {
- return when (value) {
- null -> JsonNull
- is JsonElement -> value
- is Boolean -> JsonPrimitive(value)
- is Number -> JsonPrimitive(value)
- is String -> JsonPrimitive(value)
- is List<*> -> {
- buildJsonArray {
- value.forEach { item ->
- add(serializeValue(item))
- }
- }
- }
- is Map<*, *> -> {
- buildJsonObject {
- value.forEach {
- require(it.key is String) {
- "Map keys must be of type String for serialization, but found: ${it.key?.let { k -> k::class }}"
- }
- put(it.key as String, serializeValue(it.value))
- }
- }
- }
- else -> JsonPrimitive(value.toString())
- }
- }
-
- override fun serialize(encoder: Encoder, value: SerializablePatch) {
- require(encoder is JsonEncoder)
-
- val jsonElement = buildJsonObject {
- require(value.name != null || value.index != null) {
- "Either name or index must be provided for a Patch."
- }
-
- if (value.name != null) {
- put("name", JsonPrimitive(value.name))
- } else {
- put("index", JsonPrimitive(value.index))
- }
-
- if (value.options.isNotEmpty()) {
- put("options", buildJsonArray {
- value.options.forEach { (key, optionValue) ->
- add(buildJsonObject {
- put("key", JsonPrimitive(key))
- put("value", optionValue)
- })
- }
- })
- }
- }
- encoder.encodeJsonElement(jsonElement)
- }
-
- override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Patch") {
- element("name")
- element("index")
- element