commit f70783dcc076f8785c4a5e5e9ef36b48b498e790 Author: lark Date: Wed May 27 23:33:08 2026 -0700 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad4b117 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +Fonts/ \ No newline at end of file diff --git a/iGlyph.py b/iGlyph.py new file mode 100644 index 0000000..03817df --- /dev/null +++ b/iGlyph.py @@ -0,0 +1,638 @@ +#!/usr/bin/env python3 +""" +iglyph - bundle .ttf/.otf files into a single iOS/iPadOS .mobileconfig + +The Apple .mobileconfig spec has long supported multi-font payloads in +a single profile. iGlyph just... does that. Pure stdlib. The App Store +options (iFont, Fontastic) bundle too, but charge for it. + +Usage: + python3 iglyph.py ./my-fonts/ + python3 iglyph.py ./fonts/ -o brand.mobileconfig --name "lark.cx Fonts" + python3 iglyph.py ./fonts/ --list + python3 iglyph.py ./fonts/ --serve # default port 18888 + python3 iglyph.py ./fonts/ --serve 8080 --timeout 30 +""" + +from __future__ import annotations + +import argparse +import hashlib +import http.server +import plistlib +import re +import socket +import socketserver +import struct +import sys +import threading +import time +import uuid +from pathlib import Path + +# ======================================================================= +# Font validation and PostScript-name extraction (stdlib only) +# ======================================================================= + +# Magic bytes at offset 0 for supported font formats +_TTF_MAGIC = b"\x00\x01\x00\x00" +_OTF_MAGIC = b"OTTO" +_TRUE_MAGIC = b"true" # legacy Apple TTF +_TTC_MAGIC = b"ttcf" # font collection container (covers .ttc and .otc) +_WOFF_MAGIC = b"wOFF" +_WOFF2_MAGIC = b"wOF2" + +_VALID_MAGICS = {_TTF_MAGIC, _OTF_MAGIC, _TRUE_MAGIC} + + +class FontError(ValueError): + """Raised when a font file is invalid or unsupported""" + + +def validate_font_magic(path: Path) -> None: + """Check the first 4 bytes look like a TTF or OTF + + Raises FontError with a helpful message for known-bad formats + """ + if path.stat().st_size < 12: + raise FontError(f"{path}: file too small to be a valid font") + + head = path.read_bytes()[:4] + + if head in _VALID_MAGICS: + return + if head == _TTC_MAGIC: + raise FontError( + f"{path}: this is a font collection (.ttc/.otc). " + f"iOS doesn't support these; split it into individual .ttf/.otf " + f"files with fontTools before bundling." + ) + if head == _WOFF_MAGIC or head == _WOFF2_MAGIC: + raise FontError( + f"{path}: this is a web font (WOFF/WOFF2), not a TTF/OTF. " + f"iOS won't install it. Convert it to TTF/OTF first." + ) + raise FontError( + f"{path}: doesn't look like a valid font " + f"(magic bytes: {head!r}, expected TTF or OTF)" + ) + + +def extract_postscript_name(path: Path) -> str | None: + """Parse the `name` table from a TTF/OTF and return the PostScript name + + Returns None if the name table can't be parsed or nameID 6 isn't present. + Pure stdlib - no fontTools dependency. + See: learn.microsoft.com/en-us/typography/opentype/spec/name + """ + try: + data = path.read_bytes() + + # sfnt header: u32 version, u16 numTables, u16 searchRange, + # u16 entrySelector, u16 rangeShift + if len(data) < 12: + return None + num_tables = struct.unpack(">H", data[4:6])[0] + + # Table directory: 16 bytes per entry - tag (4), checksum (4), + # offset (4), length (4) + name_offset = None + name_length = None + for i in range(num_tables): + entry_start = 12 + i * 16 + if entry_start + 16 > len(data): + return None + tag = data[entry_start : entry_start + 4] + if tag == b"name": + name_offset = struct.unpack( + ">I", data[entry_start + 8 : entry_start + 12] + )[0] + name_length = struct.unpack( + ">I", data[entry_start + 12 : entry_start + 16] + )[0] + break + + if name_offset is None or name_length is None: + return None + if name_offset + name_length > len(data): + return None + + # name table header: u16 format, u16 count, u16 stringOffset + if name_length < 6: + return None + fmt, count, string_offset = struct.unpack( + ">HHH", data[name_offset : name_offset + 6] + ) + + # Each name record is 12 bytes: + # u16 platformID, u16 encodingID, u16 languageID, u16 nameID, + # u16 length, u16 stringOffset (from start of storage area) + record_size = 12 + records_start = name_offset + 6 + storage_start = name_offset + string_offset + + # Prefer Windows Unicode (3, 1), fall back to Macintosh Roman (1, 0) + candidates: list[ + tuple[int, int, int, str] + ] = [] # (priority, plat, enc, decoded) + for i in range(count): + rec_start = records_start + i * record_size + if rec_start + record_size > len(data): + break + platform_id, encoding_id, _lang_id, name_id, length, offset = struct.unpack( + ">HHHHHH", data[rec_start : rec_start + record_size] + ) + if name_id != 6: # 6 = PostScript name + continue + # Priority: Windows Unicode = 0 (best), Mac Roman = 1, others = 2 + if platform_id == 3 and encoding_id == 1: + priority = 0 + elif platform_id == 1 and encoding_id == 0: + priority = 1 + else: + priority = 2 + str_start = storage_start + offset + str_end = str_start + length + if str_end > len(data): + continue + raw = data[str_start:str_end] + try: + if platform_id == 3: # Windows: UTF-16BE + decoded = raw.decode("utf-16-be") + elif platform_id == 1: # Mac: MacRoman + decoded = raw.decode("mac-roman") + else: + decoded = raw.decode("utf-8", errors="replace") + candidates.append((priority, platform_id, encoding_id, decoded)) + except (UnicodeDecodeError, LookupError): + continue + + if not candidates: + return None + candidates.sort(key=lambda c: c[0]) + return candidates[0][3] + except (OSError, struct.error): + return None + + +# ======================================================================= +# Profile generation +# ======================================================================= + + +def discover_fonts(inputs: list[Path]) -> list[Path]: + """Recursively find .ttf and .otf files in the given inputs. + + Case-insensitive extension matching. Resolved paths used for dedup but + user-visible paths preserved in the return (so error messages match + what the user typed). + """ + found: list[Path] = [] + for entry in inputs: + if not entry.exists(): + raise FontError(f"input not found: {entry}") + if entry.is_file(): + if entry.suffix.lower() in {".ttf", ".otf"}: + found.append(entry) + else: + raise FontError( + f"{entry}: unsupported extension (only .ttf and .otf are accepted)" + ) + elif entry.is_dir(): + for f in entry.rglob("*"): + if f.is_file() and f.suffix.lower() in {".ttf", ".otf"}: + found.append(f) + # Deduplicate by resolved path while preserving user-visible paths + seen: set[Path] = set() + unique: list[Path] = [] + for f in found: + resolved = f.resolve() + if resolved not in seen: + seen.add(resolved) + unique.append(f) + return unique + + +def _slugify(text: str) -> str: + """Lowercase, hyphenate, keep only [a-z0-9-]. For identifier derivation.""" + s = re.sub(r"[^a-zA-Z0-9]+", "-", text.lower()).strip("-") + return s or "bundle" + + +def _build_description( + annotated: list[tuple[Path, str | None]], + max_listed: int = 40, +) -> str: + """Build a description listing fonts + + Shows the PS name where available. Falls back to the filename stem. + Truncates with a "+N more" tail if the list exceeds max_listed + """ + count = len(annotated) + names: list[str] = [] + for path, ps in annotated: + names.append(ps if ps else path.stem) + if count <= max_listed: + listed = ", ".join(names) + else: + head = ", ".join(names[:max_listed]) + listed = f"{head}, +{count - max_listed} more" + return f"Installs {count} font(s): {listed}" + + +def build_font_payload(font_path: Path, profile_uuid: str) -> dict: + """Build a single com.apple.font payload dict for one font file""" + payload_uuid = str(uuid.uuid4()).upper() + return { + "PayloadType": "com.apple.font", + "PayloadVersion": 1, + "PayloadIdentifier": f"{profile_uuid}.font.{payload_uuid}", + "PayloadUUID": payload_uuid, + "PayloadDisplayName": font_path.stem, + "PayloadDescription": f"Installs {font_path.name}", + "Name": font_path.name, + "Font": font_path.read_bytes(), + } + + +def build_profile( + annotated: list[tuple[Path, str | None]], + name: str, + identifier: str, + organization: str, +) -> bytes: + """Build a complete .mobileconfig (XML plist) as bytes""" + fonts = [f for f, _ in annotated] + profile_uuid = str(uuid.uuid4()).upper() + payloads = [build_font_payload(f, profile_uuid) for f in fonts] + description = _build_description(annotated) + profile = { + "PayloadType": "Configuration", + "PayloadVersion": 1, + "PayloadIdentifier": identifier, + "PayloadUUID": profile_uuid, + "PayloadDisplayName": name, + "PayloadDescription": description, + "PayloadOrganization": organization, + "PayloadScope": "User", + "PayloadRemovalDisallowed": False, + "PayloadContent": payloads, + } + return plistlib.dumps(profile, fmt=plistlib.FMT_XML, sort_keys=False) + + +def _file_sha256(path: Path) -> str: + """Compute SHA-256 of a file's contents, chunked to handle large fonts.""" + h = hashlib.sha256() + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def derive_identifier(name: str, annotated: list[tuple[Path, str | None]]) -> str: + """Derive a unique reverse-DNS identifier from the profile name + content. + + Hashes actual font bytes so that replacing a file with a different one + that has the same PS name produces a new identifier (and thus coexists + on the device rather than silently replacing). Sorted by PS name (or + filename fallback) so identifier is independent of input ordering. + """ + slug = _slugify(name) + parts = [ + f"{ps or path.name}:{_file_sha256(path)}" + for path, ps in sorted(annotated, key=lambda x: (x[1] or x[0].name).lower()) + ] + content_hash = hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()[:8] + return f"cx.lark.iglyph.{slug}.{content_hash}" + + +# ======================================================================= +# Validation pipeline (magic bytes + PS-name collision detection) +# ======================================================================= + + +def validate_and_report( + fonts: list[Path], +) -> tuple[list[Path], list[tuple[Path, str | None]]]: + """Validate all fonts, warn on PS-name collisions, return validated list. + + Returns (valid_fonts, [(path, ps_name), ...]). + Raises FontError on hard validation failures (bad magic bytes, missing + files). PS-name collisions are warned to stderr but not blocking; + iOS silently drops duplicates. User can proceed if they want. + """ + if not fonts: + raise FontError("no .ttf or .otf files found in the input") + + annotated: list[tuple[Path, str | None]] = [] + for f in fonts: + validate_font_magic(f) + ps_name = extract_postscript_name(f) + annotated.append((f, ps_name)) + + # Detect PS-name collisions and warn (non-fatal) + by_ps: dict[str, list[Path]] = {} + for path, ps in annotated: + if ps is None: + continue + by_ps.setdefault(ps, []).append(path) + collisions = {ps: paths for ps, paths in by_ps.items() if len(paths) > 1} + if collisions: + lines = [ + "warning: PostScript-name collisions detected (iOS will silently keep one of each):" + ] + for ps, paths in collisions.items(): + lines.append(f" {ps!r}:") + for p in paths: + lines.append(f" - {p}") + lines.append( + " proceeding anyway - remove duplicates if you want deterministic behavior" + ) + print("\n".join(lines), file=sys.stderr) + + return [f for f, _ in annotated], annotated + + +# ======================================================================= +# Network delivery +# ======================================================================= + + +def get_local_ips() -> list[str]: + """Enumerate all non-loopback IPv4 addresses for this host.""" + ips: list[str] = [] + try: + hostname = socket.gethostname() + for info in socket.getaddrinfo(hostname, None, socket.AF_INET): + ip = info[4][0] + if not ip.startswith("127.") and ip not in ips: + ips.append(ip) + except socket.gaierror: + pass + + # Fallback: connect to a public IP and read back our local address + if not ips: + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + if not ip.startswith("127."): + ips.append(ip) + except OSError: + pass + + return ips or ["127.0.0.1"] + + +class MobileConfigHandler(http.server.BaseHTTPRequestHandler): + """Serves a single .mobileconfig file with the correct MIME type. + + Responds to any GET (or HEAD). Path doesn't matter. The Content- + Disposition header carries the correct filename. User + can hit just `http://192.168.1.47:18888/` from their phone without + typing 'iGlyph.mobileconfig' on a touchscreen + """ + + profile_bytes: bytes = b"" + profile_name: str = "iGlyph.mobileconfig" + on_download: "threading.Event | None" = None + + def _send_profile_headers(self) -> None: + self.send_response(200) + self.send_header("Content-Type", "application/x-apple-aspen-config") + self.send_header("Content-Length", str(len(self.profile_bytes))) + self.send_header( + "Content-Disposition", + f'attachment; filename="{self.profile_name}"', + ) + self.end_headers() + + def do_HEAD(self) -> None: # noqa: N802 + self._send_profile_headers() + + def do_GET(self) -> None: # noqa: N802 + self._send_profile_headers() + self.wfile.write(self.profile_bytes) + if self.on_download is not None: + self.on_download.set() + + def log_message(self, fmt: str, *args: object) -> None: + ts = time.strftime("%H:%M:%S") + sys.stderr.write(f"[{ts}] {self.address_string()} {fmt % args}\n") + + +class ReusableThreadingTCPServer(socketserver.ThreadingTCPServer): + """ThreadingTCPServer with SO_REUSEADDR so re-runs after Ctrl-C don't + hit 'address already in use' during the TIME_WAIT window.""" + + allow_reuse_address = True + + +def serve_profile( + profile_bytes: bytes, + profile_name: str, + port: int, + timeout_minutes: int, +) -> None: + """Spin up an HTTP server, print URLs, exit on download or timeout""" + MobileConfigHandler.profile_bytes = profile_bytes + MobileConfigHandler.profile_name = profile_name + download_event = threading.Event() + MobileConfigHandler.on_download = download_event + + # ThreadingTCPServer so the response actually completes before shutdown + server = ReusableThreadingTCPServer(("0.0.0.0", port), MobileConfigHandler) + server.daemon_threads = True + actual_port = server.server_address[1] + + ips = get_local_ips() + + print(f"\nserving {profile_name} ({len(profile_bytes):,} bytes) on all interfaces") + print("open one of these on your iPhone/iPad (Safari):") + for ip in ips: + print(f" http://{ip}:{actual_port}/") + print( + f"\nwaiting for download " + f"(timeout: {timeout_minutes}m, exits on first download, Ctrl-C to cancel)" + ) + + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + + try: + triggered = download_event.wait(timeout=timeout_minutes * 60) + if triggered: + time.sleep(0.5) # let the response finish writing + print("download served - exiting cleanly") + else: + print(f"\ntimeout reached ({timeout_minutes}m), no download - exiting") + sys.exit(1) + except KeyboardInterrupt: + print("\ncancelled by user") + sys.exit(130) + finally: + server.shutdown() + server.server_close() + + +# ======================================================================= +# CLI +# ======================================================================= + + +def format_listing(annotated: list[tuple[Path, str | None]]) -> str: + """Pretty-print the font listing for --list / --dry-run""" + lines = [f"would bundle {len(annotated)} font(s):"] + total_bytes = 0 + for path, ps in annotated: + size = path.stat().st_size + total_bytes += size + ps_display = ps if ps else "" + lines.append(f" {path.name:40s} {size:>8,} B PS: {ps_display}") + lines.append(f"total payload: {total_bytes:,} bytes") + return "\n".join(lines) + + +def _port_number(value: str) -> int: + """argparse type: accept 0-65535, reject everything else.""" + try: + port = int(value) + except ValueError: + raise argparse.ArgumentTypeError(f"port must be an integer, got {value!r}") + if not 0 <= port <= 65535: + raise argparse.ArgumentTypeError(f"port must be 0-65535, got {port}") + return port + + +def _positive_int(value: str) -> int: + """argparse type: accept positive integers only.""" + try: + n = int(value) + except ValueError: + raise argparse.ArgumentTypeError(f"must be a positive integer, got {value!r}") + if n <= 0: + raise argparse.ArgumentTypeError(f"must be > 0, got {n}") + return n + + +def main() -> int: + p = argparse.ArgumentParser( + prog="iglyph", + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p.add_argument( + "inputs", + nargs="+", + type=Path, + metavar="INPUT", + help="font files or directories containing .ttf/.otf files", + ) + p.add_argument( + "-o", + "--output", + type=Path, + default=Path("iGlyph.mobileconfig"), + help="output .mobileconfig path (default: iGlyph.mobileconfig)", + ) + p.add_argument( + "-l", + "--list", + "-d", + "--dry-run", + dest="list_only", + action="store_true", + help="list what would be bundled without writing anything", + ) + p.add_argument( + "--name", + default="iGlyph Fonts", + help="profile display name (default: 'iGlyph Fonts')", + ) + p.add_argument( + "--identifier", + default=None, + help=( + "reverse-DNS profile identifier (default: auto-derived from " + "--name + content hash, so distinct font bundles coexist on device)" + ), + ) + p.add_argument( + "--organization", + default="iGlyph", + help="organization name shown on install (default: 'iGlyph')", + ) + p.add_argument( + "--serve", + nargs="?", + const=18888, + default=None, + type=_port_number, + metavar="PORT", + help="serve the profile over HTTP (default port 18888)", + ) + p.add_argument( + "--timeout", + type=_positive_int, + default=10, + metavar="MINUTES", + help="serve timeout in minutes (default: 10)", + ) + + args = p.parse_args() + + # Discover and validate + try: + fonts = discover_fonts(args.inputs) + valid_fonts, annotated = validate_and_report(fonts) + except FontError as e: + print(f"error: {e}", file=sys.stderr) + return 1 + + # List mode exits here + if args.list_only: + print(format_listing(annotated)) + return 0 + + # Build profile + identifier = args.identifier or derive_identifier(args.name, annotated) + blob = build_profile( + annotated, + name=args.name, + identifier=identifier, + organization=args.organization, + ) + + # Write output, with cleaner errors than a raw OSError traceback + try: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_bytes(blob) + except OSError as e: + print(f"error: could not write {args.output}: {e}", file=sys.stderr) + return 1 + print(f"wrote {args.output} ({len(blob):,} bytes, {len(valid_fonts)} font(s))") + + # Serve mode + if args.serve is not None: + serve_profile( + profile_bytes=blob, + profile_name=args.output.name, + port=args.serve, + timeout_minutes=args.timeout, + ) + return 0 + + # Otherwise, print install hints + print("\nfastest path:") + print(f" python3 iglyph.py {' '.join(str(i) for i in args.inputs)} --serve") + print("\nmanual path:") + print(f" 1. AirDrop {args.output} to the device") + print(" 2. Open it -> a Settings prompt appears") + print(" 3. Settings -> General -> VPN & Device Management -> install") + print(" 4. fonts appear in Settings -> General -> Fonts and in font pickers") + return 0 + + +if __name__ == "__main__": + sys.exit(main())