ipeddle 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.
ipeddle/doctor.py ADDED
@@ -0,0 +1,173 @@
1
+ """``ipeddle doctor``: check the machine and explain what is missing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import platform
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from . import patcher, swiftsdk, toolchain, ui
15
+ from .errors import IpeddleError
16
+ from .fsutil import free_bytes
17
+ from .paths import TRIPLE, Home
18
+
19
+ GB = 1024 ** 3
20
+
21
+
22
+ @dataclass
23
+ class Check:
24
+ name: str
25
+ ok: bool
26
+ detail: str
27
+ fix: str = ""
28
+ required: bool = True
29
+
30
+
31
+ def run_checks(home: Home, deep: bool = False) -> list[Check]:
32
+ checks: list[Check] = []
33
+ add = checks.append
34
+
35
+ add(Check("python", sys.version_info >= (3, 10), platform.python_version(), "ipeddle needs Python 3.10+"))
36
+ system = platform.system()
37
+ add(Check("os", system == "Linux", f"{system} {platform.machine()}",
38
+ "ipeddle targets Linux (on Windows use WSL2; on a Mac just use Xcode)"))
39
+ add(Check("arch", platform.machine() in ("x86_64", "aarch64", "arm64"), platform.machine(),
40
+ "Swift for Linux ships for x86_64 and aarch64 only"))
41
+ for tool, why in (("git", "fetching the SDK from the mirror"), ("tar", "unpacking downloads")):
42
+ add(Check(tool, shutil.which(tool) is not None, shutil.which(tool) or "not found",
43
+ f"install {tool} ({why})", required=tool == "git"))
44
+
45
+ free = free_bytes(home.root)
46
+ st = home.load_state()
47
+ need = 1 * GB if st.get("complete") else 4 * GB
48
+ add(Check("disk", free >= need, f"{ui.human_bytes(free)} free at {home.root}",
49
+ f"free at least {ui.human_bytes(need)} (setup needs ~4 GB: Swift ~2.5 GB, SDK ~0.6 GB)"))
50
+
51
+ swift = None
52
+ if st.get("swift"):
53
+ try:
54
+ swift = toolchain.probe(Path(st["swift"]["root"]))
55
+ except IpeddleError as e:
56
+ add(Check("swift", False, e.message, "run `ipeddle setup` (or fix the system libraries "
57
+ "listed on https://www.swift.org/install/linux/)"))
58
+ else:
59
+ swift = toolchain.find_on_path()
60
+ if swift:
61
+ ok = toolchain.version_tuple(swift.version) >= toolchain.MIN_SWIFT
62
+ missing = swift.missing_tools()
63
+ add(Check("swift", ok and not missing, f"Swift {swift.version} at {swift.root}"
64
+ + (f" (missing {', '.join(missing)})" if missing else ""),
65
+ "ipeddle needs a swift.org toolchain, 6.1 or newer; `ipeddle setup` downloads one"))
66
+ elif not any(c.name == "swift" for c in checks):
67
+ add(Check("swift", False, "not found", "run `ipeddle setup` to download Swift for Linux"))
68
+
69
+ linker = st.get("linker", {}).get("path")
70
+ add(Check("linker", bool(linker) and Path(linker).is_file(), linker or "not set up",
71
+ "run `ipeddle setup` (downloads an ld64-compatible lld that accepts iOS)"))
72
+
73
+ sdk = st.get("sdk", {})
74
+ derived = Path(sdk["derived"]) if sdk.get("derived") else None
75
+ if derived and derived.is_dir():
76
+ rep = patcher.PatchReport.load(derived)
77
+ learned = sum(len(v) for v in rep.learned.values())
78
+ add(Check("sdk", True, f"iPhoneOS {sdk.get('version')} (patched: {sum(rep.overlays.values())} "
79
+ f"overlay, {sum(rep.merged.values())} merged, {learned} learned symbols)"))
80
+ src = Path(sdk.get("source", ""))
81
+ add(Check("sdk source", src.is_dir(), str(src), "the pristine SDK is gone; `ipeddle setup --force`",
82
+ required=False))
83
+ else:
84
+ add(Check("sdk", False, "not set up", "run `ipeddle setup` (fetches the SDK on this machine)"))
85
+
86
+ rt = home.resource_dir / "clang/lib/darwin" / swiftsdk.RT_ARCHIVE
87
+ add(Check("runtime", rt.exists(), str(rt) if rt.exists() else "not built",
88
+ "run `ipeddle setup` (builds the availability runtime)"))
89
+ add(Check("swift sdk bundle", (home.bundle / "swift-sdk.json").exists(), str(home.bundle),
90
+ "run `ipeddle setup`"))
91
+
92
+ try:
93
+ import PIL # noqa: F401
94
+ add(Check("pillow", True, "icons are resized", required=False))
95
+ except ImportError:
96
+ add(Check("pillow", False, "not installed: icons are copied unscaled",
97
+ "pip install 'ipeddle[icons]'", required=False))
98
+
99
+ if deep and all(c.ok for c in checks if c.required) and swift:
100
+ add(smoke_test(home, swift))
101
+ return checks
102
+
103
+
104
+ SMOKE_SOURCE = """\
105
+ import SwiftUI
106
+ import UIKit
107
+
108
+ struct SmokeView: View {
109
+ var body: some View { Text("ipeddle") }
110
+ }
111
+
112
+ @main
113
+ enum Smoke {
114
+ static func main() {
115
+ if #available(iOS 17.0, *) { print(UIDevice.self) }
116
+ print(SmokeView().body)
117
+ }
118
+ }
119
+ """
120
+
121
+
122
+ def smoke_test(home: Home, swift, target: str = "16.0", timeout: int = 900) -> Check:
123
+ """Compile and link a small SwiftUI + UIKit program for arm64-apple-ios.
124
+
125
+ Uses the shared module cache, so it also pre-builds the SDK's modules
126
+ that every app needs (Swift, Foundation, UIKit, SwiftUI ...).
127
+ """
128
+ with tempfile.TemporaryDirectory(prefix="ipeddle-smoke-") as tmp:
129
+ src = Path(tmp) / "smoke.swift"
130
+ src.write_text(SMOKE_SOURCE)
131
+ out = Path(tmp) / "smoke"
132
+ bundle = home.bundle
133
+ sdk = bundle / "sdk/iPhoneOS.sdk"
134
+ cmd = [str(swift.tool("swiftc")), "-target", f"{TRIPLE}{target}", "-parse-as-library",
135
+ "-sdk", str(sdk), "-resource-dir", str(bundle / "resource"),
136
+ "-I", str(bundle / "resource/shims"),
137
+ "-use-ld=lld", f"-ld-path={bundle / 'ld64.lld'}",
138
+ "-Xclang-linker", "-isysroot", "-Xclang-linker", str(sdk),
139
+ "-Xclang-linker", "-resource-dir", "-Xclang-linker", str(bundle / "resource/clang"),
140
+ "-runtime-compatibility-version", "none",
141
+ "-Xfrontend", "-enable-cross-import-overlays",
142
+ "-Xcc", "-Wno-nullability-completeness",
143
+ "-Xcc", "-Wno-nullability-inferred-on-nested-type",
144
+ "-module-cache-path", str(home.module_cache),
145
+ "-O", str(src), "-o", str(out)]
146
+ try:
147
+ p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout,
148
+ env={**os.environ, "TMPDIR": tmp})
149
+ except subprocess.TimeoutExpired:
150
+ return Check("test compile", False, f"timed out after {timeout}s", "rerun with IPEDDLE_VERBOSE=1")
151
+ if p.returncode != 0:
152
+ errs = [ln.strip() for ln in (p.stdout + p.stderr).splitlines() if "error" in ln][:5]
153
+ return Check("test compile", False, "; ".join(errs) or "failed",
154
+ "run `ipeddle setup --force`; if it persists, the Swift version may not "
155
+ "read this SDK's interfaces")
156
+ return Check("test compile", out.exists(), "compiled and linked a SwiftUI program for iOS")
157
+
158
+
159
+ def report(checks: list[Check]) -> bool:
160
+ ok = True
161
+ for c in checks:
162
+ if c.ok:
163
+ ui.ok(f"{c.name:16} {c.detail}")
164
+ elif c.required:
165
+ ok = False
166
+ ui.fail(f"{c.name:16} {c.detail}")
167
+ if c.fix:
168
+ ui.info(f"{'':16} -> {c.fix}")
169
+ else:
170
+ ui.warn(f"{c.name:16} {c.detail}")
171
+ if c.fix:
172
+ ui.info(f"{'':16} -> {c.fix}")
173
+ return ok
ipeddle/errors.py ADDED
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class IpeddleError(Exception):
5
+ """An error with a human explanation and, ideally, a fix."""
6
+
7
+ def __init__(self, message: str, hint: str | None = None, code: int = 1):
8
+ super().__init__(message)
9
+ self.message = message
10
+ self.hint = hint
11
+ self.code = code
ipeddle/fsutil.py ADDED
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import errno
5
+ import fcntl
6
+ import os
7
+ import shutil
8
+ from pathlib import Path
9
+
10
+
11
+ @contextlib.contextmanager
12
+ def private_tmp(path: Path):
13
+ """Point TMPDIR at ``path`` for child processes (SwiftPM and the Swift
14
+ driver leave empty ``TemporaryDirectory.*`` folders behind) and delete it."""
15
+ old = os.environ.get("TMPDIR")
16
+ path.mkdir(parents=True, exist_ok=True)
17
+ os.environ["TMPDIR"] = str(path)
18
+ try:
19
+ yield path
20
+ finally:
21
+ if old is None:
22
+ os.environ.pop("TMPDIR", None)
23
+ else:
24
+ os.environ["TMPDIR"] = old
25
+ rmtree(path)
26
+
27
+
28
+ @contextlib.contextmanager
29
+ def locked(path: Path):
30
+ """An exclusive advisory lock (serialises setup and SDK patching)."""
31
+ path.parent.mkdir(parents=True, exist_ok=True)
32
+ with open(path, "a+") as fh:
33
+ fcntl.flock(fh, fcntl.LOCK_EX)
34
+ try:
35
+ yield
36
+ finally:
37
+ fcntl.flock(fh, fcntl.LOCK_UN)
38
+
39
+
40
+ def tree_size(path: Path) -> int:
41
+ """Bytes used by ``path`` (each hard-linked inode counted once)."""
42
+ seen = set()
43
+ total = 0
44
+ for root, _dirs, files in os.walk(path):
45
+ for f in files:
46
+ try:
47
+ st = os.lstat(os.path.join(root, f))
48
+ except OSError:
49
+ continue
50
+ key = (st.st_dev, st.st_ino)
51
+ if key in seen:
52
+ continue
53
+ seen.add(key)
54
+ total += st.st_blocks * 512
55
+ return total
56
+
57
+
58
+ def own_and_shared(path: Path) -> tuple[int, int]:
59
+ """(bytes only reachable from ``path``, bytes hard-linked from elsewhere).
60
+
61
+ Files whose link count exceeds the links found under ``path`` are
62
+ shared with another tree (e.g. the pristine SDK) and cost no extra space.
63
+ """
64
+ counts: dict[tuple[int, int], list] = {}
65
+ for root, _dirs, files in os.walk(path):
66
+ for f in files:
67
+ try:
68
+ st = os.lstat(os.path.join(root, f))
69
+ except OSError:
70
+ continue
71
+ key = (st.st_dev, st.st_ino)
72
+ entry = counts.setdefault(key, [0, st.st_nlink, st.st_blocks * 512])
73
+ entry[0] += 1
74
+ own = shared = 0
75
+ for seen, nlink, size in counts.values():
76
+ if nlink > seen:
77
+ shared += size
78
+ else:
79
+ own += size
80
+ return own, shared
81
+
82
+
83
+ def link_tree(src: Path, dst: Path) -> tuple[int, int]:
84
+ """Mirror ``src`` into ``dst`` using hard links (copy when not possible).
85
+
86
+ Returns ``(linked, copied)``. Files are later patched with
87
+ ``os.replace`` so the source tree is never modified.
88
+ """
89
+ linked = copied = 0
90
+ can_link = True
91
+ for root, dirs, files in os.walk(src):
92
+ rel = os.path.relpath(root, src)
93
+ out = dst / rel if rel != "." else dst
94
+ out.mkdir(parents=True, exist_ok=True)
95
+ for d in list(dirs):
96
+ sp = os.path.join(root, d)
97
+ if os.path.islink(sp):
98
+ os.symlink(os.readlink(sp), out / d)
99
+ dirs.remove(d)
100
+ for f in files:
101
+ sp = os.path.join(root, f)
102
+ dp = out / f
103
+ if os.path.islink(sp):
104
+ os.symlink(os.readlink(sp), dp)
105
+ continue
106
+ if can_link:
107
+ try:
108
+ os.link(sp, dp)
109
+ linked += 1
110
+ continue
111
+ except OSError as e:
112
+ if e.errno not in (errno.EXDEV, errno.EPERM, errno.EMLINK, errno.ENOTSUP):
113
+ raise
114
+ can_link = False
115
+ shutil.copy2(sp, dp)
116
+ copied += 1
117
+ return linked, copied
118
+
119
+
120
+ def rmtree(path: Path) -> None:
121
+ if path.is_symlink() or path.is_file():
122
+ path.unlink()
123
+ elif path.exists():
124
+ shutil.rmtree(path)
125
+
126
+
127
+ def free_bytes(path: Path) -> int:
128
+ p = Path(path)
129
+ while not p.exists() and p != p.parent:
130
+ p = p.parent
131
+ return shutil.disk_usage(p).free
ipeddle/inspect.py ADDED
@@ -0,0 +1,329 @@
1
+ """``ipeddle inspect``: sanity checks for an .ipa (or a .app directory)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import plistlib
6
+ import posixpath
7
+ import re
8
+ import struct
9
+ import zipfile
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ from . import macho, tbd
14
+ from .config import _BUNDLE_ID_RE
15
+ from .errors import IpeddleError
16
+ from .patcher import tbd_path
17
+
18
+ REQUIRED_KEYS = ("CFBundleExecutable", "CFBundleIdentifier", "CFBundleShortVersionString",
19
+ "CFBundleVersion", "CFBundlePackageType", "MinimumOSVersion")
20
+
21
+
22
+ @dataclass
23
+ class Finding:
24
+ level: str # "error" | "warning" | "info"
25
+ message: str
26
+
27
+
28
+ @dataclass
29
+ class Report:
30
+ app_name: str = ""
31
+ bundle_id: str = ""
32
+ executable: str = ""
33
+ archs: list[str] = field(default_factory=list)
34
+ platform: str | None = None
35
+ min_os: str | None = None
36
+ sdk: str | None = None
37
+ size: int = 0
38
+ dylibs: list[str] = field(default_factory=list)
39
+ frameworks: list[str] = field(default_factory=list)
40
+ bundles: list[str] = field(default_factory=list)
41
+ imports: int = 0
42
+ unresolved: list[tuple[str, str]] = field(default_factory=list)
43
+ findings: list[Finding] = field(default_factory=list)
44
+
45
+ def add(self, level: str, msg: str) -> None:
46
+ self.findings.append(Finding(level, msg))
47
+
48
+ @property
49
+ def ok(self) -> bool:
50
+ return not any(f.level == "error" for f in self.findings)
51
+
52
+ def to_json(self) -> dict:
53
+ return {
54
+ "app": self.app_name, "bundle_id": self.bundle_id, "executable": self.executable,
55
+ "archs": self.archs, "platform": self.platform, "min_os": self.min_os, "sdk": self.sdk,
56
+ "size": self.size, "dylibs": self.dylibs, "frameworks": self.frameworks,
57
+ "bundles": self.bundles, "imports": self.imports,
58
+ "unresolved": [{"library": lib, "symbol": s} for lib, s in self.unresolved],
59
+ "findings": [f.__dict__ for f in self.findings], "ok": self.ok,
60
+ }
61
+
62
+
63
+ class _Files:
64
+ """Uniform read access to an .ipa (zip) or an .app directory."""
65
+
66
+ def __init__(self, path: Path):
67
+ self.path = path
68
+ self.zip = None
69
+ if path.is_dir():
70
+ self.names = [str(p.relative_to(path.parent)) for p in path.rglob("*") if p.is_file()]
71
+ self.root = path.parent
72
+ else:
73
+ try:
74
+ self.zip = zipfile.ZipFile(path)
75
+ except zipfile.BadZipFile as e:
76
+ raise IpeddleError(f"{path} is not a valid .ipa (zip) file") from e
77
+ self.names = [n for n in self.zip.namelist() if not n.endswith("/")]
78
+
79
+ def read(self, name: str) -> bytes:
80
+ if self.zip:
81
+ return self.zip.read(name)
82
+ return (self.root / name).read_bytes()
83
+
84
+ def size(self) -> int:
85
+ if self.zip:
86
+ return sum(i.file_size for i in self.zip.infolist())
87
+ return sum((self.root / n).stat().st_size for n in self.names)
88
+
89
+
90
+ class SymbolIndex:
91
+ """Exports of SDK libraries, following re-exports."""
92
+
93
+ def __init__(self, sdk: Path):
94
+ self.sdk = sdk
95
+ self._docs: dict[str, tbd.TbdDocument | None] = {}
96
+ self._closure: dict[str, set[str]] = {}
97
+
98
+ def doc(self, install_name: str) -> tbd.TbdDocument | None:
99
+ if install_name not in self._docs:
100
+ p = tbd_path(self.sdk, install_name)
101
+ found = None
102
+ if p is not None:
103
+ for d in tbd.read(p):
104
+ self._docs.setdefault(d.install_name, d)
105
+ if d.install_name == install_name:
106
+ found = d
107
+ self._docs[install_name] = found
108
+ return self._docs[install_name]
109
+
110
+ def exports(self, install_name: str, _depth: int = 0) -> set[str] | None:
111
+ if install_name in self._closure:
112
+ return self._closure[install_name]
113
+ d = self.doc(install_name)
114
+ if d is None:
115
+ return None
116
+ out = set(d.exports)
117
+ self._closure[install_name] = out # break cycles
118
+ if _depth < 8:
119
+ for r in d.reexports:
120
+ sub = self.exports(r, _depth + 1)
121
+ if sub:
122
+ out |= sub
123
+ return out
124
+
125
+ def reexporters(self, install_name: str, linked: list[str]) -> list[str]:
126
+ out = []
127
+ for lib in linked:
128
+ d = self.doc(lib)
129
+ if d and install_name in d.reexports:
130
+ out.append(lib)
131
+ return out
132
+
133
+
134
+ def _find_app(names: list[str]) -> str:
135
+ apps = sorted({m.group(1) for n in names if (m := re.match(r"^(Payload/[^/]+\.app)/", n))})
136
+ if not apps:
137
+ apps = sorted({m.group(1) for n in names if (m := re.match(r"^([^/]+\.app)/", n))})
138
+ if not apps:
139
+ raise IpeddleError("no Payload/<Name>.app inside; this is not an iOS app archive")
140
+ return apps[0]
141
+
142
+
143
+ def _ver_tuple(v: str | None) -> tuple[int, ...]:
144
+ return tuple(int(x) for x in re.findall(r"\d+", v or "")[:3])
145
+
146
+
147
+ def inspect(path: Path, sdk: Path | None = None) -> Report:
148
+ path = Path(path)
149
+ if not path.exists():
150
+ raise IpeddleError(f"{path} not found")
151
+ files = _Files(path)
152
+ rep = Report(size=files.size())
153
+ app = _find_app(files.names)
154
+ rep.app_name = posixpath.basename(app)
155
+
156
+ try:
157
+ info = plistlib.loads(files.read(f"{app}/Info.plist"))
158
+ except KeyError:
159
+ rep.add("error", "Info.plist is missing")
160
+ return rep
161
+ except Exception as e:
162
+ rep.add("error", f"Info.plist cannot be parsed: {e}")
163
+ return rep
164
+
165
+ for k in REQUIRED_KEYS:
166
+ if k not in info:
167
+ rep.add("error", f"Info.plist lacks {k}")
168
+ rep.bundle_id = str(info.get("CFBundleIdentifier", ""))
169
+ if rep.bundle_id and not _BUNDLE_ID_RE.match(rep.bundle_id):
170
+ rep.add("error", f"CFBundleIdentifier {rep.bundle_id!r} is not a valid bundle id")
171
+ if info.get("CFBundlePackageType", "APPL") != "APPL":
172
+ rep.add("error", "CFBundlePackageType is not APPL")
173
+ if "UILaunchScreen" not in info and "UILaunchStoryboardName" not in info:
174
+ rep.add("warning", "no UILaunchScreen: the app will run letterboxed on modern iPhones")
175
+ if info.get("UILaunchStoryboardName") and not any(
176
+ n.startswith(f"{app}/{info['UILaunchStoryboardName']}.storyboardc") for n in files.names):
177
+ rep.add("error", "UILaunchStoryboardName points to a storyboard that is not in the app")
178
+ if info.get("UIMainStoryboardFile") and not any(
179
+ n.startswith(f"{app}/{info['UIMainStoryboardFile']}.storyboardc") for n in files.names):
180
+ rep.add("error", "UIMainStoryboardFile points to a storyboard that is not in the app")
181
+ for icon in info.get("CFBundleIcons", {}).get("CFBundlePrimaryIcon", {}).get("CFBundleIconFiles", []):
182
+ if not any(n.startswith(f"{app}/{icon}") for n in files.names):
183
+ rep.add("warning", f"icon {icon} is listed in Info.plist but not bundled")
184
+ if not info.get("CFBundleIcons") and not any(n.endswith("Assets.car") for n in files.names):
185
+ rep.add("warning", "no app icon")
186
+ if any(n.endswith(".storyboard") or n.endswith(".xib") for n in files.names):
187
+ rep.add("error", "uncompiled .storyboard/.xib files in the bundle")
188
+ if any(posixpath.splitext(n)[1] == ".xcassets" or ".xcassets/" in n for n in files.names):
189
+ rep.add("warning", "an uncompiled asset catalog (.xcassets) is in the bundle")
190
+
191
+ exe_name = str(info.get("CFBundleExecutable", ""))
192
+ rep.executable = exe_name
193
+ exe_path = f"{app}/{exe_name}"
194
+ if exe_path not in files.names:
195
+ rep.add("error", f"CFBundleExecutable {exe_name!r} is not in the bundle")
196
+ return rep
197
+ data = files.read(exe_path)
198
+ if not macho.is_macho(data):
199
+ rep.add("error", f"{exe_name} is not a Mach-O binary")
200
+ return rep
201
+ try:
202
+ slices = macho.parse(data)
203
+ except (macho.MachOError, IndexError, ValueError, struct.error) as e:
204
+ rep.add("error", f"cannot parse {exe_name}: {e}")
205
+ return rep
206
+ rep.archs = [s.arch for s in slices]
207
+ main = next((s for s in slices if s.arch == "arm64"), None)
208
+ if main is None:
209
+ rep.add("error", "no arm64 slice (iOS devices need arm64)")
210
+ main = slices[0]
211
+ rep.platform, rep.min_os, rep.sdk = main.platform, main.minos, main.sdk
212
+ if main.platform != "iOS":
213
+ rep.add("error", f"built for {main.platform or 'an unknown platform'}, not iOS devices")
214
+ plist_min = str(info.get("MinimumOSVersion", ""))
215
+ if plist_min and main.minos and _ver_tuple(plist_min) != _ver_tuple(main.minos):
216
+ rep.add("warning", f"Info.plist MinimumOSVersion {plist_min} != binary minimum {main.minos}")
217
+ if main.encrypted:
218
+ rep.add("error", "the binary is FairPlay-encrypted (cryptid=1); it cannot be re-signed")
219
+ if not main.has_main:
220
+ rep.add("error", "the executable has no entry point (LC_MAIN)")
221
+ if main.signed:
222
+ rep.add("info", "the binary carries a code signature (ipeddle builds are unsigned; "
223
+ "sideloading tools re-sign anyway)")
224
+ rep.dylibs = [d.path + (" (weak)" if d.kind == "weak" else "") for d in main.dylibs]
225
+ rep.imports = len(main.imports)
226
+
227
+ rep.frameworks = sorted({m.group(1) for n in files.names
228
+ if (m := re.match(rf"^{re.escape(app)}/Frameworks/([^/]+)", n))})
229
+ rep.bundles = sorted({m.group(1) for n in files.names
230
+ if (m := re.match(rf"^{re.escape(app)}/([^/]+\.bundle)/", n))})
231
+
232
+ # Embedded libraries referenced through @rpath must be in Frameworks/.
233
+ for d in main.dylibs:
234
+ if d.path.startswith("@rpath/") or d.path.startswith("@executable_path/"):
235
+ rel = d.path.split("/", 1)[1]
236
+ if not any(n == f"{app}/Frameworks/{rel}" or n.endswith("/" + rel) for n in files.names):
237
+ lvl = "warning" if d.kind == "weak" else "error"
238
+ rep.add(lvl, f"{d.path} is linked but not embedded in Frameworks/")
239
+ for fw in rep.frameworks:
240
+ base = f"{app}/Frameworks/{fw}"
241
+ binpath = f"{base}/{fw[:-len('.framework')]}" if fw.endswith(".framework") else base
242
+ if binpath not in files.names:
243
+ continue
244
+ try:
245
+ fsl = macho.parse(files.read(binpath))
246
+ except (macho.MachOError, IndexError, ValueError, struct.error):
247
+ rep.add("error", f"Frameworks/{fw}: not a valid Mach-O")
248
+ continue
249
+ if not any(s.arch == "arm64" and s.platform == "iOS" for s in fsl):
250
+ rep.add("error", f"Frameworks/{fw}: no arm64 iOS-device slice "
251
+ f"({', '.join(f'{s.arch}/{s.platform}' for s in fsl)})")
252
+ elif any(_ver_tuple(s.minos) > _ver_tuple(main.minos) for s in fsl if s.arch == "arm64"):
253
+ rep.add("warning", f"Frameworks/{fw} requires a newer iOS than the app ({main.minos})")
254
+ if rep.frameworks and "@executable_path/Frameworks" not in main.rpaths:
255
+ rep.add("error", "embedded frameworks present but no @executable_path/Frameworks rpath")
256
+
257
+ flat = [i.name for i in main.imports if i.ordinal == macho.BIND_SPECIAL_FLAT]
258
+ if flat:
259
+ rep.add("warning", f"{len(flat)} symbol(s) use flat-namespace lookup (resolved only at launch)")
260
+
261
+ if sdk is not None:
262
+ _check_symbols(rep, main, sdk)
263
+ else:
264
+ rep.add("info", "symbol check skipped (no SDK: run `ipeddle setup` or pass --sdk)")
265
+ return rep
266
+
267
+
268
+ def _check_symbols(rep: Report, sl: macho.Slice, sdk: Path) -> None:
269
+ idx = SymbolIndex(sdk)
270
+ linked = [d.path for d in sl.dylibs]
271
+ unknown_libs = set()
272
+ for imp in sl.imports:
273
+ if imp.ordinal <= 0:
274
+ continue
275
+ lib = sl.library_for(imp)
276
+ if lib.startswith("@") or lib.startswith("<"):
277
+ continue
278
+ ex = idx.exports(lib)
279
+ if ex is None:
280
+ unknown_libs.add(lib)
281
+ continue
282
+ if imp.name not in ex and not imp.weak:
283
+ rep.unresolved.append((lib, imp.name))
284
+ for lib in sorted(unknown_libs):
285
+ rep.add("warning", f"{lib}: no stub in the SDK, its symbols were not checked")
286
+ if rep.unresolved:
287
+ rep.add("error", f"{len(rep.unresolved)} imported symbol(s) are not exported by the library "
288
+ "they are bound to (the app would crash at launch)")
289
+ # A library linked directly that the SDK says is re-exported by another
290
+ # linked one is usually a split-out framework (e.g. iOS 18's
291
+ # SwiftUICore): on older iOS it may not exist.
292
+ for d in sl.dylibs:
293
+ if d.kind == "weak":
294
+ continue
295
+ parents = idx.reexporters(d.path, [x for x in linked if x != d.path])
296
+ parents = [p for p in parents if p.startswith("/System/Library/Frameworks/")]
297
+ if parents and d.path.startswith("/System/Library/Frameworks/"):
298
+ rep.add("warning", f"{posixpath.basename(d.path)} is linked directly but is re-exported by "
299
+ f"{', '.join(posixpath.basename(p) for p in parents)}; it may not exist "
300
+ f"on iOS {rep.min_os}")
301
+
302
+
303
+ def format_report(rep: Report, verbose: bool = False) -> str:
304
+ from .ui import human_bytes
305
+
306
+ lines = [
307
+ f"App: {rep.app_name} ({rep.bundle_id or '?'})",
308
+ f"Executable: {rep.executable} [{', '.join(rep.archs) or '?'}]",
309
+ f"Platform: {rep.platform or '?'} min {rep.min_os or '?'} (built with SDK {rep.sdk or '?'})",
310
+ f"Size: {human_bytes(rep.size)} uncompressed",
311
+ f"Links: {len(rep.dylibs)} libraries, {rep.imports} imported symbols",
312
+ ]
313
+ if rep.frameworks:
314
+ lines.append(f"Embedded: {', '.join(rep.frameworks)}")
315
+ if rep.bundles:
316
+ lines.append(f"Bundles: {', '.join(rep.bundles)}")
317
+ if verbose:
318
+ lines.append("Libraries:")
319
+ lines.extend(f" {d}" for d in rep.dylibs)
320
+ if rep.unresolved:
321
+ lines.append("Unresolved symbols:")
322
+ for lib, s in rep.unresolved[: (None if verbose else 20)]:
323
+ lines.append(f" {s} <- {lib}")
324
+ if not verbose and len(rep.unresolved) > 20:
325
+ lines.append(f" ... {len(rep.unresolved) - 20} more (use --verbose)")
326
+ for f in rep.findings:
327
+ lines.append(f"{f.level.upper():8}{f.message}")
328
+ lines.append("Result: " + ("OK" if rep.ok else "PROBLEMS FOUND"))
329
+ return "\n".join(lines)