Patch Reference

Complete reference for ipa-forge patch definition YAML: every operation type, field, version-matching rule, and the hooks block.

This is the complete reference for writing patch definition files for ipa-forge. A patch definition is a single YAML (or JSON) file that describes what to change in an app bundle, keyed by bundle id + version. ipa-forge never embeds app-specific logic — everything it does to an app comes from files like this.

Working examples live in fixtures/patches/:

  • example.yamlbinary_replace + resource_replace
  • example_dylib_inject.yamldylib_inject
  • example_plist_edit.yamlplist_edit

File structure

target:            # which app this definition applies to (required)
  bundle_id: "com.example.app"
  version: { exact: "1.2.3" }     # or { min: "1.0.0", max: "2.0.0" }

patches:           # one or more operations (required, non-empty)
  - id: "my-op-1"                 # arbitrary unique id, shown in output
    type: binary_replace          # one of the types below
    # ... type-specific fields ...

Definitions are loaded with yaml.safe_load (no arbitrary code execution) and validated against a pydantic schema; a file that fails validation produces a single-line actionable error, never a traceback. An empty patches: list is rejected — a definition with no operations is always a mistake.

The target block — matching

The definition only applies when both conditions hold:

Prop

Type

Version matching

Two forms, mutually exclusive:

version: { exact: "1.2.3" }          # string equality against the bundle's version
version: { min: "1.0.0", max: "2.0.0" }   # numeric range
  • exact compares the raw strings: exact: "1.0.0" will not match a bundle whose version is 1.0.0-beta.
  • min/max compare numerically. max is exclusivemax: "2.0.0" matches 1.9.9 and 1.5.0-beta but not 2.0.0.
  • Range matching strips non-numeric segments, so 1.0.0-beta and 1.0.0 are considered equal for min/max purposes.
  • Both min and max are optional; {min: "1.0.0"} means "1.0.0 or later".

What happens when the target does not match

If the supplied definition's target does not match the IPA you patched, zero operations resolve:

  • forge patch --dry-run warns ("matches 0 patch operations ... nothing will be applied") and still succeeds — useful for confirming a version gate.
  • a real (non-dry-run) forge patch fails with "refusing to produce an unpatched IPA". A typo'd bundle id can never silently produce an unpatched output.

Common fields

Prop

Type

Operation types

binary_replace — deterministic byte patching

Replaces an exact byte sequence inside a Mach-O executable with a fixed replacement.

- id: "zero-marker-bytes"
  type: binary_replace
  executable: "TestApp"        # basename of the Mach-O file inside the bundle
  arch: "arm64"                # required for fat/universal binaries
  pattern: "ca fe f0 0d de ad" # space-separated hex bytes
  replacement: "00 00 00 00 00 00"
  expected_matches: 1          # optional, default 1

Prop

Type

Semantics:

  • The search is bounded to one architecture slice: for a fat/universal binary the pattern is only matched within the selected slice's byte range, so a patch can never silently land in the wrong architecture. For thin binaries the whole file is searched and arch is unnecessary.
  • A universal binary without arch is an error ("an explicit arch: selector is required") — ipa-forge refuses to guess.
  • Supported arch names: arm64, armv7, x86_64, i386.
  • If the pattern occurs more than once, every occurrence is replaced (and expected_matches must match the actual count).
  • The operation fails during dry run if: the executable isn't in the bundle, the pattern is malformed, the file isn't Mach-O, or the match count differs.

resource_replace — overwrite a bundle file

- id: "swap-asset"
  type: resource_replace
  path: "asset.txt"                       # bundle-relative destination
  source: "assets/patched_asset.txt"      # relative to the definition file

Prop

Type

  • source may also be an absolute path or ../ reference — the definition, the IPA, and the signing credentials are all supplied by the same trusted user, so sources are intentionally not sandboxed.
  • Fails in dry run if the source is missing or the destination doesn't exist (use resource_add to create new files instead).

resource_add — add a new bundle file

- id: "add-hook-lib"
  type: resource_add
  path: "Frameworks/libHook.dylib"
  source: "assets/libHook.dylib"
  • Identical fields to resource_replace.
  • Creates intermediate directories as needed.
  • Fails in dry run if the destination already exists (use resource_replace to overwrite) or the source is missing.

resource_remove — delete a bundle file

- id: "remove-obsolete-asset"
  type: resource_remove
  path: "obsolete.txt"

Prop

Type

  • Fails in dry run if the destination doesn't exist or is a directory.

dylib_inject — add a dylib load command

Adds an LC_LOAD_DYLIB (or LC_LOAD_WEAK_DYLIB) entry to a Mach-O so the runtime loads the named dylib.

- id: "inject-hook"
  type: dylib_inject
  executable: "TestApp"
  arch: "arm64"                     # required for fat/universal binaries
  install_name: "@rpath/libHook.dylib"   # the load path, not a source file
  load_command: "LC_LOAD_DYLIB"     # optional; or LC_LOAD_WEAK_DYLIB

Prop

Type

This operation does not copy the dylib into the bundle

The dylib file must already be present (shipped with the app, or placed there with a resource_add). The standard pattern is:

patches:
  - id: "stage-dylib"
    type: resource_add
    path: "Frameworks/libHook.dylib"
    source: "assets/libHook.dylib"
  - id: "link-dylib"
    type: dylib_inject
    executable: "TestApp"
    install_name: "@rpath/libHook.dylib"

Outcomes (reported per-operation in the manifest):

Prop

Type

