ipa-forge 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. ipa_forge/__init__.py +1 -0
  2. ipa_forge/altstore/__init__.py +1 -0
  3. ipa_forge/altstore/source.py +44 -0
  4. ipa_forge/analysis/__init__.py +18 -0
  5. ipa_forge/analysis/classdump.py +103 -0
  6. ipa_forge/analysis/diff.py +127 -0
  7. ipa_forge/analysis/security.py +72 -0
  8. ipa_forge/analysis/strings.py +40 -0
  9. ipa_forge/analysis/symbols.py +42 -0
  10. ipa_forge/analysis/type_encoding.py +164 -0
  11. ipa_forge/bundle/__init__.py +1 -0
  12. ipa_forge/bundle/inventory.py +96 -0
  13. ipa_forge/bundle/ipa.py +81 -0
  14. ipa_forge/bundle/models.py +58 -0
  15. ipa_forge/bundle/plist.py +18 -0
  16. ipa_forge/cli/__init__.py +1 -0
  17. ipa_forge/cli/analysis.py +225 -0
  18. ipa_forge/cli/common.py +41 -0
  19. ipa_forge/cli/hooks.py +357 -0
  20. ipa_forge/cli/main.py +170 -0
  21. ipa_forge/gui/__init__.py +1 -0
  22. ipa_forge/gui/analysis.html +229 -0
  23. ipa_forge/gui/app.py +304 -0
  24. ipa_forge/gui/index.html +244 -0
  25. ipa_forge/hooks/__init__.py +0 -0
  26. ipa_forge/hooks/scan.py +81 -0
  27. ipa_forge/hooks/verify.py +348 -0
  28. ipa_forge/machO/__init__.py +1 -0
  29. ipa_forge/machO/arch.py +94 -0
  30. ipa_forge/machO/detect.py +50 -0
  31. ipa_forge/machO/injector.py +99 -0
  32. ipa_forge/machO/objc.py +534 -0
  33. ipa_forge/manifest.py +74 -0
  34. ipa_forge/patch/__init__.py +1 -0
  35. ipa_forge/patch/base.py +48 -0
  36. ipa_forge/patch/binary.py +131 -0
  37. ipa_forge/patch/dylib.py +59 -0
  38. ipa_forge/patch/engine.py +48 -0
  39. ipa_forge/patch/loader.py +88 -0
  40. ipa_forge/patch/paths.py +19 -0
  41. ipa_forge/patch/plist.py +61 -0
  42. ipa_forge/patch/resolver.py +37 -0
  43. ipa_forge/patch/resource.py +135 -0
  44. ipa_forge/patch/schema.py +118 -0
  45. ipa_forge/patch/version.py +25 -0
  46. ipa_forge/patches.py +76 -0
  47. ipa_forge/pipeline.py +235 -0
  48. ipa_forge/signing/__init__.py +1 -0
  49. ipa_forge/signing/backend.py +111 -0
  50. ipa_forge/signing/pipeline.py +99 -0
  51. ipa_forge/signing/profile.py +131 -0
  52. ipa_forge/signing/provider.py +113 -0
  53. ipa_forge/signing/reconcile.py +27 -0
  54. ipa_forge/validators/__init__.py +1 -0
  55. ipa_forge/validators/archive_validator.py +25 -0
  56. ipa_forge/validators/bundle_validator.py +27 -0
  57. ipa_forge/validators/ipa_validator.py +39 -0
  58. ipa_forge-0.1.0.dist-info/METADATA +914 -0
  59. ipa_forge-0.1.0.dist-info/RECORD +62 -0
  60. ipa_forge-0.1.0.dist-info/WHEEL +4 -0
  61. ipa_forge-0.1.0.dist-info/entry_points.txt +2 -0
  62. ipa_forge-0.1.0.dist-info/licenses/LICENSE +674 -0
