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/package.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Turn SwiftPM build products into ``Payload/<App>.app`` and an ``.ipa``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import plistlib
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import stat
|
|
11
|
+
import zipfile
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import ui
|
|
16
|
+
from .config import AppConfig
|
|
17
|
+
from .errors import IpeddleError
|
|
18
|
+
from .fsutil import rmtree
|
|
19
|
+
|
|
20
|
+
ZIP_EPOCH = (1980, 1, 1, 0, 0, 0) # fixed timestamps -> reproducible .ipa
|
|
21
|
+
|
|
22
|
+
ICON_SPECS = [
|
|
23
|
+
# (file name, pixels, idiom)
|
|
24
|
+
("AppIcon60x60@2x.png", 120, "iphone"),
|
|
25
|
+
("AppIcon60x60@3x.png", 180, "iphone"),
|
|
26
|
+
("AppIcon76x76@2x~ipad.png", 152, "ipad"),
|
|
27
|
+
("AppIcon83.5x83.5@2x~ipad.png", 167, "ipad"),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
XCODE_VARS = re.compile(r"\$\(([A-Z_]+)(?::[a-z0-9]+)?\)|\$\{([A-Z_]+)\}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class PackageReport:
|
|
35
|
+
app: Path
|
|
36
|
+
ipa: Path
|
|
37
|
+
warnings: list[str] = field(default_factory=list)
|
|
38
|
+
flattened_images: int = 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def substitute_vars(obj, env: dict):
|
|
42
|
+
if isinstance(obj, str):
|
|
43
|
+
return XCODE_VARS.sub(lambda m: env.get(m.group(1) or m.group(2), m.group(0)), obj)
|
|
44
|
+
if isinstance(obj, list):
|
|
45
|
+
return [substitute_vars(x, env) for x in obj]
|
|
46
|
+
if isinstance(obj, dict):
|
|
47
|
+
return {k: substitute_vars(v, env) for k, v in obj.items()}
|
|
48
|
+
return obj
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def make_info_plist(cfg: AppConfig, executable: str, bundle_id: str, min_os: str,
|
|
52
|
+
sdk_version: str, icons: list[str]) -> dict:
|
|
53
|
+
name = cfg.display_name or executable
|
|
54
|
+
info = {
|
|
55
|
+
"CFBundleDevelopmentRegion": "en",
|
|
56
|
+
"CFBundleExecutable": executable,
|
|
57
|
+
"CFBundleIdentifier": bundle_id,
|
|
58
|
+
"CFBundleInfoDictionaryVersion": "6.0",
|
|
59
|
+
"CFBundleName": executable[:15],
|
|
60
|
+
"CFBundleDisplayName": name,
|
|
61
|
+
"CFBundlePackageType": "APPL",
|
|
62
|
+
"CFBundleShortVersionString": cfg.version,
|
|
63
|
+
"CFBundleVersion": cfg.build,
|
|
64
|
+
"CFBundleSupportedPlatforms": ["iPhoneOS"],
|
|
65
|
+
"DTPlatformName": "iphoneos",
|
|
66
|
+
"DTPlatformVersion": sdk_version,
|
|
67
|
+
"DTSDKName": f"iphoneos{sdk_version}",
|
|
68
|
+
"MinimumOSVersion": min_os,
|
|
69
|
+
"LSRequiresIPhoneOS": True,
|
|
70
|
+
"UIDeviceFamily": cfg.device_family,
|
|
71
|
+
"UIRequiredDeviceCapabilities": ["arm64"],
|
|
72
|
+
"UILaunchScreen": {},
|
|
73
|
+
"UISupportedInterfaceOrientations": [
|
|
74
|
+
"UIInterfaceOrientationPortrait",
|
|
75
|
+
"UIInterfaceOrientationLandscapeLeft",
|
|
76
|
+
"UIInterfaceOrientationLandscapeRight",
|
|
77
|
+
],
|
|
78
|
+
"UISupportedInterfaceOrientations~ipad": [
|
|
79
|
+
"UIInterfaceOrientationPortrait",
|
|
80
|
+
"UIInterfaceOrientationPortraitUpsideDown",
|
|
81
|
+
"UIInterfaceOrientationLandscapeLeft",
|
|
82
|
+
"UIInterfaceOrientationLandscapeRight",
|
|
83
|
+
],
|
|
84
|
+
}
|
|
85
|
+
if icons:
|
|
86
|
+
iphone = sorted({re.sub(r"@\dx.*", "", i) for i in icons if "~ipad" not in i})
|
|
87
|
+
ipad = sorted({re.sub(r"@\dx.*", "", i) for i in icons if "~ipad" in i})
|
|
88
|
+
info["CFBundleIcons"] = {"CFBundlePrimaryIcon": {"CFBundleIconFiles": iphone}}
|
|
89
|
+
if ipad:
|
|
90
|
+
info["CFBundleIcons~ipad"] = {"CFBundlePrimaryIcon": {"CFBundleIconFiles": iphone + ipad}}
|
|
91
|
+
if cfg.info_plist:
|
|
92
|
+
try:
|
|
93
|
+
with open(cfg.info_plist, "rb") as fh:
|
|
94
|
+
user = plistlib.load(fh)
|
|
95
|
+
except Exception as e:
|
|
96
|
+
raise IpeddleError(f"cannot read {cfg.info_plist}: {e}") from e
|
|
97
|
+
env = {
|
|
98
|
+
"PRODUCT_BUNDLE_IDENTIFIER": bundle_id,
|
|
99
|
+
"EXECUTABLE_NAME": executable,
|
|
100
|
+
"PRODUCT_NAME": executable,
|
|
101
|
+
"PRODUCT_MODULE_NAME": executable,
|
|
102
|
+
"MARKETING_VERSION": cfg.version,
|
|
103
|
+
"CURRENT_PROJECT_VERSION": cfg.build,
|
|
104
|
+
"DEVELOPMENT_LANGUAGE": "en",
|
|
105
|
+
"PRODUCT_BUNDLE_PACKAGE_TYPE": "APPL",
|
|
106
|
+
"IPHONEOS_DEPLOYMENT_TARGET": min_os,
|
|
107
|
+
}
|
|
108
|
+
user = substitute_vars(user, env)
|
|
109
|
+
for key in ("UIMainStoryboardFile", "UILaunchStoryboardName"):
|
|
110
|
+
if key in user:
|
|
111
|
+
ui.warn(f"Info.plist: dropping {key} (storyboards cannot be compiled on Linux); "
|
|
112
|
+
"the launch screen comes from UILaunchScreen instead")
|
|
113
|
+
user.pop(key)
|
|
114
|
+
info.update(user)
|
|
115
|
+
# These must describe the binary we actually ship.
|
|
116
|
+
info["CFBundleExecutable"] = executable
|
|
117
|
+
info["MinimumOSVersion"] = min_os
|
|
118
|
+
info.setdefault("UILaunchScreen", {})
|
|
119
|
+
return info
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def make_icons(src: Path, app: Path, families: list[int]) -> list[str]:
|
|
123
|
+
"""Write home-screen icons from one square PNG (resized when Pillow is
|
|
124
|
+
installed, copied as-is otherwise — iOS scales it)."""
|
|
125
|
+
try:
|
|
126
|
+
from PIL import Image # type: ignore
|
|
127
|
+
except ImportError:
|
|
128
|
+
Image = None
|
|
129
|
+
names = []
|
|
130
|
+
for fname, px, idiom in ICON_SPECS:
|
|
131
|
+
if idiom == "ipad" and 2 not in families:
|
|
132
|
+
continue
|
|
133
|
+
out = app / fname
|
|
134
|
+
if Image is not None:
|
|
135
|
+
with Image.open(src) as im:
|
|
136
|
+
im = im.convert("RGBA")
|
|
137
|
+
bg = Image.new("RGB", im.size, (255, 255, 255)) # icons must be opaque
|
|
138
|
+
bg.paste(im, mask=im.split()[3])
|
|
139
|
+
bg.resize((px, px), Image.LANCZOS).save(out, "PNG")
|
|
140
|
+
else:
|
|
141
|
+
shutil.copyfile(src, out)
|
|
142
|
+
names.append(fname)
|
|
143
|
+
return names
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def find_appicon_in_catalogs(root: Path) -> Path | None:
|
|
147
|
+
best, best_px = None, 0
|
|
148
|
+
for setdir in root.rglob("*.appiconset"):
|
|
149
|
+
try:
|
|
150
|
+
contents = json.loads((setdir / "Contents.json").read_text())
|
|
151
|
+
except (OSError, ValueError):
|
|
152
|
+
contents = {"images": []}
|
|
153
|
+
for img in contents.get("images", []):
|
|
154
|
+
fn = img.get("filename")
|
|
155
|
+
if not fn or not (setdir / fn).is_file():
|
|
156
|
+
continue
|
|
157
|
+
try:
|
|
158
|
+
size = float(str(img.get("size", "0x0")).split("x")[0])
|
|
159
|
+
scale = float(str(img.get("scale", "1x")).rstrip("x"))
|
|
160
|
+
except ValueError:
|
|
161
|
+
size, scale = 0, 1
|
|
162
|
+
px = size * scale
|
|
163
|
+
if px > best_px:
|
|
164
|
+
best, best_px = setdir / fn, px
|
|
165
|
+
return best
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def flatten_asset_catalogs(bundle_root: Path, report: PackageReport) -> None:
|
|
169
|
+
"""There is no ``actool`` on Linux. Image sets become loose files
|
|
170
|
+
(``name.png``, ``name@2x.png`` ...), which ``UIImage(named:)`` and
|
|
171
|
+
SwiftUI's ``Image("name")`` find; everything else is reported."""
|
|
172
|
+
for cat in sorted(bundle_root.rglob("*.xcassets")):
|
|
173
|
+
if not cat.is_dir():
|
|
174
|
+
continue
|
|
175
|
+
dest = cat.parent
|
|
176
|
+
unsupported = set()
|
|
177
|
+
for setdir in cat.rglob("*"):
|
|
178
|
+
if not setdir.is_dir():
|
|
179
|
+
continue
|
|
180
|
+
kind = setdir.suffix
|
|
181
|
+
if kind == ".imageset":
|
|
182
|
+
try:
|
|
183
|
+
contents = json.loads((setdir / "Contents.json").read_text())
|
|
184
|
+
except (OSError, ValueError):
|
|
185
|
+
continue
|
|
186
|
+
name = setdir.stem
|
|
187
|
+
for img in contents.get("images", []):
|
|
188
|
+
fn = img.get("filename")
|
|
189
|
+
if not fn or not (setdir / fn).is_file():
|
|
190
|
+
continue
|
|
191
|
+
scale = str(img.get("scale", "1x"))
|
|
192
|
+
suffix = "" if scale in ("1x", "") else "@" + scale
|
|
193
|
+
ext = Path(fn).suffix
|
|
194
|
+
out = dest / f"{name}{suffix}{ext}"
|
|
195
|
+
if not out.exists():
|
|
196
|
+
shutil.copyfile(setdir / fn, out)
|
|
197
|
+
report.flattened_images += 1
|
|
198
|
+
elif kind in (".colorset", ".symbolset", ".dataset", ".textureset", ".imagestack"):
|
|
199
|
+
unsupported.add(kind)
|
|
200
|
+
rmtree(cat)
|
|
201
|
+
if unsupported:
|
|
202
|
+
report.warnings.append(
|
|
203
|
+
f"{cat.name}: {', '.join(sorted(unsupported))} entries are not supported without actool "
|
|
204
|
+
"(use code-defined colors/data instead)"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def check_uncompilable(root: Path, report: PackageReport) -> None:
|
|
209
|
+
for pattern in ("*.storyboard", "*.xib"):
|
|
210
|
+
for f in root.rglob(pattern):
|
|
211
|
+
report.warnings.append(
|
|
212
|
+
f"{f.relative_to(root)}: Interface Builder files cannot be compiled on Linux "
|
|
213
|
+
"(no ibtool); build that UI in code"
|
|
214
|
+
)
|
|
215
|
+
rmtree(f)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _copy(src: Path, dst: Path) -> None:
|
|
219
|
+
if src.is_dir():
|
|
220
|
+
shutil.copytree(src, dst, symlinks=True, dirs_exist_ok=True)
|
|
221
|
+
else:
|
|
222
|
+
shutil.copy2(src, dst)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def assemble_app(*, work: Path, products_dir: Path, executable: str, cfg: AppConfig,
|
|
226
|
+
bundle_id: str, min_os: str, sdk_version: str, app_name: str) -> PackageReport:
|
|
227
|
+
payload = work / "Payload"
|
|
228
|
+
rmtree(payload)
|
|
229
|
+
app = payload / f"{app_name}.app"
|
|
230
|
+
app.mkdir(parents=True)
|
|
231
|
+
report = PackageReport(app=app, ipa=Path())
|
|
232
|
+
|
|
233
|
+
binary = products_dir / executable
|
|
234
|
+
if not binary.is_file():
|
|
235
|
+
raise IpeddleError(f"build finished but {binary} is missing")
|
|
236
|
+
shutil.copy2(binary, app / executable)
|
|
237
|
+
os.chmod(app / executable, 0o755)
|
|
238
|
+
|
|
239
|
+
# SwiftPM resource bundles (Bundle.module looks next to the executable).
|
|
240
|
+
for b in sorted(products_dir.glob("*.bundle")):
|
|
241
|
+
_copy(b, app / b.name)
|
|
242
|
+
# Dynamic frameworks / dylibs produced or vended by the package graph.
|
|
243
|
+
fw_dir = app / "Frameworks"
|
|
244
|
+
for fw in sorted(products_dir.glob("*.framework")):
|
|
245
|
+
fw_dir.mkdir(exist_ok=True)
|
|
246
|
+
_copy(fw, fw_dir / fw.name)
|
|
247
|
+
# Like Xcode's "Embed": build-time-only parts do not ship.
|
|
248
|
+
for part in ("Headers", "PrivateHeaders", "Modules", "_CodeSignature"):
|
|
249
|
+
rmtree(fw_dir / fw.name / part)
|
|
250
|
+
for dylib in sorted(products_dir.glob("*.dylib")):
|
|
251
|
+
fw_dir.mkdir(exist_ok=True)
|
|
252
|
+
_copy(dylib, fw_dir / dylib.name)
|
|
253
|
+
for extra in cfg.resources:
|
|
254
|
+
_copy(extra, app / extra.name)
|
|
255
|
+
|
|
256
|
+
icon = cfg.icon
|
|
257
|
+
if icon is None:
|
|
258
|
+
icon = find_appicon_in_catalogs(app)
|
|
259
|
+
if icon is not None:
|
|
260
|
+
ui.info(f"using app icon from asset catalog: {icon.name}")
|
|
261
|
+
flatten_asset_catalogs(app, report)
|
|
262
|
+
check_uncompilable(app, report)
|
|
263
|
+
|
|
264
|
+
icons = make_icons(icon, app, cfg.device_family) if icon else []
|
|
265
|
+
if not icon:
|
|
266
|
+
report.warnings.append("no app icon (set `icon` in ipeddle.toml); iOS will show a blank one")
|
|
267
|
+
|
|
268
|
+
info = make_info_plist(cfg, executable, bundle_id, min_os, sdk_version, icons)
|
|
269
|
+
with open(app / "Info.plist", "wb") as fh:
|
|
270
|
+
plistlib.dump(info, fh, fmt=plistlib.FMT_BINARY)
|
|
271
|
+
(app / "PkgInfo").write_bytes(b"APPL????")
|
|
272
|
+
return report
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def write_ipa(payload_parent: Path, ipa: Path) -> Path:
|
|
276
|
+
"""Zip ``Payload/`` deterministically (sorted entries, fixed times)."""
|
|
277
|
+
ipa.parent.mkdir(parents=True, exist_ok=True)
|
|
278
|
+
tmp = ipa.with_name(ipa.name + ".tmp")
|
|
279
|
+
root = payload_parent
|
|
280
|
+
entries = []
|
|
281
|
+
for dirpath, dirnames, filenames in os.walk(root / "Payload"):
|
|
282
|
+
dirnames.sort()
|
|
283
|
+
rel_dir = Path(dirpath).relative_to(root)
|
|
284
|
+
entries.append((rel_dir, True))
|
|
285
|
+
for f in sorted(filenames):
|
|
286
|
+
entries.append((rel_dir / f, False))
|
|
287
|
+
with zipfile.ZipFile(tmp, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
|
|
288
|
+
for rel, is_dir in entries:
|
|
289
|
+
full = root / rel
|
|
290
|
+
if is_dir:
|
|
291
|
+
zi = zipfile.ZipInfo(str(rel) + "/", ZIP_EPOCH)
|
|
292
|
+
zi.external_attr = (0o40755 << 16) | 0x10
|
|
293
|
+
zf.writestr(zi, b"")
|
|
294
|
+
continue
|
|
295
|
+
if full.is_symlink():
|
|
296
|
+
zi = zipfile.ZipInfo(str(rel), ZIP_EPOCH)
|
|
297
|
+
zi.create_system = 3
|
|
298
|
+
zi.external_attr = (stat.S_IFLNK | 0o777) << 16
|
|
299
|
+
zf.writestr(zi, os.readlink(full))
|
|
300
|
+
continue
|
|
301
|
+
mode = full.stat().st_mode
|
|
302
|
+
zi = zipfile.ZipInfo(str(rel), ZIP_EPOCH)
|
|
303
|
+
zi.create_system = 3
|
|
304
|
+
zi.external_attr = ((stat.S_IFREG | (0o755 if mode & 0o111 else 0o644)) << 16)
|
|
305
|
+
zi.compress_type = zipfile.ZIP_DEFLATED
|
|
306
|
+
with open(full, "rb") as fh, zf.open(zi, "w") as out:
|
|
307
|
+
shutil.copyfileobj(fh, out, 1 << 20)
|
|
308
|
+
os.replace(tmp, ipa)
|
|
309
|
+
return ipa
|
ipeddle/patcher.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Make a public iOS SDK usable for linking Swift apps that run on iOS 15+.
|
|
2
|
+
|
|
3
|
+
Public SDK mirrors ship *reconstructed* ``.tbd`` stubs: they describe the
|
|
4
|
+
libraries of the iOS version the SDK was taken from, without the
|
|
5
|
+
``$ld$previous`` / install-name history that Xcode's stubs carry. Two things
|
|
6
|
+
go wrong when an app built against them runs on an older iOS:
|
|
7
|
+
|
|
8
|
+
1. **Swift overlays.** Up to iOS 17, the Swift half of a framework
|
|
9
|
+
(``Foundation``'s ``Data``, ``URLRequest``, ...) lived in an overlay dylib,
|
|
10
|
+
``/usr/lib/swift/libswiftFoundation.dylib``. From iOS 18 it lives inside
|
|
11
|
+
the framework and the overlay only re-exports it. The reconstructed stubs
|
|
12
|
+
put the symbols in the framework, so the linker binds them there and the
|
|
13
|
+
app fails to launch on iOS 15-17. Fix: every Swift symbol *owned by
|
|
14
|
+
module X* that a framework re-exported by ``libswiftX`` exports is moved
|
|
15
|
+
to ``libswiftX.tbd``. Binding through the overlay works on every version
|
|
16
|
+
(it defines the symbol on old systems and re-exports it on new ones).
|
|
17
|
+
|
|
18
|
+
2. **Split frameworks.** Frameworks sometimes get split (``SwiftUICore`` was
|
|
19
|
+
carved out of ``SwiftUI`` in iOS 18). Declarations that moved keep their
|
|
20
|
+
original mangling (``@_originallyDefinedIn``), so a symbol mangled as
|
|
21
|
+
owned by ``SwiftUI`` but exported by ``SwiftUICore`` really belongs to the
|
|
22
|
+
parent. Fix: such symbols move back into the parent framework that
|
|
23
|
+
re-exports the child; the app then only depends on the parent.
|
|
24
|
+
|
|
25
|
+
A third class, Swift symbols the stubs do not list at all, is handled at
|
|
26
|
+
build time (see :func:`learn_missing`).
|
|
27
|
+
|
|
28
|
+
Nothing here contains Apple data: every rule is applied to the SDK the user
|
|
29
|
+
fetched, on the user's machine.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
from collections import defaultdict
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
from . import tbd
|
|
41
|
+
from .mangling import is_swift_symbol, owner_module
|
|
42
|
+
|
|
43
|
+
PATCH_VERSION = 3
|
|
44
|
+
REPORT_NAME = ".ipeddle-patches.json"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class PatchReport:
|
|
49
|
+
overlays: dict[str, int] = field(default_factory=dict) # libswiftX -> moved count
|
|
50
|
+
merged: dict[str, int] = field(default_factory=dict) # child -> moved count
|
|
51
|
+
learned: dict[str, list[str]] = field(default_factory=dict) # libswiftX -> symbols
|
|
52
|
+
skipped: list[str] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
def to_json(self) -> dict:
|
|
55
|
+
return {
|
|
56
|
+
"patch_version": PATCH_VERSION,
|
|
57
|
+
"overlays": self.overlays,
|
|
58
|
+
"merged": self.merged,
|
|
59
|
+
"learned": self.learned,
|
|
60
|
+
"skipped": self.skipped,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def load(cls, sdk: Path) -> PatchReport:
|
|
65
|
+
p = sdk / REPORT_NAME
|
|
66
|
+
try:
|
|
67
|
+
d = json.loads(p.read_text())
|
|
68
|
+
except (FileNotFoundError, ValueError):
|
|
69
|
+
return cls()
|
|
70
|
+
return cls(d.get("overlays", {}), d.get("merged", {}), d.get("learned", {}), d.get("skipped", []))
|
|
71
|
+
|
|
72
|
+
def save(self, sdk: Path) -> None:
|
|
73
|
+
(sdk / REPORT_NAME).write_text(json.dumps(self.to_json(), indent=2, sort_keys=True) + "\n")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def known_modules(sdk: Path) -> frozenset[str]:
|
|
77
|
+
"""Names of every Swift module the SDK ships (plus the standard library)."""
|
|
78
|
+
names = {"Swift"}
|
|
79
|
+
for pattern in ("System/Library/Frameworks/*.framework/Modules/*.swiftmodule",
|
|
80
|
+
"usr/lib/swift/*.swiftmodule"):
|
|
81
|
+
names.update(p.name[: -len(".swiftmodule")] for p in sdk.glob(pattern))
|
|
82
|
+
return frozenset(names)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def tbd_path(sdk: Path, install_name: str) -> Path | None:
|
|
86
|
+
"""Locate the stub for an install name inside the SDK."""
|
|
87
|
+
rel = install_name.lstrip("/")
|
|
88
|
+
for cand in (sdk / (rel + ".tbd"), sdk / (os.path.splitext(rel)[0] + ".tbd")):
|
|
89
|
+
if cand.is_file():
|
|
90
|
+
return cand
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _doc_for(docs, install_name):
|
|
95
|
+
for d in docs:
|
|
96
|
+
if d.install_name == install_name:
|
|
97
|
+
return d
|
|
98
|
+
return docs[0] if docs else None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def overlay_module(path: Path) -> str | None:
|
|
102
|
+
name = path.name
|
|
103
|
+
if name.startswith("libswift") and name.endswith(".tbd"):
|
|
104
|
+
return name[len("libswift"):-len(".tbd")] or None
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def is_stub_overlay(docs, text: str = "") -> bool:
|
|
109
|
+
"""A reconstructed overlay: it re-exports something and owns no Swift
|
|
110
|
+
(or only Swift that ipeddle itself put there)."""
|
|
111
|
+
if not docs:
|
|
112
|
+
return False
|
|
113
|
+
d = docs[0]
|
|
114
|
+
if not d.reexports:
|
|
115
|
+
return False
|
|
116
|
+
return tbd.MARKER in text or not any(is_swift_symbol(s) for s in d.exports)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def move_symbols(src: Path, dst: Path, symbols, note: str) -> int:
|
|
120
|
+
symbols = sorted(set(symbols))
|
|
121
|
+
if not symbols:
|
|
122
|
+
return 0
|
|
123
|
+
tbd.write_atomic(dst, tbd.add_symbols(dst.read_text(), symbols, note=note))
|
|
124
|
+
tbd.write_atomic(src, tbd.hide_symbols(src.read_text(), symbols))
|
|
125
|
+
return len(symbols)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def relocate_overlays(sdk: Path, report: PatchReport, known: frozenset[str]) -> None:
|
|
129
|
+
swift_dir = sdk / "usr/lib/swift"
|
|
130
|
+
if not swift_dir.is_dir():
|
|
131
|
+
report.skipped.append("no usr/lib/swift in SDK")
|
|
132
|
+
return
|
|
133
|
+
for ov in sorted(swift_dir.glob("libswift*.tbd")):
|
|
134
|
+
module = overlay_module(ov)
|
|
135
|
+
try:
|
|
136
|
+
docs = tbd.read(ov)
|
|
137
|
+
except tbd.TbdError:
|
|
138
|
+
report.skipped.append(f"unreadable {ov.name}")
|
|
139
|
+
continue
|
|
140
|
+
if not module or not is_stub_overlay(docs):
|
|
141
|
+
continue
|
|
142
|
+
moved = 0
|
|
143
|
+
for target in docs[0].reexports:
|
|
144
|
+
fw = tbd_path(sdk, target)
|
|
145
|
+
if fw is None:
|
|
146
|
+
continue
|
|
147
|
+
fdoc = _doc_for(tbd.read(fw), target)
|
|
148
|
+
if fdoc is None:
|
|
149
|
+
continue
|
|
150
|
+
owned = [s for s in fdoc.exports if is_swift_symbol(s) and owner_module(s, known) == module]
|
|
151
|
+
moved += move_symbols(fw, ov, owned, f"moved from {target} (Swift overlay of {module})")
|
|
152
|
+
if moved:
|
|
153
|
+
report.overlays[ov.name] = report.overlays.get(ov.name, 0) + moved
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def merge_split_frameworks(sdk: Path, report: PatchReport, known: frozenset[str]) -> None:
|
|
157
|
+
fw_root = sdk / "System/Library/Frameworks"
|
|
158
|
+
if not fw_root.is_dir():
|
|
159
|
+
return
|
|
160
|
+
for parent_tbd in sorted(fw_root.glob("*.framework/*.tbd")):
|
|
161
|
+
parent_module = parent_tbd.stem
|
|
162
|
+
try:
|
|
163
|
+
pdocs = tbd.read(parent_tbd)
|
|
164
|
+
except tbd.TbdError:
|
|
165
|
+
continue
|
|
166
|
+
if not pdocs:
|
|
167
|
+
continue
|
|
168
|
+
for child in pdocs[0].reexports:
|
|
169
|
+
if not child.startswith("/System/Library/Frameworks/"):
|
|
170
|
+
continue
|
|
171
|
+
ctbd = tbd_path(sdk, child)
|
|
172
|
+
if ctbd is None or ctbd == parent_tbd:
|
|
173
|
+
continue
|
|
174
|
+
cdoc = _doc_for(tbd.read(ctbd), child)
|
|
175
|
+
if cdoc is None:
|
|
176
|
+
continue
|
|
177
|
+
owned = [s for s in cdoc.exports if is_swift_symbol(s) and owner_module(s, known) == parent_module]
|
|
178
|
+
n = move_symbols(ctbd, parent_tbd, owned, f"originally defined in {parent_module}, moved back from {child}")
|
|
179
|
+
if n:
|
|
180
|
+
report.merged[Path(child).name] = report.merged.get(Path(child).name, 0) + n
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def apply_learned(sdk: Path, report: PatchReport) -> None:
|
|
184
|
+
for lib, syms in report.learned.items():
|
|
185
|
+
p = sdk / "usr/lib/swift" / lib
|
|
186
|
+
if p.is_file():
|
|
187
|
+
tbd.write_atomic(p, tbd.add_symbols(p.read_text(), syms, note="learned at link time"))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def patch_sdk(sdk: Path) -> PatchReport:
|
|
191
|
+
"""Apply every rule to a *derived* SDK copy (never to the pristine one)."""
|
|
192
|
+
report = PatchReport.load(sdk)
|
|
193
|
+
known = known_modules(sdk)
|
|
194
|
+
relocate_overlays(sdk, report, known)
|
|
195
|
+
merge_split_frameworks(sdk, report, known)
|
|
196
|
+
apply_learned(sdk, report)
|
|
197
|
+
report.save(sdk)
|
|
198
|
+
return report
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def overlays_exporting(sdk: Path, symbols) -> set[str]:
|
|
202
|
+
"""Learned overlays (as ``-l`` names, e.g. ``swiftPhotosUI``) that now
|
|
203
|
+
export at least one of ``symbols``; an app needing them links them."""
|
|
204
|
+
report = PatchReport.load(sdk)
|
|
205
|
+
wanted = set(symbols)
|
|
206
|
+
return {lib[3:-4] for lib, syms in report.learned.items() if wanted & set(syms)}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def learn_missing(sdk: Path, symbols) -> dict[str, list[str]]:
|
|
210
|
+
"""Declare undefined Swift symbols in the overlay of their owning module.
|
|
211
|
+
|
|
212
|
+
Returns ``{overlay.tbd: [symbols]}`` for what was added. Symbols whose
|
|
213
|
+
owner has no reconstructed overlay are left alone (they are real errors).
|
|
214
|
+
"""
|
|
215
|
+
report = PatchReport.load(sdk)
|
|
216
|
+
by_lib: dict[str, set[str]] = defaultdict(set)
|
|
217
|
+
swift_dir = sdk / "usr/lib/swift"
|
|
218
|
+
known = known_modules(sdk)
|
|
219
|
+
for s in symbols:
|
|
220
|
+
if not is_swift_symbol(s):
|
|
221
|
+
continue
|
|
222
|
+
mod = owner_module(s, known)
|
|
223
|
+
if not mod:
|
|
224
|
+
continue
|
|
225
|
+
ov = swift_dir / f"libswift{mod}.tbd"
|
|
226
|
+
if not ov.is_file():
|
|
227
|
+
continue
|
|
228
|
+
try:
|
|
229
|
+
text = ov.read_text()
|
|
230
|
+
if not is_stub_overlay(tbd.parse(text), text):
|
|
231
|
+
continue
|
|
232
|
+
except tbd.TbdError:
|
|
233
|
+
continue
|
|
234
|
+
by_lib[ov.name].add(s)
|
|
235
|
+
added: dict[str, list[str]] = {}
|
|
236
|
+
for lib, syms in by_lib.items():
|
|
237
|
+
p = swift_dir / lib
|
|
238
|
+
before = tbd.read(p)[0].exports
|
|
239
|
+
new = sorted(syms - before)
|
|
240
|
+
if not new:
|
|
241
|
+
continue
|
|
242
|
+
tbd.write_atomic(p, tbd.add_symbols(p.read_text(), new, note="learned at link time"))
|
|
243
|
+
report.learned[lib] = sorted(set(report.learned.get(lib, [])) | set(new))
|
|
244
|
+
added[lib] = new
|
|
245
|
+
if added:
|
|
246
|
+
report.save(sdk)
|
|
247
|
+
return added
|
ipeddle/paths.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Where ipeddle keeps things, and its small persistent state file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
BUNDLE_NAME = "ipeddle-ios.artifactbundle"
|
|
11
|
+
SWIFT_SDK_ID = "ipeddle-ios"
|
|
12
|
+
TRIPLE = "arm64-apple-ios"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Home:
|
|
17
|
+
root: Path
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def default(cls) -> Home:
|
|
21
|
+
env = os.environ.get("IPEDDLE_HOME")
|
|
22
|
+
if env:
|
|
23
|
+
return cls(Path(env).expanduser().resolve())
|
|
24
|
+
xdg = os.environ.get("XDG_DATA_HOME")
|
|
25
|
+
base = Path(xdg).expanduser() if xdg else Path.home() / ".local" / "share"
|
|
26
|
+
return cls((base / "ipeddle").resolve())
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def state_file(self) -> Path:
|
|
30
|
+
return self.root / "state.json"
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def toolchains(self) -> Path:
|
|
34
|
+
return self.root / "toolchains"
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def sources(self) -> Path:
|
|
38
|
+
return self.root / "sources"
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def sdk_dir(self) -> Path:
|
|
42
|
+
return self.root / "sdk"
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def resource_dir(self) -> Path:
|
|
46
|
+
return self.root / "resource"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def swift_sdks(self) -> Path:
|
|
50
|
+
return self.root / "swift-sdks"
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def bundle(self) -> Path:
|
|
54
|
+
return self.swift_sdks / BUNDLE_NAME
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def module_cache(self) -> Path:
|
|
58
|
+
return self.root / "cache" / "modules"
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def downloads(self) -> Path:
|
|
62
|
+
return self.root / "downloads"
|
|
63
|
+
|
|
64
|
+
def load_state(self) -> dict:
|
|
65
|
+
try:
|
|
66
|
+
return json.loads(self.state_file.read_text())
|
|
67
|
+
except FileNotFoundError:
|
|
68
|
+
return {}
|
|
69
|
+
except ValueError:
|
|
70
|
+
return {}
|
|
71
|
+
|
|
72
|
+
def save_state(self, state: dict) -> None:
|
|
73
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
tmp = self.state_file.with_suffix(".tmp")
|
|
75
|
+
tmp.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n")
|
|
76
|
+
os.replace(tmp, self.state_file)
|
ipeddle/proc.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Subprocess helpers: always bounded, output captured or streamed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import ui
|
|
12
|
+
from .errors import IpeddleError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run(cmd, *, cwd=None, env=None, timeout=600, check=True, capture=True, log: Path | None = None):
|
|
16
|
+
"""Run ``cmd``; kill the whole process group on timeout.
|
|
17
|
+
|
|
18
|
+
With ``log`` set, output is written to that file (and the tail is shown
|
|
19
|
+
on failure). Returns a CompletedProcess with ``stdout`` as text.
|
|
20
|
+
"""
|
|
21
|
+
cmd = [str(c) for c in cmd]
|
|
22
|
+
ui.debug("$ " + shlex.join(cmd))
|
|
23
|
+
full_env = dict(os.environ)
|
|
24
|
+
if env:
|
|
25
|
+
full_env.update(env)
|
|
26
|
+
out_fh = None
|
|
27
|
+
if log is not None:
|
|
28
|
+
log.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
out_fh = open(log, "w")
|
|
30
|
+
stdout, stderr = out_fh, subprocess.STDOUT
|
|
31
|
+
elif capture:
|
|
32
|
+
stdout, stderr = subprocess.PIPE, subprocess.STDOUT
|
|
33
|
+
else:
|
|
34
|
+
stdout = stderr = None
|
|
35
|
+
try:
|
|
36
|
+
p = subprocess.Popen(cmd, cwd=cwd, env=full_env, stdout=stdout, stderr=stderr,
|
|
37
|
+
text=True, start_new_session=True)
|
|
38
|
+
except FileNotFoundError as e:
|
|
39
|
+
raise IpeddleError(f"command not found: {cmd[0]}") from e
|
|
40
|
+
try:
|
|
41
|
+
out, _ = p.communicate(timeout=timeout)
|
|
42
|
+
except subprocess.TimeoutExpired:
|
|
43
|
+
try:
|
|
44
|
+
os.killpg(p.pid, signal.SIGKILL)
|
|
45
|
+
except ProcessLookupError:
|
|
46
|
+
pass
|
|
47
|
+
p.wait()
|
|
48
|
+
raise IpeddleError(f"timed out after {timeout}s: {shlex.join(cmd[:3])} ...")
|
|
49
|
+
finally:
|
|
50
|
+
if out_fh:
|
|
51
|
+
out_fh.close()
|
|
52
|
+
if log is not None:
|
|
53
|
+
out = log.read_text(errors="replace")
|
|
54
|
+
cp = subprocess.CompletedProcess(cmd, p.returncode, out or "", "")
|
|
55
|
+
if check and p.returncode != 0:
|
|
56
|
+
tail = "\n".join((out or "").splitlines()[-25:])
|
|
57
|
+
raise IpeddleError(f"command failed ({p.returncode}): {shlex.join(cmd[:4])} ...\n{tail}")
|
|
58
|
+
return cp
|