Dylib injection is deliberately the most fragile operation — it rewrites Mach-O load commands and forces a full re-sign. Prefer binary_replace or resource operations when they can express the change.

plist_edit — set/remove Info.plist keys

- id: "set-display-name"
  type: plist_edit
  action: "set"            # or "remove"
  key: "CFBundleDisplayName"
  value: "Patched App"     # required when action is set
  path: "Info.plist"       # optional, bundle-relative, defaults to Info.plist

Prop

Type

  • remove fails in dry run if the key is not present.
  • Works on binary or XML plists transparently.

How operations run: the dry-run gate and ordering

The dry-run gate

forge patch never mutates anything until every operation has reported dry_run_ok. If any operation would fail, the whole patch is rejected before a single file changes.

When applying, operations run in a fixed order:

Resource operations and plist editsresource_replace/resource_add/resource_remove and plist_edit run first; they don't touch Mach-O layout.

Binary patchesbinary_replace raw byte edits.

Dylib injectiondylib_inject runs last, because LIEF's load-command rewrites can shift byte offsets that binary patches depend on.

Order within a group follows the order in the YAML file.

The hooks block — verify runtime hooks against the binary

Dylib-injection tweaks live or die on ObjC runtime hooks: the dylib swizzles -[Class selector], and when a newer app version renames or removes that class/selector the hook silently no-ops — the feature just stops working with no error. The hooks: block declares every hook the patch set relies on so forge can verify it against the actual binary before anything mutates:

hooks:
  - class: "AppConfig"
    selector: "isFeatureFlagEnabled"
    kind: instance        # instance (default) | class
  - class: "PlaybackResponse"
    selector: "extraDataArray"
    added: true           # the tweak adds this method itself, it doesn't exist in the app
  - class: "SessionService"
    selector: "urlFromURL:withAdditionalFragmentParameters:"
    required: true        # fail the run if this hook can't attach

Prop

Type

forge patch --dry-run verifies every declared hook against the app's main binary (class table + method lists + selrefs, chained-fixup aware) and prints a summary. The forge hooks commands do the same without patching, and every one of them accepts --app-dir Payload/<App>.app in place of --ipa to skip re-extraction when iterating on the same app.

Dry run OK -- 4 operation(s) would apply.
Hooks: 151/159 attach (8 issue(s))
  ! SettingsController -[isHintsDisabled]: unverified -- class exists ... walk missed it

Hook statuses

Prop

Type

referenced-only is the dead-hook detector: the binary references the selector (selrefs / NSSelectorFromString / optional protocol sends) but does not declare it as a method anywhere (__objc_methname has no such name), so there is no IMP to swizzle and class_getInstanceMethod returns NULL — the hook cannot attach. This is the classic "the verify says it attaches but the feature does nothing" trap (some selectors are referenced by the app but implemented by nothing in a given build). Distinguish it from unverified, which means the selector is declared as a method somewhere (a protocol, a category, or a method list the walker did not decode) — the hook may well attach.

unverified is usually a parser gap, not real drift

When a class or selector the walk missed is still present as a string in some binary image (the old manual strings <binary> | grep cross-check), the report says so — "class-name string present … the walk missed it (likely attaches)" / "selector declared as a method somewhere … (likely attaches)". The parser under-reports methods on protobuf-generated message classes and large Swift class tables this way; Swift-mangled (_TtC…) classes absent from the parsed table are always reported unverified, never mislabeled as system classes.

Porting to a new app version becomes: bump target.version, run --dry-run, read the hook report, and fix exactly the hooks the report flags. forge hooks manifest --dir <dylib-sources> regenerates the hooks: block from the tweak sources (inline NSClassFromString calls, unambiguous variable assignments, class names passed through a local resolver helper — a function whose body calls NSClassFromString invoked as resolver("X") at the hook call site — and <prefix>HookConfigBool config-flag getters); pass --inplace patch.yaml to write the block straight into the patch definition. forge hooks diff --old A.ipa --new B.ipa --patches patch.yaml shows which hooks regressed between two versions.

forge hooks find <selector> --ipa App.ipa is the reverse lookup — which classes implement a selector, plus the referenced-only warning when nothing does. Check it first when a hook "attaches per verify" but does nothing on device. forge hooks extract --ipa App.ipa --class <name> prints the FULL method list (no truncation) — pipe large config classes through grep.

A hook not declared in the hooks: block is invisible to --dry-run

--dry-run only checks declared hooks; a hook the tweak source calls but the block omits produces no error, no warning. forge hooks audit --ipa App.ipa --dir <dylib-sources> --patches patch.yaml closes that gap: it scans the sources the same way manifest does, then diffs what it found against what's declared, printing every hook that's missing and exiting 1 if any are. Run it as a standard part of the loop above (after manifest, alongside --dry-run), not only when a feature seems broken — this is how real, load-bearing hooks have been found missing from shipped patch sets after the fact, with --dry-run green the whole time.

The manifest

Every run produces a structured manifest (--verbose prints it; the GUI shows it in the result card):

{
  "input_sha256": "...",
  "bundle_id": "com.example.synthetic",
  "version": "1.0.0",
  "build": "1",
  "patches_applied": [
    {"id": "zero-marker-bytes", "status": "applied", "message": "replaced 1 match(es) at [0]"}
  ],
  "files_added": [], "files_modified": [...], "files_removed": [],
  "macho_modified": [".../TestApp"],
  "profile": {"uuid": "...", "team_id": "...", "expiration": "..."},
  "output_sha256": "..."
}

Use it to verify exactly what changed (and what didn't) before installing.

Common mistakes

On this page