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
@@ -0,0 +1,96 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Bottom-up nested-executable inventory walker.
3
+
4
+ Classification is purely structural (by containing directory suffix), never
5
+ by bundle/app identifier -- this keeps the core engine app-agnostic per the
6
+ architecture's core rule.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from ipa_forge.bundle.models import AppBundle, MachOKind, MachOTarget
14
+ from ipa_forge.bundle.plist import read_plist
15
+ from ipa_forge.machO.detect import is_macho_file
16
+
17
+ _OWNING_BUNDLE_SUFFIX: dict[MachOKind, str] = {"appex": ".appex", "watch_app": ".app"}
18
+
19
+
20
+ def _classify(rel: Path, main_executable_name: str) -> MachOKind:
21
+ parts = rel.parts
22
+
23
+ if len(parts) == 1 and parts[0] == main_executable_name:
24
+ return "main"
25
+
26
+ # Innermost containing directory determines kind: a dylib inside a
27
+ # .framework is still a "framework" executable, a framework nested
28
+ # inside a watch app is still a "framework", etc.
29
+ for name in reversed(parts[:-1]):
30
+ if name.endswith(".appex"):
31
+ return "appex"
32
+ if name.endswith(".app"):
33
+ return "watch_app"
34
+ if name.endswith(".framework"):
35
+ return "framework"
36
+
37
+ if rel.suffix == ".dylib":
38
+ return "dylib"
39
+
40
+ return "other"
41
+
42
+
43
+ def _owning_bundle_dir(app_root: Path, rel: Path, kind: MachOKind) -> Path | None:
44
+ """Directory of the bundle (.appex/.app) that owns this target's own
45
+ Info.plist, for kinds that can carry their own provisioning profile."""
46
+ suffix = _OWNING_BUNDLE_SUFFIX.get(kind)
47
+ if suffix is None:
48
+ return None
49
+ parts = rel.parts
50
+ for i in range(len(parts) - 1, -1, -1):
51
+ if parts[i].endswith(suffix):
52
+ return app_root.joinpath(*parts[: i + 1])
53
+ return None
54
+
55
+
56
+ def _read_bundle_id(bundle_dir: Path) -> str | None:
57
+ try:
58
+ plist = read_plist(bundle_dir / "Info.plist")
59
+ except (OSError, ValueError):
60
+ return None
61
+ bundle_id = plist.get("CFBundleIdentifier")
62
+ return bundle_id if isinstance(bundle_id, str) else None
63
+
64
+
65
+ def build_inventory(bundle: AppBundle) -> list[MachOTarget]:
66
+ """Walk bundle.root for Mach-O executables, returned in bottom-up sign order
67
+ (deepest-nested first; the top-level main executable always last)."""
68
+ app_root = bundle.root
69
+ main_name = bundle.main_executable_name
70
+
71
+ targets: list[MachOTarget] = []
72
+ for path in app_root.rglob("*"):
73
+ if not is_macho_file(path):
74
+ continue
75
+ rel = path.relative_to(app_root)
76
+ kind = _classify(rel, main_name)
77
+
78
+ target_bundle_id: str | None
79
+ if kind == "main":
80
+ target_bundle_id = bundle.bundle_id
81
+ else:
82
+ owning_dir = _owning_bundle_dir(app_root, rel, kind)
83
+ target_bundle_id = _read_bundle_id(owning_dir) if owning_dir else None
84
+
85
+ targets.append(
86
+ MachOTarget(
87
+ path=path,
88
+ bundle_relative=str(Path("Payload") / app_root.name / rel),
89
+ kind=kind,
90
+ depth=len(rel.parts),
91
+ bundle_id=target_bundle_id,
92
+ )
93
+ )
94
+
95
+ targets.sort(key=lambda t: (t.kind == "main", -t.depth))
96
+ return targets
@@ -0,0 +1,81 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """IPA extraction and repackaging.
3
+
4
+ Uses the platform `zip`/`unzip` CLI tools rather than Python's `zipfile`
5
+ module because Apple bundles routinely contain symlinks (e.g.
6
+ `Foo.framework/Foo -> Versions/Current/Foo`), and `zip -y` / `unzip` round-trip
7
+ those correctly while `zipfile` does not preserve them by default.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import subprocess
13
+ from pathlib import Path
14
+
15
+ from ipa_forge.bundle.inventory import build_inventory
16
+ from ipa_forge.bundle.models import AppBundle
17
+ from ipa_forge.bundle.plist import read_plist
18
+
19
+
20
+ def extract_ipa(ipa_path: Path, dest_dir: Path) -> Path:
21
+ """Extract ipa_path into dest_dir. Returns the path to Payload/<App>.app.
22
+
23
+ Structural validation of the archive itself is a separate stage
24
+ (validators/ipa_validator.py) run by the pipeline before this is called;
25
+ here we only require the extraction to yield exactly one Payload/*.app.
26
+ """
27
+ dest_dir.mkdir(parents=True, exist_ok=True)
28
+ subprocess.run(
29
+ ["unzip", "-qq", "-o", str(ipa_path), "-d", str(dest_dir)],
30
+ check=True,
31
+ )
32
+ payload_dir = dest_dir / "Payload"
33
+ apps = [p for p in payload_dir.iterdir() if p.is_dir() and p.name.endswith(".app")]
34
+ if len(apps) != 1:
35
+ raise FileNotFoundError(f"expected exactly one Payload/*.app bundle under {payload_dir}, found {len(apps)}")
36
+ return apps[0]
37
+
38
+
39
+ def repack_ipa(extraction_root: Path, output_path: Path) -> Path:
40
+ """Zip extraction_root/Payload back into a standard-structure .ipa at output_path."""
41
+ payload_dir = extraction_root / "Payload"
42
+ if not payload_dir.is_dir():
43
+ raise FileNotFoundError(f"No Payload/ directory under {extraction_root}")
44
+ output_path.parent.mkdir(parents=True, exist_ok=True)
45
+ if output_path.exists():
46
+ output_path.unlink()
47
+ subprocess.run(
48
+ ["zip", "-qq", "-r", "-y", str(output_path), "Payload"],
49
+ cwd=extraction_root,
50
+ check=True,
51
+ )
52
+ return output_path
53
+
54
+
55
+ def validate_and_extract(ipa: Path, dest: Path) -> Path:
56
+ """Structure-validate (pipeline stage 1), then extract. Raises ValueError
57
+ on malformed input; the neutral home shared by the GUI and CLI."""
58
+ from ipa_forge.validators.ipa_validator import IpaValidationError, validate_ipa_structure
59
+
60
+ try:
61
+ validate_ipa_structure(ipa)
62
+ except IpaValidationError as e:
63
+ raise ValueError(str(e)) from e
64
+ return extract_ipa(ipa, dest)
65
+
66
+
67
+ def load_bundle(app_path: Path) -> AppBundle:
68
+ """Build an AppBundle model (with full inventory) from an already-extracted .app."""
69
+ info_plist_path = app_path / "Info.plist"
70
+ info_plist = read_plist(info_plist_path)
71
+
72
+ bundle = AppBundle(
73
+ root=app_path,
74
+ extraction_root=app_path.parent.parent,
75
+ info_plist=info_plist,
76
+ bundle_id=info_plist["CFBundleIdentifier"],
77
+ version=info_plist.get("CFBundleShortVersionString", ""),
78
+ build=info_plist.get("CFBundleVersion", ""),
79
+ )
80
+ bundle.executables = build_inventory(bundle)
81
+ return bundle
@@ -0,0 +1,58 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Core bundle data model shared by the patch engine and the signing subsystem."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any, Literal
9
+
10
+ MachOKind = Literal["main", "framework", "dylib", "appex", "watch_app", "other"]
11
+
12
+
13
+ @dataclass
14
+ class MachOTarget:
15
+ """A single executable within the bundle (main binary, framework, dylib, extension, watch app)."""
16
+
17
+ path: Path
18
+ """Absolute path on disk, inside the extraction working directory."""
19
+
20
+ bundle_relative: str
21
+ """Path relative to the extraction root (e.g. "Payload/App.app/Frameworks/Foo.framework/Foo"),
22
+ used to address this target from patch definitions and the manifest."""
23
+
24
+ kind: MachOKind
25
+
26
+ depth: int
27
+ """Number of path components below the top-level app root; used to derive bottom-up sign order."""
28
+
29
+ bundle_id: str | None = None
30
+ """CFBundleIdentifier of the owning bundle, for kinds that have their own Info.plist and can
31
+ carry their own embedded.mobileprovision (main, appex, watch_app). None for framework/dylib/other,
32
+ which are signed but never embed a profile of their own."""
33
+
34
+
35
+ @dataclass
36
+ class AppBundle:
37
+ """In-memory representation of an extracted .app, with its nested executable inventory."""
38
+
39
+ root: Path
40
+ """Path to Payload/<App>.app."""
41
+
42
+ extraction_root: Path
43
+ """Path to the temp directory containing Payload/, i.e. root.parent.parent."""
44
+
45
+ info_plist: dict[str, Any]
46
+ bundle_id: str
47
+ version: str
48
+ build: str
49
+
50
+ executables: list[MachOTarget] = field(default_factory=list)
51
+ """Populated by the inventory walker, ordered bottom-up (deepest-nested first, main app last)."""
52
+
53
+ entitlements: dict[str, Any] | None = None
54
+ """Populated during the signing stage; None until then."""
55
+
56
+ @property
57
+ def main_executable_name(self) -> str:
58
+ return self.info_plist["CFBundleExecutable"]
@@ -0,0 +1,18 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Info.plist read/write helpers (binary or XML plist, transparently)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import plistlib
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ def read_plist(path: Path) -> dict[str, Any]:
12
+ with open(path, "rb") as f:
13
+ return plistlib.load(f)
14
+
15
+
16
+ def write_plist(path: Path, data: dict[str, Any]) -> None:
17
+ with open(path, "wb") as f:
18
+ plistlib.dump(data, f)
@@ -0,0 +1 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
@@ -0,0 +1,225 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """`forge analysis` subcommands: general-purpose reverse engineering of an
3
+ IPA, built on the same Mach-O/ObjC analysis engine `forge hooks` uses for
4
+ hook verification (see `ipa_forge.machO.objc`).
5
+
6
+ Every command accepts ``--app-dir`` in place of the IPA, same as
7
+ `forge hooks` — point it at an already-extracted ``Payload/<App>.app``
8
+ directory to skip re-extraction when iterating.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re as _re
14
+ import tempfile
15
+ from pathlib import Path
16
+
17
+ import typer
18
+
19
+ from ipa_forge.analysis.classdump import render_analysis
20
+ from ipa_forge.analysis.diff import diff_analyses, render_diff
21
+ from ipa_forge.analysis.security import analyze_security, render_security_posture
22
+ from ipa_forge.analysis.strings import strings_in_bundle
23
+ from ipa_forge.analysis.symbols import analyze_symbols
24
+ from ipa_forge.bundle.ipa import load_bundle
25
+ from ipa_forge.bundle.models import AppBundle
26
+ from ipa_forge.cli.common import extract_or_use, resolve_app_path
27
+ from ipa_forge.machO.arch import AmbiguousArchError, ArchNotFoundError, NotMachOError
28
+ from ipa_forge.machO.detect import bundle_executable_paths
29
+ from ipa_forge.machO.objc import analyze_bundle
30
+
31
+ app = typer.Typer(help="General-purpose IPA reverse engineering: class-dump, strings, symbols, security, diff.")
32
+
33
+
34
+ def _select_binary(bundle: AppBundle, binary: str | None) -> Path:
35
+ """Resolve --binary to one executable path: the main executable when
36
+ omitted, otherwise the unique path whose bundle-relative string contains
37
+ the given substring."""
38
+ if binary is None:
39
+ return bundle.root / bundle.main_executable_name
40
+ matches = [p for p in bundle_executable_paths(bundle) if binary in str(p)]
41
+ if not matches:
42
+ typer.secho(f"error: no executable matching '{binary}' found in the bundle", fg=typer.colors.RED, err=True)
43
+ raise typer.Exit(code=1)
44
+ if len(matches) > 1:
45
+ typer.secho(
46
+ f"error: '{binary}' matches multiple executables: {[str(p) for p in matches]} -- be more specific",
47
+ fg=typer.colors.RED,
48
+ err=True,
49
+ )
50
+ raise typer.Exit(code=1)
51
+ return matches[0]
52
+
53
+
54
+ @app.command("classdump")
55
+ def analysis_classdump(
56
+ ipa: Path | None = typer.Option(None, "--ipa", exists=True, help="Input .ipa (not needed when --app-dir is given)"),
57
+ app_dir: Path | None = typer.Option(
58
+ None,
59
+ "--app-dir",
60
+ exists=True,
61
+ help="Already-extracted Payload/<App>.app directory to analyze instead of re-extracting the IPA",
62
+ ),
63
+ class_name: str | None = typer.Option(None, "--class", help="Restrict to one class"),
64
+ search: str | None = typer.Option(None, "--search", help="Only classes whose name matches this regex"),
65
+ output: Path | None = typer.Option(None, "--output", "-o", help="Write to a file instead of stdout"),
66
+ ) -> None:
67
+ """Dump the app's Objective-C runtime metadata as `.h`-style class-dump
68
+ text: every class (superclass, protocol conformance, ivars, properties,
69
+ method signatures), protocol declarations, and categories. `--class`/
70
+ `--search` restrict output to matching classes only (protocols and
71
+ categories are omitted in that case, matching `forge hooks extract`)."""
72
+ with tempfile.TemporaryDirectory(prefix="ipa_forge_analysis_") as tmp:
73
+ app_path = extract_or_use(ipa, app_dir, Path(tmp))
74
+ bundle = load_bundle(app_path)
75
+ analysis = analyze_bundle(bundle)
76
+
77
+ if class_name and class_name not in analysis.classes:
78
+ typer.secho(f"class '{class_name}' not found", fg=typer.colors.YELLOW)
79
+ raise typer.Exit(code=1)
80
+
81
+ text = render_analysis(analysis, class_filter=class_name, search=search)
82
+ if not text:
83
+ typer.echo("no matching classes/protocols/categories found")
84
+ raise typer.Exit(code=1)
85
+
86
+ if output:
87
+ output.write_text(text + "\n")
88
+ typer.echo(f"wrote {output}")
89
+ else:
90
+ typer.echo(text)
91
+
92
+
93
+ @app.command("strings")
94
+ def analysis_strings(
95
+ ipa: Path | None = typer.Option(None, "--ipa", exists=True, help="Input .ipa (not needed when --app-dir is given)"),
96
+ app_dir: Path | None = typer.Option(
97
+ None,
98
+ "--app-dir",
99
+ exists=True,
100
+ help="Already-extracted Payload/<App>.app directory to analyze instead of re-extracting the IPA",
101
+ ),
102
+ min_len: int = typer.Option(4, "--min-len", help="Minimum printable-ASCII run length to report"),
103
+ search: str | None = typer.Option(None, "--search", help="Only strings matching this regex"),
104
+ binary: str | None = typer.Option(None, "--binary", help="Restrict to one executable (bundle-relative substring)"),
105
+ ) -> None:
106
+ """Extract printable strings from every executable in the bundle (main +
107
+ frameworks + dylibs + extensions), tagged with which binary each string
108
+ came from. Pipe to grep for anything not covered by --search."""
109
+ with tempfile.TemporaryDirectory(prefix="ipa_forge_analysis_") as tmp:
110
+ app_path = extract_or_use(ipa, app_dir, Path(tmp))
111
+ bundle = load_bundle(app_path)
112
+ found = strings_in_bundle(bundle, min_len=min_len)
113
+
114
+ if binary:
115
+ found = [s for s in found if binary in s.binary]
116
+ if search:
117
+ pat = _re.compile(search)
118
+ found = [s for s in found if pat.search(s.value)]
119
+
120
+ for s in found:
121
+ typer.echo(f"[{s.binary}] {s.value}")
122
+ typer.echo(f"-- {len(found)} string(s)", err=True)
123
+
124
+
125
+ @app.command("symbols")
126
+ def analysis_symbols(
127
+ ipa: Path | None = typer.Option(None, "--ipa", exists=True, help="Input .ipa (not needed when --app-dir is given)"),
128
+ app_dir: Path | None = typer.Option(
129
+ None,
130
+ "--app-dir",
131
+ exists=True,
132
+ help="Already-extracted Payload/<App>.app directory to analyze instead of re-extracting the IPA",
133
+ ),
134
+ binary: str | None = typer.Option(None, "--binary", help="Which executable to inspect; default: main executable"),
135
+ arch: str | None = typer.Option(None, "--arch", help="Architecture slice, required for a universal binary"),
136
+ ) -> None:
137
+ """Linked libraries and imported/exported symbols for one Mach-O
138
+ executable in the bundle (`otool -L` + `nm`, structured)."""
139
+ with tempfile.TemporaryDirectory(prefix="ipa_forge_analysis_") as tmp:
140
+ app_path = extract_or_use(ipa, app_dir, Path(tmp))
141
+ bundle = load_bundle(app_path)
142
+ target = _select_binary(bundle, binary)
143
+ try:
144
+ result = analyze_symbols(target, arch)
145
+ except (AmbiguousArchError, ArchNotFoundError, NotMachOError) as e:
146
+ typer.secho(f"error: {e}", fg=typer.colors.RED, err=True)
147
+ raise typer.Exit(code=1) from None
148
+
149
+ typer.echo(f"{result.binary} ({result.arch})")
150
+ typer.echo(f"linked libraries ({len(result.linked_libraries)}):")
151
+ for lib in result.linked_libraries:
152
+ typer.echo(f" {lib}")
153
+ typer.echo(f"imported symbols ({len(result.imported_symbols)}):")
154
+ for sym in result.imported_symbols:
155
+ typer.echo(f" {sym}")
156
+ typer.echo(f"exported symbols ({len(result.exported_symbols)}):")
157
+ for sym in result.exported_symbols:
158
+ typer.echo(f" {sym}")
159
+
160
+
161
+ @app.command("security")
162
+ def analysis_security(
163
+ ipa: Path | None = typer.Option(None, "--ipa", exists=True, help="Input .ipa (not needed when --app-dir is given)"),
164
+ app_dir: Path | None = typer.Option(
165
+ None,
166
+ "--app-dir",
167
+ exists=True,
168
+ help="Already-extracted Payload/<App>.app directory to analyze instead of re-extracting the IPA",
169
+ ),
170
+ binary: str | None = typer.Option(None, "--binary", help="Which executable to inspect; default: main executable"),
171
+ arch: str | None = typer.Option(None, "--arch", help="Architecture slice, required for a universal binary"),
172
+ ) -> None:
173
+ """PIE, encryption-flag (detection only, never decrypted), stack
174
+ protector, an ARC heuristic, min-OS, and platform for one executable."""
175
+ with tempfile.TemporaryDirectory(prefix="ipa_forge_analysis_") as tmp:
176
+ app_path = extract_or_use(ipa, app_dir, Path(tmp))
177
+ bundle = load_bundle(app_path)
178
+ target = _select_binary(bundle, binary)
179
+ try:
180
+ posture = analyze_security(target, arch)
181
+ except (AmbiguousArchError, ArchNotFoundError, NotMachOError) as e:
182
+ typer.secho(f"error: {e}", fg=typer.colors.RED, err=True)
183
+ raise typer.Exit(code=1) from None
184
+
185
+ typer.echo(render_security_posture(posture))
186
+
187
+
188
+ @app.command("diff")
189
+ def analysis_diff(
190
+ old_ipa: Path | None = typer.Option(
191
+ None, "--old", exists=True, help="Previous-version .ipa (not needed with --old-app-dir)"
192
+ ),
193
+ new_ipa: Path | None = typer.Option(
194
+ None, "--new", exists=True, help="New-version .ipa (not needed with --new-app-dir)"
195
+ ),
196
+ old_app_dir: Path | None = typer.Option(
197
+ None, "--old-app-dir", exists=True, help="Extracted Payload/<App>.app of the old build"
198
+ ),
199
+ new_app_dir: Path | None = typer.Option(
200
+ None, "--new-app-dir", exists=True, help="Extracted Payload/<App>.app of the new build"
201
+ ),
202
+ ) -> None:
203
+ """Survey what changed between two builds of the same app: classes and
204
+ protocols added/removed, per-class method churn, and Info.plist key
205
+ changes. Broader and purely informational compared to `forge hooks
206
+ diff`, which only re-checks one patch definition's declared hook
207
+ targets and fails the exit code on a required-hook regression."""
208
+ with tempfile.TemporaryDirectory(prefix="ipa_forge_analysis_") as tmp:
209
+ try:
210
+ old_path = resolve_app_path(old_ipa, old_app_dir, Path(tmp) / "old")
211
+ new_path = resolve_app_path(new_ipa, new_app_dir, Path(tmp) / "new")
212
+ except ValueError as e:
213
+ typer.secho(f"error: {e}", fg=typer.colors.RED, err=True)
214
+ raise typer.Exit(code=1) from None
215
+ bundle_old = load_bundle(old_path)
216
+ bundle_new = load_bundle(new_path)
217
+ diff = diff_analyses(
218
+ analyze_bundle(bundle_old),
219
+ analyze_bundle(bundle_new),
220
+ bundle_old.info_plist,
221
+ bundle_new.info_plist,
222
+ )
223
+
224
+ typer.echo(f"{bundle_old.bundle_id} {bundle_old.version} -> {bundle_new.version}")
225
+ typer.echo(render_diff(diff))
@@ -0,0 +1,41 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Shared CLI helpers. The extraction helper lives in the bundle domain
3
+ (ipa_forge.bundle.ipa.validate_and_extract); this module re-exports it so
4
+ CLI code reads cleanly, and provides the --app-dir resolution used by the
5
+ `forge hooks` and `forge analysis` commands to skip re-extraction during
6
+ iteration."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ import typer
13
+
14
+ from ipa_forge.bundle.ipa import validate_and_extract as validated_extract # noqa: F401
15
+
16
+
17
+ def resolve_app_path(ipa: Path | None, app_dir: Path | None, dest: Path) -> Path:
18
+ """Return the app bundle to analyze: the already-extracted ``app_dir``
19
+ when given, otherwise extract ``ipa`` into ``dest``.
20
+
21
+ Exactly one of the two inputs must be set — passing both is a mistake
22
+ (the dir would silently take precedence over a fresh extract) and passing
23
+ neither leaves nothing to analyze.
24
+ """
25
+ if app_dir is not None and ipa is not None:
26
+ raise ValueError("pass either --ipa or --app-dir, not both")
27
+ if app_dir is not None:
28
+ return app_dir
29
+ if ipa is None:
30
+ raise ValueError("--ipa is required when --app-dir is not given")
31
+ return validated_extract(ipa, dest)
32
+
33
+
34
+ def extract_or_use(ipa: Path | None, app_dir: Path | None, tmp: Path) -> Path:
35
+ """`resolve_app_path`, with resolution errors reported as a clean
36
+ single-line CLI error instead of a traceback."""
37
+ try:
38
+ return resolve_app_path(ipa, app_dir, tmp)
39
+ except ValueError as e:
40
+ typer.secho(f"error: {e}", fg=typer.colors.RED, err=True)
41
+ raise typer.Exit(code=1) from None