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/__init__.py +3 -0
- ipeddle/__main__.py +3 -0
- ipeddle/build.py +309 -0
- ipeddle/cli.py +219 -0
- ipeddle/config.py +95 -0
- ipeddle/doctor.py +173 -0
- ipeddle/errors.py +11 -0
- ipeddle/fsutil.py +131 -0
- ipeddle/inspect.py +329 -0
- ipeddle/macho.py +281 -0
- ipeddle/mangling.py +136 -0
- ipeddle/package.py +309 -0
- ipeddle/patcher.py +247 -0
- ipeddle/paths.py +76 -0
- ipeddle/proc.py +58 -0
- ipeddle/runtime.py +73 -0
- ipeddle/scaffold.py +83 -0
- ipeddle/sdk.py +130 -0
- ipeddle/setup.py +309 -0
- ipeddle/swiftsdk.py +143 -0
- ipeddle/tbd.py +308 -0
- ipeddle/toolchain.py +194 -0
- ipeddle/ui.py +56 -0
- ipeddle/xip.py +213 -0
- ipeddle-0.1.0.dist-info/METADATA +272 -0
- ipeddle-0.1.0.dist-info/RECORD +30 -0
- ipeddle-0.1.0.dist-info/WHEEL +4 -0
- ipeddle-0.1.0.dist-info/entry_points.txt +2 -0
- ipeddle-0.1.0.dist-info/licenses/LICENSE +201 -0
- ipeddle-0.1.0.dist-info/licenses/NOTICE +27 -0
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)
|