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/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