Configuration reference
morphe.toml is the only file a template user or fork needs to edit. It declares which apps to build, which patches to apply, which architectures to target, and where to download stock APKs from. No Python code is involved in adding or changing an app. See Build with GitHub Template for no-install workflow.
This document is the complete field reference. For a guided introduction, see getting started.
Some key names deliberately follow the user-facing vocabulary of j-hc/revanced-magisk-module so users migrating from it recognize them. The schema and implementation are unrelated, and no code was taken.
File structure
A configuration has one optional [toolchain] table and one or more ordered [[apps]] entries:
[toolchain]
morphe-source = "MorpheApp/morphe-desktop"
patches-source = "MorpheApp/morphe-patches"
[[apps]]
package = "com.google.android.youtube"
name = "YouTube"
# ... app fields
[apps.patch-options."SponsorBlock"]
toast-on-connection-error = true
[apps.google-play]
profile = "D2"
[apps.fallbacks]
apkmirror = ["https://www.apkmirror.com/apk/google-inc/youtube/"]Each [apps.*] subtable belongs to the [[apps]] entry that precedes it. Apps stay an ordered array of tables rather than named tables, because named tables lose ordering and produce worse validation errors than indexed ones (apps[1].slug must ...).
There is deliberately no [defaults] merge layer. With a small number of apps, repeating density, arch, and Google Play values keeps each entry readable in isolation; a merge layer would add resolution complexity for no real saving. Revisit that at four or more apps.
Validation rules
Parsing uses Python's standard-library tomllib and is strict by design. All of the following are rejected before any network access happens:
| Rejected | Example |
|---|---|
| Unknown keys | unknown apps[0] key: verison |
| Duplicate packages | two entries with the same package |
| Duplicate slugs | two entries resolving to the same output name |
| Unsupported enum values | build-mode = "zip" |
| Invalid package names | package = "not a package" |
| Invalid slugs | uppercase, underscores, or leading hyphens |
| Malformed signer pins | expected-signer that is not 64 hex characters |
| Conflicting patch selection | exclusive-patches together with include/exclude |
| Same patch included and excluded | the patch appears in both arrays |
| Options for unselected patches | patch-options naming an excluded patch |
| Version code without version name | version-code with version = "auto" |
| Non-scalar patch option values | arrays or tables as option values |
| Non-HTTPS or credential-bearing URLs | http://, or a URL containing user:pass@ |
Configuration failures exit with code 2.
[toolchain]
Defaults for every app. The whole table is optional.
| Key | Default | Meaning |
|---|---|---|
morphe-source | MorpheApp/morphe-desktop | Repository providing the Morphe CLI, as owner/repo. |
morphe-version | latest | CLI release tag, or latest. |
patches-source | MorpheApp/morphe-patches | Repository providing the patch bundle, as owner/repo. |
patches-version | latest | Patch bundle release tag, or latest. |
include-universal-patches | false | Include Morphe Universal patches in compatibility output. Per-app override available. |
include-experimental-versions | false | Include Morphe experimental versions in patch and version compatibility output. Per-app override available. |
Each app may override all four source/version values (using cli-source for the CLI repository) and both compatibility flags. Toolchain assets and provenance are keyed by source plus resolved release tag, so two apps on different patch sources never reuse each other's cache.
One app invokes Morphe with exactly one --patches bundle. Bundle stacking is unsupported: if you need patches from two repositories, they have to be combined upstream into one bundle.
[[apps]] identity
| Key | Required | Default | Meaning |
|---|---|---|---|
package | yes | — | Stock Android package name, validated against the package-name grammar. |
name | yes | — | Display name used in release notes, module metadata, and logs. |
slug | no | package with . replaced by - | Output, cache, and module name. Lowercase letters, digits, and hyphens only. Must be unique across apps. |
patched-package | no | unset | Package name expected after non-root patching. Omit it when patches keep the stock package. |
expected-signer | no | unset | Lowercase 64-character SHA-256 pin for the stock signing certificate. |
expect-corrupted-version-code | no | false | Verify patched APK output against known Morphe sentinel 2147483647, instead of stock version code. Narrow upstream-bug workaround; see below. |
slug is what you see on disk, so changing it changes output filenames, module IDs, and cache directories.
patched-package is verified after patching, which catches a patch bundle that silently stopped renaming the package. Root module payloads must keep the stock package name, so an app.morphe.* package is rejected for modules; see modules.md.
expected-signer opts an otherwise unpinned app into strict signer checking. Without it, one cryptographically valid and internally consistent signer served by the selected provider is accepted, its SHA-256 is recorded in provenance, and the build logs unpinned signer accepted. YouTube and YouTube Music have built-in pins that always apply. See google-play-integrity.md.
[[apps]] version selection
| Key | Required | Default | Meaning |
|---|---|---|---|
version | no | auto | auto, latest, or an exact version name such as 21.04.223. |
version-code | no | resolved | Explicit Google version code. Requires an explicit version. |
auto and latest both select the first version common to every selected patch, in Morphe's own version ordering. An exact version name is checked against the downloaded APK, so a provider that serves something else fails instead of silently building the wrong version.
version-code is rejected together with auto or latest, because without an expected version name there would be nothing to check the downloaded APK's identity against.
[[apps]] build targets
| Key | Required | Default | Values |
|---|---|---|---|
build-mode | no | apk | apk, module, both |
arch | no | arm64-v8a | arm64-v8a, arm-v7a, all, both |
density | no | unset | Split density preference, for example xxhdpi |
both and all expand into fully independent ARM64 and ARMv7 jobs, each with its own version resolution, download, merge, and output directory. One failing architecture fails the build rather than publishing a half matrix.
With density unset, the builder accepts only an unambiguous single density split. This is the right setting when a profile offers exactly one density, which is common with arch = "both".
[[apps]] patch selection
| Key | Required | Default | Meaning |
|---|---|---|---|
include-patches | no | [] | Patches to enable in addition to the bundle's defaults. |
exclude-patches | no | [] | Patches to disable. |
exclusive-patches | no | [] | Apply only these patches. Cannot be combined with include or exclude. |
patch-options | no | {} | Per-patch scalar options. |
Patch names must exist in the resolved bundle; unknown names fail with exit code 3. patch-options values must be TOML scalars (string, integer, float, boolean) and are passed to Morphe as argument-array values, never through shell interpolation. Every option must belong to a selected patch. Morphe's option keys are global across patches, so two selected patches declaring the same option key fail rather than silently taking one value.
[apps.patch-options."SponsorBlock"]
toast-on-connection-error = trueRun morphe-builder list-patches morphe.toml to see the resolved version and patch count per app before committing a selection change.
Universal patches and experimental versions
Morphe does not include Universal patches or experimental app versions unless requested. Both settings are false by default. Set them once under [toolchain], or override either for one app:
[toolchain]
include-universal-patches = false
include-experimental-versions = false
[[apps]]
package = "com.reddit.frontpage"
name = "Reddit"
include-universal-patches = true
include-experimental-versions = true
include-patches = ["Change package name"]Universal patches have no per-app compatible-version list, so they do not narrow selection. A selection containing only Universal patches is rejected because it cannot establish a safe source version.
Change package name upstream workaround
Current MorpheApp/morphe-patches changes android:versionCode to 2147483647 (Int.MAX_VALUE) while recompiling a patched manifest. This is an upstream bug. Stock APK identity and patched package name remain verified; enable this narrow workaround only for an affected app:
[[apps]]
package = "com.reddit.frontpage"
patched-package = "app.morphe.reddit.frontpage"
include-universal-patches = true
include-patches = ["Change package name"]
expect-corrupted-version-code = trueThe generated provenance continues to record the real downloaded version code. This check applies to non-root APKs and root module payloads. Root modules always exclude Change package name, because their payload must retain the stock package.
[apps.google-play]
| Key | Required | Default | Meaning |
|---|---|---|---|
profile | no | goopdl default | Device profile name. |
country | no | account region | Two-letter region code, uppercased. |
proxy | no | direct | HTTP or HTTPS proxy URL. |
dispenser | no | MORPHE_DISPENSER_URL or built-in | Anonymous token dispenser, HTTPS only. |
Leave profile unset when arch = "both": a single profile cannot represent both ABIs, and each architecture needs a valid profile of its own.
dispenser matters only when no Google account credentials are configured. With GOOPDL_ACCOUNT_EMAIL and GOOPDL_AAS_TOKEN set, goopdl authenticates directly and no dispenser is contacted; see Google Play authentication.
[apps.fallbacks]
Ordered alternative sources, used when Google Play cannot serve the requested app or version.
| Key | Accepts |
|---|---|
direct | Direct HTTPS APK asset URL. |
archive | Direct archive.org/download/... APK asset URL. |
apkmirror | Direct APK asset URL, or an APKMirror catalog page. |
uptodown | Direct APK asset URL, or an Uptodown catalog page. |
All entries must be HTTPS with no embedded credentials and no fragment. An empty table means Google Play is the only source, so a Play failure fails the job.
When an apkmirror URL does not end in .apk, the provider resolves catalog page, release page, architecture variant row, and download page before downloading. Configured and intermediate URLs must stay on an APKMirror host; the final redirect may also use APKMirror's Cloudflare R2 delivery host. Every other host fails closed. Uptodown resolves catalog, version, architecture variant, and download page, and must stay on Uptodown hosts throughout.
Catalog resolution parses third-party HTML, so it is inherently more fragile than a pinned asset URL. Mirror-supplied descriptions, filenames, sizes, and version labels are treated as untrusted hints: a downloaded file still passes full APK identity, version, architecture, and signature verification before it is published. Fallback order and terminal-failure rules: providers.md.
Google version-code mapping
Google Play addresses a build by version code, but configuration and patches speak version names. The mapping is resolved in a fixed order, and never guessed:
- Explicit
version-codefrom configuration. - Confirmed local mapping cache.
- APKMirror metadata candidate.
- Google download using the candidate version code.
- Verified fallback provider download.
The mapping cache key includes package, version name, architecture, profile, and region, and ARM64 and ARMv7 resolve independently. A candidate seen only in mirror metadata is never cached on its own; it is cached after a real download confirms it. A version-name mismatch fails immediately.
The builder never derives a version code arithmetically and never scans a range of candidate codes.
Morphe compatibility resolution
Compatibility uses reviewed Morphe Desktop CLI commands, invoked as argument arrays:
java -jar CLI list-patches --patches=PATCHES --filter-package-name=PACKAGE ...
java -jar CLI list-versions --patches=PATCHES --filter-package-names=PACKAGEANSI escape sequences and INFO: prefixes are stripped before parsing. The builder intersects the supported version sets of all selected patches, preserves Morphe's version ordering, and selects the first common version for auto and latest.
Unknown patch names, missing packages, incompatible overrides, and empty intersections all fail with exit code 3. An empty intersection usually means two selected patches have no app version in common, so the fix is a narrower selection, not a different version.
Dependency quarantine
[tool.uv].exclude-newer = "3 days" in pyproject.toml refuses distributions uploaded in the previous 72 hours when resolving dependencies, which blunts a freshly published malicious release. The setting lives in the project file so it overrides user configuration, and the resulting resolution is recorded in uv.lock.
Run uv lock after an intentional dependency change. CI runs uv lock --check before installation, so an unrecorded change fails the build.