ipa_forge/cli/hooks.py ADDED
@@ -0,0 +1,357 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """`forge hooks` subcommands: verify / extract / audit / manifest / diff.
3
+
4
+ Hook verification catches the silent no-ops that version drift causes: a
5
+ class/selector rename in a newer app build and the dylib hook just stops
6
+ firing. These commands turn that into a checkable report against the actual
7
+ binary (main executable + every embedded framework).
8
+
9
+ Every command accepts ``--app-dir`` in place of the IPA: point it at an
10
+ already-extracted ``Payload/<App>.app`` directory to skip the (potentially
11
+ slow) re-extraction when iterating on the same app — e.g. after one
12
+ ``forge patch`` run the working bundle can be reused for the whole
13
+ verify/fix/verify loop.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import tempfile
19
+ from collections import Counter
20
+ from pathlib import Path
21
+
22
+ import typer
23
+
24
+ from ipa_forge.bundle.ipa import load_bundle
25
+ from ipa_forge.cli.common import extract_or_use as _extract_or_use
26
+ from ipa_forge.cli.common import resolve_app_path
27
+ from ipa_forge.hooks.scan import scan_hook_sources
28
+ from ipa_forge.hooks.verify import HookDecl, verify_hooks
29
+ from ipa_forge.machO.objc import analyze_bundle
30
+ from ipa_forge.patch.loader import load_patch_definition
31
+
32
+ app = typer.Typer(help="Mach-O Objective-C hook verification (catches silent no-ops on version drift).")
33
+
34
+
35
+ @app.command("verify")
36
+ def hooks_verify(
37
+ ipa: Path | None = typer.Option(None, "--ipa", exists=True, help="Input .ipa (not needed when --app-dir is given)"),
38
+ patches: Path = typer.Option(..., "--patches", exists=True, help="Patch definition with a `hooks:` section"),
39
+ app_dir: Path | None = typer.Option(
40
+ None,
41
+ "--app-dir",
42
+ exists=True,
43
+ help="Already-extracted Payload/<App>.app directory to analyze instead of re-extracting the IPA",
44
+ ),
45
+ required_only: bool = typer.Option(False, "--required-only", help="Only print hooks that fail to attach"),
46
+ ) -> None:
47
+ """Verify every declared hook in the patch definition against the app binary.
48
+ Exit code 0 = all required hooks attach; 1 = a required hook is missing."""
49
+ with tempfile.TemporaryDirectory(prefix="ipa_forge_hooks_") as tmp:
50
+ app_path = _extract_or_use(ipa, app_dir, Path(tmp))
51
+ try:
52
+ bundle = load_bundle(app_path)
53
+ definition = load_patch_definition(patches)
54
+ except ValueError as e:
55
+ typer.secho(f"error: {e}", fg=typer.colors.RED, err=True)
56
+ raise typer.Exit(code=1) from None
57
+ decls = [HookDecl(h.class_name, h.selector, h.kind, h.added, h.required) for h in (definition.hooks or [])]
58
+ if not decls:
59
+ typer.echo("No hooks declared in this definition (add a `hooks:` section).")
60
+ raise typer.Exit(code=0)
61
+ analysis = analyze_bundle(bundle)
62
+ results = verify_hooks(analysis, decls)
63
+ for r in results:
64
+ if required_only and r.ok:
65
+ continue
66
+ color = typer.colors.GREEN if r.ok else typer.colors.YELLOW
67
+ flag = "" if r.ok else f" ({r.detail})"
68
+ label = f"[{r.status:16}] {r.class_name} "
69
+ label += f"{'+' if r.kind == 'class' else '-'}[{r.selector}]{flag}"
70
+ typer.secho(label, fg=color)
71
+ bad = [r for r in results if not r.ok and r.required]
72
+ ok = sum(1 for r in results if r.ok)
73
+ typer.echo(f"{ok}/{len(results)} hooks attach; {len(bad)} required hook(s) failing.")
74
+ raise typer.Exit(code=1 if bad else 0)
75
+
76
+
77
+ @app.command("extract")
78
+ def hooks_extract(
79
+ ipa: Path | None = typer.Option(None, "--ipa", exists=True, help="Input .ipa (not needed when --app-dir is given)"),
80
+ app_dir: Path | None = typer.Option(
81
+ None,
82
+ "--app-dir",
83
+ exists=True,
84
+ help="Already-extracted Payload/<App>.app directory to analyze instead of re-extracting the IPA",
85
+ ),
86
+ class_name: str | None = typer.Option(None, "--class", help="Restrict to one class"),
87
+ search: str | None = typer.Option(None, "--search", help="Only classes whose name matches this regex"),
88
+ limit: int = typer.Option(60, "--limit", help="Max classes to print (0 = no limit)"),
89
+ ) -> None:
90
+ """Dump the Objective-C class table of the app's binaries: class names,
91
+ superclasses, and instance/class method lists (chained-fixup aware)."""
92
+ import re as _re
93
+
94
+ with tempfile.TemporaryDirectory(prefix="ipa_forge_hooks_") as tmp:
95
+ app_path = _extract_or_use(ipa, app_dir, Path(tmp))
96
+ bundle = load_bundle(app_path)
97
+ analysis = analyze_bundle(bundle)
98
+ if class_name:
99
+ cls = analysis.classes.get(class_name)
100
+ if not cls:
101
+ in_names = class_name in analysis.classnames
102
+ present = any((b"\x00" + class_name.encode("utf-8") + b"\x00") in data for data in analysis.raw_data)
103
+ typer.secho(
104
+ f"class '{class_name}' not parsed (classname string: {in_names}; raw string present: {present})",
105
+ fg=typer.colors.YELLOW,
106
+ )
107
+ raise typer.Exit(code=1)
108
+ targets = [cls]
109
+ elif search:
110
+ pat = _re.compile(search)
111
+ targets = [c for c in analysis.classes.values() if pat.search(c.name)]
112
+ targets.sort(key=lambda c: c.name)
113
+ else:
114
+ targets = sorted(analysis.classes.values(), key=lambda c: c.name)
115
+ if limit:
116
+ targets = targets[:limit]
117
+ for cls in targets:
118
+ typer.echo(f"{cls.name} : {cls.super_name} (inst={len(cls.inst)} cls={len(cls.cls)})")
119
+ # Full method lists, not truncated: a `[:40]` slice silently hid real
120
+ # methods on large config classes (e.g. YTColdConfig's 7k getters) and
121
+ # sent users greping for selectors that WERE there. Pipe to grep.
122
+ if cls.inst:
123
+ typer.echo(" inst: " + ", ".join(sorted(cls.inst)))
124
+ if cls.cls:
125
+ typer.echo(" class: " + ", ".join(sorted(cls.cls)))
126
+
127
+
128
+ @app.command("audit")
129
+ def hooks_audit(
130
+ ipa: Path | None = typer.Option(None, "--ipa", exists=True, help="Input .ipa (not needed when --app-dir is given)"),
131
+ dylib_src: Path = typer.Option(..., "--dir", exists=True, help="Directory of tweak sources (*.m/*.h) to scan"),
132
+ app_dir: Path | None = typer.Option(
133
+ None,
134
+ "--app-dir",
135
+ exists=True,
136
+ help="Already-extracted Payload/<App>.app directory to analyze instead of re-extracting the IPA",
137
+ ),
138
+ patches: Path | None = typer.Option(
139
+ None,
140
+ "--patches",
141
+ exists=True,
142
+ help="Patch definition with a `hooks:` section -- cross-check it against what the source scan finds "
143
+ "and flag hooks the source calls that aren't declared (so --dry-run isn't gating on them)",
144
+ ),
145
+ ) -> None:
146
+ """Scan ObjC tweak sources for hook calls and verify each target against
147
+ the app binary. Catches hooks the author wrote but a newer app version
148
+ broke. With --patches, also catches hooks the author wrote but forgot to
149
+ declare in the `hooks:` block -- those never get checked by --dry-run."""
150
+ decls = scan_hook_sources(dylib_src)
151
+ if not decls:
152
+ typer.echo("No hook calls found in the sources.")
153
+ raise typer.Exit(code=0)
154
+ with tempfile.TemporaryDirectory(prefix="ipa_forge_hooks_") as tmp:
155
+ app_path = _extract_or_use(ipa, app_dir, Path(tmp))
156
+ bundle = load_bundle(app_path)
157
+ analysis = analyze_bundle(bundle)
158
+ results = verify_hooks(analysis, decls)
159
+ statuses = Counter(r.status for r in results)
160
+ typer.echo(f"{len(results)} hooks found in sources: " + ", ".join(f"{k}={v}" for k, v in sorted(statuses.items())))
161
+ for r in results:
162
+ if not r.ok:
163
+ typer.secho(
164
+ f"[{r.status:16}] {r.class_name} {'+' if r.kind == 'class' else '-'}[{r.selector}] {r.detail}",
165
+ fg=typer.colors.YELLOW,
166
+ )
167
+
168
+ if patches is None:
169
+ return
170
+ definition = load_patch_definition(patches)
171
+ declared = {(h.class_name, h.selector) for h in (definition.hooks or [])}
172
+ undeclared = [d for d in decls if (d.class_name, d.selector) not in declared]
173
+ if undeclared:
174
+ typer.echo("")
175
+ typer.secho(
176
+ f"{len(undeclared)} hook(s) found in sources but missing from {patches.name}'s `hooks:` block "
177
+ f"(--dry-run does not gate on these):",
178
+ fg=typer.colors.RED,
179
+ )
180
+ for d in undeclared:
181
+ typer.secho(f" {d.class_name} {'+' if d.kind == 'class' else '-'}[{d.selector}]", fg=typer.colors.RED)
182
+ raise typer.Exit(code=1)
183
+ typer.echo(f"\nAll {len(decls)} source hooks are declared in {patches.name}'s `hooks:` block.")
184
+
185
+
186
+ @app.command("find")
187
+ def hooks_find(
188
+ selector: str = typer.Argument(..., help="Selector to look up, e.g. 'didPressVarispeed:'"),
189
+ ipa: Path | None = typer.Option(None, "--ipa", exists=True, help="Input .ipa (not needed when --app-dir is given)"),
190
+ app_dir: Path | None = typer.Option(
191
+ None,
192
+ "--app-dir",
193
+ exists=True,
194
+ help="Already-extracted Payload/<App>.app directory to analyze instead of re-extracting the IPA",
195
+ ),
196
+ ) -> None:
197
+ """Reverse lookup: which classes implement a selector? Distinguishes a real
198
+ method (swizzle-able) from a bare reference (referenced-only — no IMP
199
+ exists, the hook cannot attach). This is the first check when a hook
200
+ 'attaches per verify' but does nothing on device."""
201
+ with tempfile.TemporaryDirectory(prefix="ipa_forge_hooks_") as tmp:
202
+ app_path = _extract_or_use(ipa, app_dir, Path(tmp))
203
+ bundle = load_bundle(app_path)
204
+ analysis = analyze_bundle(bundle)
205
+
206
+ inst: list[str] = []
207
+ cls: list[str] = []
208
+ for name, c in analysis.classes.items():
209
+ if selector in c.inst:
210
+ inst.append(name)
211
+ if selector in c.cls:
212
+ cls.append(name)
213
+
214
+ if inst:
215
+ typer.echo(f"instance method on {len(inst)} class(es):")
216
+ for n in sorted(inst):
217
+ typer.echo(f" -[{n} {selector}]")
218
+ if cls:
219
+ typer.echo(f"class method on {len(cls)} class(es):")
220
+ for n in sorted(cls):
221
+ typer.echo(f" +[{n} {selector}]")
222
+
223
+ if not inst and not cls:
224
+ if selector in analysis.methnames:
225
+ typer.secho(
226
+ f"declared as a method name somewhere (protocol/category) but no parsed class implements "
227
+ f"{selector} — the hook may attach if a class you did not scan provides it",
228
+ fg=typer.colors.YELLOW,
229
+ )
230
+ elif selector in analysis.selectors:
231
+ typer.secho(
232
+ f"REFERENCED-ONLY: the binary references {selector} but no class declares it — "
233
+ f"there is no IMP to swizzle, so a hook on it cannot attach",
234
+ fg=typer.colors.RED,
235
+ )
236
+ else:
237
+ typer.secho(f"{selector} not found anywhere in the binary", fg=typer.colors.RED)
238
+
239
+ # similar selectors, to catch renames
240
+ similar = sorted(s for s in analysis.selectors if selector in s and s != selector)[:10]
241
+ if similar:
242
+ typer.echo("\nselectors containing the lookup text:")
243
+ for s in similar:
244
+ typer.echo(f" {s}")
245
+
246
+
247
+ @app.command("manifest")
248
+ def hooks_manifest(
249
+ dylib_src: Path = typer.Option(..., "--dir", exists=True, help="Directory of tweak sources (*.m)"),
250
+ required: Path | None = typer.Option(
251
+ None, "--required", exists=True, help="Optional file with 'ClassName selector' lines to mark required: true"
252
+ ),
253
+ inplace: Path | None = typer.Option(
254
+ None,
255
+ "--inplace",
256
+ exists=False,
257
+ help="Patch definition YAML to update in place: replace its `hooks:` block with the generated one",
258
+ ),
259
+ ) -> None:
260
+ """Emit a `hooks:` YAML block from the hook calls in tweak sources — the
261
+ block goes into the patch definition's `hooks:` section so dry-run
262
+ verifies every hook against the real binary. With --inplace the block is
263
+ written straight into the patch definition (replacing the old hooks).
264
+ Covers ytfHookInstance/ytfHookClass/ytfHookConfigBool/ytfAddInstanceMethod."""
265
+ decls = scan_hook_sources(dylib_src)
266
+ if not decls:
267
+ typer.echo("No hook calls found in the sources.")
268
+ raise typer.Exit(code=0)
269
+
270
+ required_set: set[tuple[str, str]] = set()
271
+ if required:
272
+ for line in required.read_text().splitlines():
273
+ line = line.strip()
274
+ if not line or line.startswith("#"):
275
+ continue
276
+ parts = line.split(None, 1)
277
+ if len(parts) == 2:
278
+ required_set.add((parts[0], parts[1]))
279
+
280
+ lines = ["hooks:"]
281
+ for d in decls:
282
+ lines.append(f' - class: "{d.class_name}"')
283
+ lines.append(f' selector: "{d.selector}"')
284
+ lines.append(f" kind: {d.kind}")
285
+ if d.added:
286
+ lines.append(" added: true")
287
+ if (d.class_name, d.selector) in required_set:
288
+ lines.append(" required: true")
289
+ block = "\n".join(lines) + "\n"
290
+
291
+ if inplace:
292
+ text = inplace.read_text()
293
+ head = text.split("\nhooks:\n", 1)[0]
294
+ inplace.write_text(head + "\n" + block)
295
+ typer.echo(f"wrote {len(decls)} hooks into {inplace}")
296
+ raise typer.Exit(code=0)
297
+
298
+ typer.echo(block.rstrip("\n"))
299
+ typer.secho(f"# {len(decls)} hooks ({len(required_set)} required)", fg=typer.colors.BRIGHT_BLACK)
300
+
301
+
302
+ @app.command("diff")
303
+ def hooks_diff(
304
+ old_ipa: Path | None = typer.Option(
305
+ None, "--old", exists=True, help="Previously-working .ipa (not needed with --old-app-dir)"
306
+ ),
307
+ new_ipa: Path | None = typer.Option(None, "--new", exists=True, help="New .ipa (not needed with --new-app-dir)"),
308
+ patches: Path = typer.Option(..., "--patches", exists=True, help="Patch definition with a `hooks:` section"),
309
+ old_app_dir: Path | None = typer.Option(
310
+ None, "--old-app-dir", exists=True, help="Extracted Payload/<App>.app of the old build"
311
+ ),
312
+ new_app_dir: Path | None = typer.Option(
313
+ None, "--new-app-dir", exists=True, help="Extracted Payload/<App>.app of the new build"
314
+ ),
315
+ ) -> None:
316
+ """Compare hook attachment between two IPAs — the porting aid. Shows every
317
+ hook whose status changed, highlighting ones that regressed (would no-op
318
+ on the new version). Exit 1 if a required hook regressed."""
319
+ with tempfile.TemporaryDirectory(prefix="ipa_forge_hooks_") as tmp:
320
+ try:
321
+ old_path = resolve_app_path(old_ipa, old_app_dir, Path(tmp) / "old")
322
+ new_path = resolve_app_path(new_ipa, new_app_dir, Path(tmp) / "new")
323
+ except ValueError as e:
324
+ typer.secho(f"error: {e}", fg=typer.colors.RED, err=True)
325
+ raise typer.Exit(code=1) from None
326
+ bundle_old = load_bundle(old_path)
327
+ bundle_new = load_bundle(new_path)
328
+ definition = load_patch_definition(patches)
329
+ decls = [HookDecl(h.class_name, h.selector, h.kind, h.added, h.required) for h in (definition.hooks or [])]
330
+ if not decls:
331
+ typer.echo("No hooks declared in the definition (add a `hooks:` section).")
332
+ raise typer.Exit(code=0)
333
+ old_results = {r.class_name + r.selector: r for r in verify_hooks(analyze_bundle(bundle_old), decls)}
334
+ new_results = {r.class_name + r.selector: r for r in verify_hooks(analyze_bundle(bundle_new), decls)}
335
+
336
+ regressed = 0
337
+ for d in decls:
338
+ key = d.class_name + d.selector
339
+ old = old_results.get(key)
340
+ new = new_results.get(key)
341
+ if old is None or new is None:
342
+ continue
343
+ if old.ok and not new.ok:
344
+ regressed += 1
345
+ typer.secho(
346
+ f"[{old.status:14} -> {new.status:14}] {d.class_name} "
347
+ f"{'+' if d.kind == 'class' else '-'}[{d.selector}] REGRESSED",
348
+ fg=typer.colors.RED,
349
+ )
350
+ elif old.status != new.status:
351
+ typer.secho(
352
+ f"[{old.status:14} -> {new.status:14}] {d.class_name} "
353
+ f"{'+' if d.kind == 'class' else '-'}[{d.selector}]",
354
+ fg=typer.colors.YELLOW,
355
+ )
356
+ typer.echo(f"{regressed} hook(s) regressed" + (" — required hook broken" if regressed else "."))
357
+ raise typer.Exit(code=1 if regressed else 0)
ipa_forge/cli/main.py ADDED
@@ -0,0 +1,170 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ from __future__ import annotations
3
+
4
+ import tempfile
5
+ from importlib.metadata import PackageNotFoundError
6
+ from importlib.metadata import version as _installed_version
7
+ from pathlib import Path
8
+
9
+ import typer
10
+
11
+ from ipa_forge.altstore.source import build_app_entry, write_source_json
12
+ from ipa_forge.bundle.ipa import load_bundle
13
+ from ipa_forge.cli import analysis as _analysis # `forge analysis` subcommands
14
+ from ipa_forge.cli import hooks as _hooks # `forge hooks` subcommands
15
+ from ipa_forge.cli.common import validated_extract
16
+ from ipa_forge.pipeline import PipelineError, run_pipeline
17
+ from ipa_forge.validators.bundle_validator import validate_bundle
18
+
19
+ app = typer.Typer()
20
+
21
+
22
+ def _package_version() -> str:
23
+ try:
24
+ return _installed_version("ipa-forge")
25
+ except PackageNotFoundError: # not installed (e.g. run from a source checkout)
26
+ return "0.1.0"
27
+
28
+
29
+ def _version_callback(value: bool) -> None:
30
+ if value:
31
+ typer.echo(f"ipa-forge {_package_version()}")
32
+ raise typer.Exit()
33
+
34
+
35
+ @app.callback()
36
+ def main(
37
+ version: bool = typer.Option(False, "--version", callback=_version_callback, help="Show version and exit."),
38
+ ) -> None:
39
+ """Generic, data-driven iOS IPA patcher with AltStore Classic re-signing support."""
40
+
41
+
42
+ def _validated_extract(ipa: Path, dest: Path) -> Path:
43
+ """Alias of cli.common.validated_extract with a clean CLI error."""
44
+ try:
45
+ return validated_extract(ipa, dest)
46
+ except ValueError as e:
47
+ typer.secho(f"error: {e}", fg=typer.colors.RED, err=True)
48
+ raise typer.Exit(code=1) from None
49
+
50
+
51
+ @app.command()
52
+ def inspect(ipa: Path = typer.Argument(..., exists=True, help="Path to the .ipa to inspect")) -> None:
53
+ """Print bundle id, version, and the bottom-up executable inventory for an IPA."""
54
+ with tempfile.TemporaryDirectory(prefix="ipa_forge_inspect_") as tmp:
55
+ app_path = _validated_extract(ipa, Path(tmp))
56
+ bundle = load_bundle(app_path)
57
+ typer.echo(f"Bundle ID: {bundle.bundle_id}")
58
+ typer.echo(f"Version: {bundle.version} (build {bundle.build})")
59
+ typer.echo(f"Main exec: {bundle.main_executable_name}")
60
+ typer.echo("Executables (bottom-up sign order):")
61
+ for target in bundle.executables:
62
+ typer.echo(f" [{target.kind:10}] {target.bundle_relative}")
63
+
64
+
65
+ @app.command()
66
+ def validate(ipa: Path = typer.Argument(..., exists=True, help="Path to the .ipa to validate")) -> None:
67
+ """Validate IPA structure and Info.plist without patching or signing anything."""
68
+ with tempfile.TemporaryDirectory(prefix="ipa_forge_validate_") as tmp:
69
+ app_path = _validated_extract(ipa, Path(tmp))
70
+ bundle = load_bundle(app_path)
71
+ validate_bundle(bundle)
72
+ typer.echo("OK")
73
+
74
+
75
+ @app.command()
76
+ def patch(
77
+ ipa: Path = typer.Option(..., "--ipa", exists=True, help="Input .ipa"),
78
+ patches: Path = typer.Option(..., "--patches", exists=True, help="Patch definition YAML/JSON file"),
79
+ identity: str | None = typer.Option(
80
+ None,
81
+ "--identity",
82
+ help="Codesigning identity: SHA-1 hash or unique name substring (not needed for --dry-run)",
83
+ ),
84
+ profile: list[Path] | None = typer.Option(
85
+ None,
86
+ "--profile",
87
+ exists=True,
88
+ help="Provisioning profile (.mobileprovision), repeatable; not needed for --dry-run. Repeat to "
89
+ "supply one per app extension/watch app -- each is matched to its own bundle id; a single "
90
+ "profile signs everything, as before.",
91
+ ),
92
+ output: Path = typer.Option(..., "--output", help="Output .ipa path"),
93
+ dry_run: bool = typer.Option(False, "--dry-run", help="Validate patches without mutating or signing anything"),
94
+ no_sign: bool = typer.Option(
95
+ False,
96
+ "--no-sign",
97
+ help="Apply patches and repackage without codesigning (for AltStore, which signs at install)",
98
+ ),
99
+ verbose: bool = typer.Option(False, "--verbose", help="Print the full manifest on success"),
100
+ ) -> None:
101
+ """Extract, patch, re-sign, and repackage an IPA for AltStore Classic sideloading."""
102
+ if not dry_run and not no_sign:
103
+ if not identity:
104
+ typer.secho("error: --identity is required unless --dry-run is set", fg=typer.colors.RED, err=True)
105
+ raise typer.Exit(code=1) from None
106
+ if not profile:
107
+ typer.secho(
108
+ "error: at least one --profile is required unless --dry-run is set", fg=typer.colors.RED, err=True
109
+ )
110
+ raise typer.Exit(code=1) from None
111
+ try:
112
+ result = run_pipeline(ipa, patches, identity or "", profile or [], output, dry_run=dry_run, no_sign=no_sign)
113
+ except PipelineError as e:
114
+ typer.secho(f"error: {e}", fg=typer.colors.RED, err=True)
115
+ raise typer.Exit(code=1) from None
116
+
117
+ if dry_run:
118
+ count = len(result.manifest.patches_applied)
119
+ typer.echo(f"Dry run OK -- {count} operation(s) would apply.")
120
+ hook_report = result.manifest.hook_report
121
+ if hook_report:
122
+ ok = sum(1 for h in hook_report if h["status"] in ("ok", "ok-system", "ok-inherited", "added"))
123
+ typer.echo(f"Hooks: {ok}/{len(hook_report)} attach ({len(hook_report) - ok} issue(s))")
124
+ for h in hook_report:
125
+ if h["status"] not in ("ok", "ok-system", "ok-inherited", "added"):
126
+ typer.secho(
127
+ f" ! {h['class']} {'+' if h['kind'] == 'class' else '-'}[{h['selector']}]: "
128
+ f"{h['status']} -- {h['detail']}",
129
+ fg=typer.colors.YELLOW,
130
+ )
131
+ else:
132
+ manifest = result.manifest
133
+ applied = sum(1 for p in manifest.patches_applied if p["status"] == "applied")
134
+ typer.echo(
135
+ f"Applied {applied} operation(s) to {manifest.bundle_id} v{manifest.version} -> {result.output_path}"
136
+ )
137
+
138
+ if verbose:
139
+ typer.echo(result.manifest.to_json())
140
+
141
+
142
+ @app.command("export-source")
143
+ def export_source(
144
+ ipa: Path = typer.Option(..., "--ipa", exists=True, help="Patched, signed .ipa to describe"),
145
+ download_url: str = typer.Option(..., "--download-url", help="URL this IPA will be hosted at"),
146
+ output: Path = typer.Option(..., "--output", help="Output source.json path"),
147
+ ) -> None:
148
+ """Emit an AltStore Classic source.json entry for an already-patched IPA."""
149
+ entry = build_app_entry(ipa, download_url)
150
+ write_source_json(entry, output)
151
+ typer.echo(f"Wrote {output}")
152
+
153
+
154
+ @app.command()
155
+ def gui(
156
+ host: str = typer.Option("127.0.0.1", "--host"),
157
+ port: int = typer.Option(8765, "--port"),
158
+ ) -> None:
159
+ """Launch the local web GUI (wraps the same pipeline as `forge patch`)."""
160
+ import uvicorn
161
+
162
+ uvicorn.run("ipa_forge.gui.app:app", host=host, port=port)
163
+
164
+
165
+ app.add_typer(_hooks.app, name="hooks")
166
+ app.add_typer(_analysis.app, name="analysis")
167
+
168
+
169
+ if __name__ == "__main__":
170
+ app()
@@ -0,0 +1 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later