639 lines
21 KiB
Python
639 lines
21 KiB
Python
#!/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 "<no PS name found>"
|
|
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())
|