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 ADDED
@@ -0,0 +1,3 @@
1
+ """ipeddle: build iOS apps (.ipa) on Linux, no Mac or Xcode required."""
2
+
3
+ __version__ = "0.1.0"
ipeddle/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from ipeddle.cli import main
2
+
3
+ raise SystemExit(main())
ipeddle/build.py ADDED
@@ -0,0 +1,309 @@
1
+ """``ipeddle build``: SwiftPM package -> unsigned .ipa."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import signal
10
+ import subprocess
11
+ import threading
12
+ import time
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ from . import config as appconfig
17
+ from . import package, patcher, proc, ui
18
+ from .errors import IpeddleError
19
+ from .fsutil import locked, private_tmp, rmtree
20
+ from .paths import SWIFT_SDK_ID, TRIPLE, Home
21
+ from .setup import Installation, load_installation
22
+
23
+ UNDEFINED_RE = re.compile(r"ld64\.lld: error: undefined symbol: (\S+)")
24
+ NOISE = (
25
+ "does not support linking for platform iOS",
26
+ "'--build-system native' has been deprecated",
27
+ "Unable to locate libSwiftScan",
28
+ )
29
+ MAX_LEARN_ROUNDS = 3
30
+
31
+
32
+ @dataclass
33
+ class BuildResult:
34
+ ipa: Path
35
+ app: Path
36
+ seconds: float
37
+ compile_seconds: float
38
+ learned: dict
39
+ warnings: list[str]
40
+
41
+
42
+ def _describe(app_dir: Path, inst: Installation, cache_dir: Path) -> dict:
43
+ """``swift package describe`` (cached on the manifest's contents)."""
44
+ h = hashlib.sha256()
45
+ for name in ("Package.swift", "Package.resolved"):
46
+ p = app_dir / name
47
+ if p.exists():
48
+ h.update(p.read_bytes())
49
+ for p in sorted(app_dir.glob("Package@swift-*.swift")):
50
+ h.update(p.read_bytes())
51
+ key = h.hexdigest()[:16]
52
+ cache = cache_dir / f"describe-{key}.json"
53
+ if cache.exists():
54
+ try:
55
+ return json.loads(cache.read_text())
56
+ except ValueError:
57
+ pass
58
+ out = proc.run([inst.swift.tool("swift"), "package", "--package-path", app_dir,
59
+ "describe", "--type", "json"], timeout=600, check=False)
60
+ text = out.stdout
61
+ start = text.find("{")
62
+ if out.returncode != 0 or start < 0:
63
+ raise IpeddleError(
64
+ f"`swift package describe` failed for {app_dir}:\n" + "\n".join(text.splitlines()[-20:]),
65
+ hint="is this a SwiftPM package with a valid Package.swift?",
66
+ )
67
+ data = json.loads(text[start:])
68
+ cache_dir.mkdir(parents=True, exist_ok=True)
69
+ for old in cache_dir.glob("describe-*.json"):
70
+ old.unlink()
71
+ cache.write_text(json.dumps(data))
72
+ return data
73
+
74
+
75
+ def pick_product(desc: dict, wanted: str | None) -> str:
76
+ exes = [p["name"] for p in desc.get("products", []) if "executable" in p.get("type", {})]
77
+ if wanted:
78
+ if wanted not in exes:
79
+ raise IpeddleError(
80
+ f"no executable product named {wanted!r}",
81
+ hint="executable products: " + (", ".join(exes) or "none"),
82
+ )
83
+ return wanted
84
+ if len(exes) == 1:
85
+ return exes[0]
86
+ if not exes:
87
+ raise IpeddleError(
88
+ "the package has no executable product",
89
+ hint="add `.executable(name: \"MyApp\", targets: [\"MyApp\"])` with an "
90
+ "`.executableTarget` that contains your `@main` App (see examples/hello)",
91
+ )
92
+ raise IpeddleError(
93
+ f"several executable products ({', '.join(exes)})",
94
+ hint="choose one with --product or `product` in ipeddle.toml",
95
+ )
96
+
97
+
98
+ def deployment_target(desc: dict) -> str:
99
+ for p in desc.get("platforms", []):
100
+ if p.get("name") == "ios":
101
+ return p.get("version", "16.0")
102
+ raise IpeddleError(
103
+ "Package.swift does not declare an iOS deployment target",
104
+ hint="add `platforms: [.iOS(.v16)]` (15 or newer) to the Package(...) declaration",
105
+ )
106
+
107
+
108
+ def _version_tuple(v: str) -> tuple[int, ...]:
109
+ return tuple(int(x) for x in re.findall(r"\d+", v)[:3])
110
+
111
+
112
+ def _stream(cmd, cwd: Path, log: Path, timeout: int) -> tuple[int, str]:
113
+ """Run ``cmd`` streaming interesting lines; full output goes to ``log``."""
114
+ log.parent.mkdir(parents=True, exist_ok=True)
115
+ ui.debug("$ " + " ".join(str(c) for c in cmd))
116
+ lines = []
117
+ timed_out = threading.Event()
118
+ with open(log, "w") as fh:
119
+ p = subprocess.Popen([str(c) for c in cmd], cwd=cwd, stdout=subprocess.PIPE,
120
+ stderr=subprocess.STDOUT, text=True, bufsize=1, start_new_session=True)
121
+
122
+ def _kill():
123
+ timed_out.set()
124
+ try:
125
+ os.killpg(p.pid, signal.SIGKILL)
126
+ except ProcessLookupError:
127
+ pass
128
+
129
+ watchdog = threading.Timer(timeout, _kill)
130
+ watchdog.daemon = True
131
+ watchdog.start()
132
+ try:
133
+ assert p.stdout is not None
134
+ for line in p.stdout:
135
+ fh.write(line)
136
+ lines.append(line)
137
+ s = line.rstrip()
138
+ if any(n in s for n in NOISE):
139
+ continue
140
+ if re.match(r"^\[\d+/\d+\] (Compiling|Linking|Emitting module)", s) or s.startswith("Build complete"):
141
+ ui.info(s)
142
+ elif ("error:" in s or "warning:" in s) and not s.startswith("ld64.lld: error: undefined symbol"):
143
+ ui.info(s)
144
+ p.wait()
145
+ except KeyboardInterrupt:
146
+ os.killpg(p.pid, signal.SIGINT)
147
+ p.wait()
148
+ raise
149
+ finally:
150
+ watchdog.cancel()
151
+ if timed_out.is_set():
152
+ raise IpeddleError(f"build timed out after {timeout}s (log: {log})",
153
+ hint="raise it with --timeout")
154
+ return p.returncode, "".join(lines)
155
+
156
+
157
+ def sdk_stamp(inst: Installation) -> str:
158
+ rep = (inst.sdk / patcher.REPORT_NAME)
159
+ data = rep.read_bytes() if rep.exists() else b""
160
+ return hashlib.sha256(data + str(inst.sdk).encode() + inst.swift.version.encode()).hexdigest()[:16]
161
+
162
+
163
+ def build(app_dir: Path, *, home: Home | None = None, **kwargs) -> BuildResult:
164
+ app_dir = Path(app_dir).resolve()
165
+ with private_tmp(app_dir / ".build" / "ipeddle" / "tmp"):
166
+ return _build(app_dir, home=home, **kwargs)
167
+
168
+
169
+ def _build(app_dir: Path, *, home: Home | None = None, configuration: str = "release",
170
+ product: str | None = None, output: Path | None = None, clean: bool = False,
171
+ timeout: int = 3600, jobs: int | None = None) -> BuildResult:
172
+ t0 = time.monotonic()
173
+ home = home or Home.default()
174
+ app_dir = Path(app_dir).resolve()
175
+ if not (app_dir / "Package.swift").exists():
176
+ raise IpeddleError(
177
+ f"{app_dir} has no Package.swift",
178
+ hint="ipeddle builds SwiftPM packages; see examples/hello for the layout",
179
+ )
180
+ inst = load_installation(home)
181
+ cfg = appconfig.load(app_dir)
182
+ build_root = app_dir / ".build"
183
+ meta = build_root / "ipeddle"
184
+ if clean:
185
+ ui.step("cleaning previous build")
186
+ rmtree(build_root / TRIPLE)
187
+ for child in meta.glob("*") if meta.is_dir() else []:
188
+ if child.name != "tmp":
189
+ rmtree(child) if child.is_dir() else child.unlink()
190
+ meta.mkdir(parents=True, exist_ok=True)
191
+
192
+ desc = _describe(app_dir, inst, meta)
193
+ exe = pick_product(desc, product or cfg.product)
194
+ min_os = deployment_target(desc)
195
+ if _version_tuple(min_os) < (15, 0):
196
+ raise IpeddleError(
197
+ f"deployment target iOS {min_os} is below what ipeddle supports",
198
+ hint="use .iOS(.v15) or newer in Package.swift (back-deployment libraries "
199
+ "for older iOS only ship with Xcode)",
200
+ )
201
+ bundle_id = cfg.bundle_id or appconfig.default_bundle_id(exe)
202
+ ui.step(f"building {exe} ({configuration}, iOS {min_os}+, {bundle_id})")
203
+
204
+ products_dir = build_root / TRIPLE / configuration
205
+ stamp_file = meta / "sdk-stamp"
206
+ stamp = sdk_stamp(inst)
207
+ if stamp_file.exists() and stamp_file.read_text() != stamp:
208
+ # The SDK stubs changed (new setup, learned symbols): relink.
209
+ ui.info("SDK changed since the last build; relinking")
210
+ (products_dir / exe).unlink(missing_ok=True)
211
+
212
+ learned_all: dict[str, list[str]] = {}
213
+ libs_file = meta / "link-libs.json"
214
+ try:
215
+ project_libs = set(json.loads(libs_file.read_text()))
216
+ except (OSError, ValueError):
217
+ project_libs = set()
218
+ compile_t0 = time.monotonic()
219
+ for round_no in range(MAX_LEARN_ROUNDS + 1):
220
+ cmd = [inst.swift.tool("swift"), "build",
221
+ "--package-path", app_dir,
222
+ "--swift-sdks-path", home.swift_sdks,
223
+ "--swift-sdk", SWIFT_SDK_ID,
224
+ "-c", configuration,
225
+ "--product", exe,
226
+ "--build-system", "native",
227
+ "-debug-info-format", "none",
228
+ "-Xlinker", "--error-limit=0",
229
+ "-Xlinker", "-rpath", "-Xlinker", "@executable_path/Frameworks"]
230
+ for lib in sorted(project_libs):
231
+ cmd += ["-Xlinker", f"-l{lib}"]
232
+ for flag in cfg.extra_swiftc:
233
+ cmd += ["-Xswiftc", flag]
234
+ if jobs:
235
+ cmd += ["-j", str(jobs)]
236
+ rc, out = _stream(cmd, app_dir, meta / "build.log", timeout)
237
+ if rc == 0:
238
+ break
239
+ undefined = sorted({"_" + s if not s.startswith("_") else s for s in UNDEFINED_RE.findall(out)})
240
+ if not undefined:
241
+ raise IpeddleError(
242
+ f"swift build failed (full log: {meta / 'build.log'})",
243
+ hint=_hint_for(out),
244
+ )
245
+ added, new_libs = {}, set()
246
+ if round_no < MAX_LEARN_ROUNDS:
247
+ with locked(home.root / ".lock"):
248
+ added = patcher.learn_missing(inst.sdk, undefined)
249
+ new_libs = patcher.overlays_exporting(inst.sdk, undefined) - project_libs
250
+ if not added and not new_libs:
251
+ raise IpeddleError(
252
+ "link failed: undefined symbols\n" + _describe_symbols(inst, undefined),
253
+ hint="these symbols are not in the SDK's stubs nor in an overlay ipeddle can "
254
+ "route them to; see 'Known issues' in the README",
255
+ )
256
+ for lib, syms in added.items():
257
+ learned_all.setdefault(lib, []).extend(syms)
258
+ ui.info(f"declared {len(syms)} Swift symbol(s) in {lib} (missing from the public SDK stubs)")
259
+ if new_libs:
260
+ project_libs |= new_libs
261
+ libs_file.write_text(json.dumps(sorted(project_libs)))
262
+ ui.info("linking " + ", ".join(f"lib{n}" for n in sorted(new_libs)))
263
+ compile_seconds = time.monotonic() - compile_t0
264
+ stamp_file.write_text(sdk_stamp(inst))
265
+
266
+ ui.step("packaging")
267
+ report = package.assemble_app(
268
+ work=meta / "package", products_dir=products_dir, executable=exe, cfg=cfg,
269
+ bundle_id=bundle_id, min_os=min_os, sdk_version=inst.sdk_version, app_name=exe,
270
+ )
271
+ suffix = "" if configuration == "release" else f"-{configuration}"
272
+ ipa = Path(output).resolve() if output else (build_root / f"{exe}{suffix}.ipa")
273
+ package.write_ipa(meta / "package", ipa)
274
+ rmtree(meta / "package")
275
+ for w in report.warnings:
276
+ ui.warn(w)
277
+ if report.flattened_images:
278
+ ui.info(f"asset catalog fallback: {report.flattened_images} image file(s) bundled loose")
279
+ return BuildResult(ipa=ipa, app=report.app, seconds=time.monotonic() - t0,
280
+ compile_seconds=compile_seconds, learned=learned_all, warnings=report.warnings)
281
+
282
+
283
+ def _describe_symbols(inst: Installation, syms: list[str], limit: int = 30) -> str:
284
+ shown = syms[:limit]
285
+ demangled = shown
286
+ tool = inst.swift.tool("swift-demangle")
287
+ if tool.exists():
288
+ try:
289
+ out = subprocess.run([str(tool), "--compact"], input="\n".join(shown), text=True,
290
+ capture_output=True, timeout=30).stdout.splitlines()
291
+ if len(out) == len(shown):
292
+ demangled = out
293
+ except (OSError, subprocess.TimeoutExpired):
294
+ pass
295
+ lines = [f" {d}" + (f" [{s}]" if d != s else "") for s, d in zip(shown, demangled, strict=False)]
296
+ if len(syms) > limit:
297
+ lines.append(f" ... and {len(syms) - limit} more")
298
+ return "\n".join(lines)
299
+
300
+
301
+ def _hint_for(out: str) -> str | None:
302
+ if "no such module 'UIKit'" in out or "no such module 'SwiftUI'" in out:
303
+ return "the SDK is not set up correctly; run `ipeddle doctor`"
304
+ if "this SDK is not supported by the compiler" in out:
305
+ return ("the Swift toolchain could not read the SDK's module interfaces; try the Swift "
306
+ "version that matches the SDK (see `ipeddle doctor`)")
307
+ if "unable to resolve" in out.lower() or "could not resolve" in out.lower():
308
+ return "a package dependency could not be fetched; check your network"
309
+ return "scroll up for the first error; rerun with IPEDDLE_VERBOSE=1 for commands"
ipeddle/cli.py ADDED
@@ -0,0 +1,219 @@
1
+ """Command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from . import __version__, ui
11
+ from .errors import IpeddleError
12
+ from .paths import Home
13
+
14
+
15
+ def _home(args) -> Home:
16
+ return Home(Path(args.home).expanduser().resolve()) if args.home else Home.default()
17
+
18
+
19
+ def cmd_doctor(args) -> int:
20
+ from . import doctor
21
+
22
+ home = _home(args)
23
+ ui.step(f"ipeddle {__version__} doctor (home: {home.root})")
24
+ ok = doctor.report(doctor.run_checks(home, deep=args.deep))
25
+ if ok:
26
+ ui.ok("everything needed is in place" + ("" if args.deep else " (add --deep for a test compile)"))
27
+ else:
28
+ ui.fail("some required pieces are missing (see the hints above)")
29
+ return 0 if ok else 1
30
+
31
+
32
+ def cmd_setup(args) -> int:
33
+ from . import setup
34
+
35
+ home = _home(args)
36
+ ui.step(f"ipeddle {__version__} setup (home: {home.root})")
37
+ t = ui.Timer()
38
+ setup.setup(home, swift_dir=args.swift, swift_version=args.swift_version, linker=args.linker,
39
+ sdk_dir=args.sdk, xip=args.xip, sdk_version=args.sdk_version, mirror=args.mirror,
40
+ force=args.force, download=not args.no_download, warm=not args.no_test)
41
+ ui.ok(f"setup finished in {t.seconds:.1f}s — next: ipeddle build <your SwiftPM app>")
42
+ return 0
43
+
44
+
45
+ def cmd_build(args) -> int:
46
+ from . import build
47
+
48
+ res = build.build(Path(args.path), home=_home(args), configuration=args.configuration,
49
+ product=args.product, output=Path(args.output) if args.output else None,
50
+ clean=args.clean, timeout=args.timeout, jobs=args.jobs)
51
+ size = res.ipa.stat().st_size
52
+ ui.ok(f"{res.ipa} ({ui.human_bytes(size)}, unsigned) in {res.seconds:.1f}s "
53
+ f"(compile+link {res.compile_seconds:.1f}s)")
54
+ ui.info("sign and install it with Sideloadly or AltStore (free Apple ID)")
55
+ if args.inspect:
56
+ return _inspect(res.ipa, _home(args), None, False, False)
57
+ return 0
58
+
59
+
60
+ def _sdk_for_inspect(home: Home, sdk_arg: str | None) -> Path | None:
61
+ if sdk_arg:
62
+ return Path(sdk_arg).expanduser().resolve()
63
+ st = home.load_state()
64
+ d = st.get("sdk", {}).get("derived")
65
+ return Path(d) if d and Path(d).is_dir() else None
66
+
67
+
68
+ def _inspect(path: Path, home: Home, sdk_arg, as_json: bool, verbose: bool) -> int:
69
+ from . import inspect as insp
70
+
71
+ rep = insp.inspect(path, _sdk_for_inspect(home, sdk_arg))
72
+ if as_json:
73
+ print(json.dumps(rep.to_json(), indent=2))
74
+ else:
75
+ print(insp.format_report(rep, verbose=verbose))
76
+ return 0 if rep.ok else 2
77
+
78
+
79
+ def cmd_inspect(args) -> int:
80
+ return _inspect(Path(args.ipa), _home(args), args.sdk, args.json, args.verbose)
81
+
82
+
83
+ def cmd_init(args) -> int:
84
+ from . import scaffold
85
+ from .config import validate_bundle_id
86
+
87
+ if args.bundle_id:
88
+ validate_bundle_id(args.bundle_id)
89
+ d = scaffold.init(Path(args.path), args.name, args.bundle_id)
90
+ ui.ok(f"created a SwiftUI app in {d}")
91
+ ui.info(f"next: ipeddle build {args.path}")
92
+ return 0
93
+
94
+
95
+ def cmd_clean(args) -> int:
96
+ from .fsutil import rmtree, tree_size
97
+
98
+ home = _home(args)
99
+ if args.path:
100
+ b = Path(args.path).resolve() / ".build"
101
+ size = tree_size(b) if b.exists() else 0
102
+ rmtree(b)
103
+ ui.ok(f"removed {b} ({ui.human_bytes(size)})")
104
+ return 0
105
+ targets = [home.downloads, home.root / "tmp"]
106
+ if args.cache:
107
+ targets.append(home.module_cache)
108
+ if args.all:
109
+ targets = [home.root]
110
+ freed = 0
111
+ for t in targets:
112
+ if t.exists():
113
+ freed += tree_size(t)
114
+ rmtree(t)
115
+ for partial in home.root.rglob("*.partial") if home.root.exists() else []:
116
+ freed += tree_size(partial)
117
+ rmtree(partial)
118
+ ui.ok(f"freed {ui.human_bytes(freed)}" + (" (ipeddle home removed)" if args.all else ""))
119
+ return 0
120
+
121
+
122
+ def build_parser() -> argparse.ArgumentParser:
123
+ from . import sdk as sdkmod
124
+ from . import toolchain
125
+
126
+ p = argparse.ArgumentParser(
127
+ prog="ipeddle",
128
+ description="Build iOS apps (.ipa) on Linux, no Mac or Xcode required.",
129
+ )
130
+ p.add_argument("--version", action="version", version=f"ipeddle {__version__}")
131
+ p.add_argument("--home", help="where ipeddle keeps the toolchain and SDK "
132
+ "(default: $IPEDDLE_HOME or ~/.local/share/ipeddle)")
133
+ sub = p.add_subparsers(dest="command", metavar="<command>")
134
+
135
+ d = sub.add_parser("doctor", help="check this machine and explain what is missing")
136
+ d.add_argument("--deep", action="store_true", help="also compile and link a test program")
137
+ d.set_defaults(func=cmd_doctor)
138
+
139
+ s = sub.add_parser("setup", help="install Swift, the linker and the iOS SDK (once)")
140
+ s.add_argument("--swift", metavar="DIR", help="use this Swift toolchain (contains usr/bin/swift)")
141
+ s.add_argument("--swift-version", default=None,
142
+ help=f"Swift version to download (default: {toolchain.DEFAULT_SWIFT_VERSION})")
143
+ s.add_argument("--linker", metavar="FILE", help="use this ld64.lld instead of downloading one")
144
+ g = s.add_mutually_exclusive_group()
145
+ g.add_argument("--sdk", metavar="DIR", help="use an iPhoneOS*.sdk directory you already have")
146
+ g.add_argument("--xip", metavar="FILE", help="extract the SDK from an Xcode .xip you downloaded")
147
+ s.add_argument("--sdk-version", default=None,
148
+ help=f"SDK to fetch from the mirror (default: {sdkmod.DEFAULT_SDK_VERSION})")
149
+ s.add_argument("--mirror", default=None, help="git URL of the SDK mirror")
150
+ s.add_argument("--force", action="store_true", help="rebuild the patched SDK and runtime")
151
+ s.add_argument("--no-download", action="store_true", help="fail instead of downloading anything")
152
+ s.add_argument("--no-test", action="store_true",
153
+ help="skip the final test compile (which also pre-builds the shared module cache)")
154
+ s.set_defaults(func=cmd_setup)
155
+
156
+ n = sub.add_parser("init", help="create a minimal SwiftUI app to start from")
157
+ n.add_argument("path", help="directory to create the app in")
158
+ n.add_argument("--name", help="app/module name (default: the directory name)")
159
+ n.add_argument("--bundle-id", help="bundle identifier (default: dev.ipeddle.<name>)")
160
+ n.set_defaults(func=cmd_init)
161
+
162
+ b = sub.add_parser("build", help="build a SwiftPM app into an unsigned .ipa")
163
+ b.add_argument("path", nargs="?", default=".", help="app directory (with Package.swift)")
164
+ b.add_argument("-c", "--configuration", choices=("release", "debug"), default="release")
165
+ b.add_argument("--product", help="executable product to package")
166
+ b.add_argument("-o", "--output", help="where to write the .ipa (default: .build/<App>.ipa)")
167
+ b.add_argument("--clean", action="store_true", help="discard previous build products first")
168
+ b.add_argument("--inspect", action="store_true", help="run `ipeddle inspect` on the result")
169
+ b.add_argument("-j", "--jobs", type=int, help="parallel compile jobs")
170
+ b.add_argument("--timeout", type=int, default=3600, help="give up after this many seconds")
171
+ b.set_defaults(func=cmd_build)
172
+
173
+ i = sub.add_parser("inspect", help="check an .ipa: arch, minimum iOS, Info.plist, symbols")
174
+ i.add_argument("ipa", help=".ipa file or .app directory")
175
+ i.add_argument("--sdk", help="SDK to check symbols against (default: the one from setup)")
176
+ i.add_argument("--json", action="store_true", help="machine-readable output")
177
+ i.add_argument("-v", "--verbose", action="store_true")
178
+ i.set_defaults(func=cmd_inspect)
179
+
180
+ c = sub.add_parser("clean", help="remove temporary files (or an app's build directory)")
181
+ c.add_argument("path", nargs="?", help="app directory whose .build to delete")
182
+ c.add_argument("--cache", action="store_true", help="also empty the shared module cache")
183
+ c.add_argument("--all", action="store_true", help="remove everything ipeddle installed")
184
+ c.set_defaults(func=cmd_clean)
185
+ return p
186
+
187
+
188
+ def main(argv: list[str] | None = None) -> int:
189
+ from . import sdk as sdkmod
190
+ from . import toolchain
191
+
192
+ argp = build_parser()
193
+ args = argp.parse_args(argv)
194
+ if not getattr(args, "func", None):
195
+ argp.print_help()
196
+ return 0
197
+ if args.command == "setup":
198
+ args.swift_version = args.swift_version or toolchain.DEFAULT_SWIFT_VERSION
199
+ args.mirror = args.mirror or sdkmod.DEFAULT_MIRROR
200
+ try:
201
+ if args.command in ("setup", "doctor"):
202
+ # The Swift driver leaves empty TemporaryDirectory.* folders in $TMPDIR.
203
+ from .fsutil import private_tmp
204
+
205
+ with private_tmp(_home(args).root / "tmp"):
206
+ return args.func(args)
207
+ return args.func(args)
208
+ except IpeddleError as e:
209
+ ui.fail(e.message)
210
+ if e.hint:
211
+ ui.info("hint: " + e.hint)
212
+ return e.code
213
+ except KeyboardInterrupt:
214
+ ui.fail("interrupted")
215
+ return 130
216
+
217
+
218
+ if __name__ == "__main__": # pragma: no cover
219
+ sys.exit(main())
ipeddle/config.py ADDED
@@ -0,0 +1,95 @@
1
+ """Per-app settings: ``ipeddle.toml`` next to ``Package.swift`` (optional)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import sys
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ from .errors import IpeddleError
11
+
12
+ if sys.version_info >= (3, 11):
13
+ import tomllib
14
+ else: # pragma: no cover
15
+ import tomli as tomllib
16
+
17
+ CONFIG_NAME = "ipeddle.toml"
18
+ _BUNDLE_ID_RE = re.compile(r"^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$")
19
+
20
+
21
+ @dataclass
22
+ class AppConfig:
23
+ product: str | None = None
24
+ bundle_id: str | None = None
25
+ display_name: str | None = None
26
+ version: str = "1.0"
27
+ build: str = "1"
28
+ icon: Path | None = None
29
+ info_plist: Path | None = None
30
+ resources: list[Path] = field(default_factory=list)
31
+ device_family: list[int] = field(default_factory=lambda: [1, 2])
32
+ extra_swiftc: list[str] = field(default_factory=list)
33
+
34
+
35
+ def _path(base: Path, value, key: str) -> Path:
36
+ if not isinstance(value, str):
37
+ raise IpeddleError(f"{CONFIG_NAME}: `{key}` must be a path string")
38
+ p = (base / value).resolve()
39
+ if not p.exists():
40
+ raise IpeddleError(f"{CONFIG_NAME}: `{key}` points to {value}, which does not exist")
41
+ return p
42
+
43
+
44
+ def load(app_dir: Path) -> AppConfig:
45
+ cfg = AppConfig()
46
+ f = app_dir / CONFIG_NAME
47
+ if not f.exists():
48
+ return cfg
49
+ try:
50
+ data = tomllib.loads(f.read_text())
51
+ except tomllib.TOMLDecodeError as e:
52
+ raise IpeddleError(f"{f}: {e}") from e
53
+ app = data.get("app", {})
54
+ known = {"product", "bundle-id", "display-name", "version", "build", "icon", "info-plist",
55
+ "resources", "device-family", "swiftc-flags"}
56
+ unknown = set(app) - known
57
+ if unknown:
58
+ raise IpeddleError(
59
+ f"{f}: unknown key(s) in [app]: {', '.join(sorted(unknown))}",
60
+ hint="known keys: " + ", ".join(sorted(known)),
61
+ )
62
+ cfg.product = app.get("product")
63
+ cfg.bundle_id = app.get("bundle-id")
64
+ cfg.display_name = app.get("display-name")
65
+ cfg.version = str(app.get("version", cfg.version))
66
+ cfg.build = str(app.get("build", cfg.build))
67
+ if "icon" in app:
68
+ cfg.icon = _path(app_dir, app["icon"], "icon")
69
+ if "info-plist" in app:
70
+ cfg.info_plist = _path(app_dir, app["info-plist"], "info-plist")
71
+ cfg.resources = [_path(app_dir, r, "resources") for r in app.get("resources", [])]
72
+ fam = app.get("device-family")
73
+ if fam is not None:
74
+ names = {"iphone": 1, "ipad": 2}
75
+ try:
76
+ cfg.device_family = [names[x] if isinstance(x, str) else int(x) for x in fam]
77
+ except (KeyError, ValueError) as e:
78
+ raise IpeddleError(f"{f}: device-family must list \"iphone\" and/or \"ipad\"") from e
79
+ cfg.extra_swiftc = [str(x) for x in app.get("swiftc-flags", [])]
80
+ if cfg.bundle_id:
81
+ validate_bundle_id(cfg.bundle_id)
82
+ return cfg
83
+
84
+
85
+ def validate_bundle_id(bid: str) -> None:
86
+ if not _BUNDLE_ID_RE.match(bid):
87
+ raise IpeddleError(
88
+ f"invalid bundle identifier {bid!r}",
89
+ hint="use reverse-DNS letters, digits, '-' and '.', e.g. com.example.myapp",
90
+ )
91
+
92
+
93
+ def default_bundle_id(product: str) -> str:
94
+ slug = re.sub(r"[^A-Za-z0-9-]", "-", product).strip("-").lower() or "app"
95
+ return f"dev.ipeddle.{slug}"