Files
ios-location-track/tools/fetch_ddi.py
T
jbjf6666 85e9a53750 docs: 新增 Windows 移植交接文档
docs/WINDOWS_PORT.md 覆盖:
- 逐文件可移植性审计(核心逻辑层约 90% 可直接复用)
- 核心难点:iOS 17+ 的 RSD 隧道无法在 Windows 复用(macOS 实现依赖 XPC +
  ObjC runtime 私有框架),改用 pymobiledevice3 的 userspace 隧道
- 已实测确认的命令与 API(--userspace 挂在 lockdown start-tunnel / mounter
  auto-mount 上,remote start-tunnel 不支持;类名是 UserspaceRsdTunnel)
- 窗口启动、数据目录、打包与分发(DMG → Inno Setup)、图标(.icns → .ico)
- macOS 侧踩过的坑:镜像挂载用 Python API、必须打包包元数据、不要把 distutils
  放进 excludes、AlreadyMountedError 当成功处理
- 设计约束(DVT 长连接、只能改经纬度、last_point 与 last_reported 分离等)
- 验收基线(复用现有测试套件)与建议的工作顺序
2026-10-16 01:05:09 +08:00

246 lines
9.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""批量抓取各 iOS 版本的开发者镜像,供打包进应用(离线可用)。
镜像来源:doronz88/DeveloperDiskImage(pymobiledevice3 官方使用的仓库)
两类镜像:
1. **经典镜像**(iOS 16 及更早):每个系统版本一套
`DeveloperDiskImage.dmg` + `DeveloperDiskImage.dmg.signature`
2. **个性化镜像**(iOS 17 及以后,含 27.x):**全系共用一套**
`Image.dmg` + `BuildManifest.plist` + `Image.dmg.trustcache`
实际签名在挂载时由 Apple TSS 按设备在线生成,因此不需要逐版本素材。
注意:raw.githubusercontent.com 在国内会被 SNI 阻断(4 个 IP 里只有部分可用),
所以这里逐个 IP 直连重试,并支持 jsDelivr 镜像兜底。
用法:
./.venv/bin/python tools/fetch_ddi.py # 下载全部(16.0-16.7 + 个性化)
./.venv/bin/python tools/fetch_ddi.py --only 16.5 # 只下一个版本
./.venv/bin/python tools/fetch_ddi.py --check # 只检查已有文件完整性
"""
from __future__ import annotations
import argparse
import http.client
import json
import shutil
import ssl
import sys
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "resources" / "ddi"
REPO = "doronz88/DeveloperDiskImage"
BRANCH = "main"
# 实测:.111 / .108 可达,.109 / .110 是黑洞(先试可达的)
RAW_IPS = ["185.199.111.133", "185.199.108.133", "185.199.109.133", "185.199.110.133"]
# 经典镜像:iOS 16 全系(仓库里 16.x 到此为止)
CLASSIC_VERSIONS = ["16.0", "16.1", "16.2", "16.3", "16.4", "16.5", "16.6", "16.7"]
CLASSIC_FILES = ["DeveloperDiskImage.dmg", "DeveloperDiskImage.dmg.signature"]
# 个性化镜像:iOS 17+ 全系共用
PERSONALIZED_DIR = "PersonalizedImages/Xcode_iOS_DDI_Personalized"
PERSONALIZED_FILES = ["Image.dmg", "BuildManifest.plist", "Image.dmg.trustcache"]
PERSONALIZED_OUT = "personalized"
# 各文件的最小合理体积(用于判断下载是否完整)
MIN_SIZE = {
"DeveloperDiskImage.dmg": 5 * 1024 * 1024,
"DeveloperDiskImage.dmg.signature": 64,
"Image.dmg": 10 * 1024 * 1024,
"BuildManifest.plist": 100 * 1024,
"Image.dmg.trustcache": 256,
}
CONNECT_TIMEOUT = 6.0 # TCP 建连超时:坏 IP 是黑洞,必须快速放弃
READ_TIMEOUT = 300.0 # 读数据超时:大文件(15MB)要留足时间
def _pipe(sock, host: str, path: str, timeout: float) -> bytes | None:
"""在已建立的 TCP 连接上完成 TLS + HTTP GET。"""
context = ssl.create_default_context()
tls = context.wrap_socket(sock, server_hostname=host)
try:
tls.settimeout(timeout)
request = (
f"GET /{REPO}/{BRANCH}/{path} HTTP/1.1\r\n"
f"Host: {host}\r\n"
"User-Agent: Mozilla/5.0\r\n"
"Accept: */*\r\n"
"Connection: close\r\n\r\n"
)
tls.sendall(request.encode())
chunks: list[bytes] = []
while True:
chunk = tls.recv(262144)
if not chunk:
break
chunks.append(chunk)
raw = b"".join(chunks)
finally:
try:
tls.close()
except Exception:
pass
if not raw:
return None
head, _, body = raw.partition(b"\r\n\r\n")
if b"200" not in head.split(b"\r\n")[0]:
return None
return body
def fetch_via_ip(path: str, timeout: float = READ_TIMEOUT) -> bytes | None:
"""直连 GitHub Pages IP 拉取(绕过被阻断的 DNS 轮询)。
关键:**先做一次短超时的 TCP 建连测试**。实测坏的 IP 是黑洞,
HTTPSConnection 的 timeout 管不住 TCP 连接阶段,会挂 75 秒以上,
导致整个下载流程看起来"卡死"。
"""
import socket
for ip in RAW_IPS:
try:
sock = socket.create_connection((ip, 443), timeout=CONNECT_TIMEOUT)
except Exception:
print(f" ({ip} 连接超时,跳过)")
continue
try:
data = _pipe(sock, "raw.githubusercontent.com", path, timeout)
if data:
return data
except Exception as exc:
print(f" ({ip} 传输失败:{exc.__class__.__name__})")
continue
finally:
try:
sock.close()
except Exception:
pass
return None
def fetch_via_jsdelivr(path: str, timeout: float = 180.0) -> bytes | None:
"""jsDelivr CDN 兜底(国内通常可达)。"""
url = f"https://cdn.jsdelivr.net/gh/{REPO}@{BRANCH}/{path}"
try:
request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(request, timeout=timeout) as response:
if response.status != 200:
return None
return response.read()
except Exception:
return None
def download(rel_path: str, dest: Path, force: bool = False) -> bool:
name = dest.name
minimum = MIN_SIZE.get(name, 100)
if dest.exists() and dest.stat().st_size >= minimum and not force:
print(f" 已有({dest.stat().st_size / 1048576:.2f} MB),跳过")
return True
dest.parent.mkdir(parents=True, exist_ok=True)
data = fetch_via_ip(rel_path)
source = "GitHub IP"
if not data:
print(" GitHub 直连失败,改用 jsDelivr…")
data = fetch_via_jsdelivr(rel_path)
source = "jsDelivr"
if not data or len(data) < minimum:
print(f" ❌ 下载失败({len(data) if data else 0} 字节,期望 ≥ {minimum})")
return False
dest.write_bytes(data)
print(f" ✅ {len(data) / 1048576:.2f} MB({source})")
return True
def verify() -> int:
"""检查镜像完整性。
**必备**:个性化镜像(iOS 17+ 全系,含 27.x)。
**可选**:iOS 16.x 经典镜像 —— Apple 按系统构建号签发,社区镜像在多数新构建上
会被设备拒签(实测 16.5/20F66 报 Failed to verify the disk image signature),
因此不作为必备项,缺失只提示。
"""
print("\033[1m检查镜像完整性\033[0m")
required_total = 0
required_missing: list[str] = []
print(" 【必备】个性化镜像(iOS 17 及以上全系共用)")
for name in PERSONALIZED_FILES:
path = OUT / PERSONALIZED_OUT / name
ok = path.exists() and path.stat().st_size >= MIN_SIZE[name]
if ok:
required_total += path.stat().st_size
else:
required_missing.append(f"{PERSONALIZED_OUT}/{name}")
size = f"{path.stat().st_size / 1048576:.2f} MB" if path.exists() else "缺失"
print(f" {'✅' if ok else '❌'} {name:35s} {size}")
optional_total = 0
optional_found: list[str] = []
print(" 【可选】iOS 16.x 经典镜像(构建号匹配时才有用)")
for version in CLASSIC_VERSIONS:
directory = OUT / version
have = all(
(directory / name).exists() and (directory / name).stat().st_size >= MIN_SIZE[name]
for name in CLASSIC_FILES
)
if have:
optional_found.append(version)
optional_total += sum((directory / name).stat().st_size for name in CLASSIC_FILES)
print(f" {'✅' if have else '·'} {version}")
print(f"\n 必备素材合计 {required_total / 1048576:.1f} MB"
f"|可选经典镜像 {len(optional_found)}/{len(CLASSIC_VERSIONS)} 个版本、{optional_total / 1048576:.1f} MB")
if required_missing:
print(f" \033[31m必备素材缺失 {len(required_missing)} 个:{', '.join(required_missing)}\033[0m")
return 1
print(" \033[32m必备素材齐全(iOS 17 及以上可离线挂载)\033[0m")
return 0
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--only", default=None, help="只下载指定版本(如 16.5)")
parser.add_argument("--check", action="store_true", help="只检查完整性")
parser.add_argument("--force", action="store_true", help="强制重新下载")
parser.add_argument("--personalized-only", action="store_true", help="只下载 iOS 17+ 共用镜像")
parser.add_argument("--classic", action="store_true",
help="额外下载 iOS 16.x 经典镜像(注意:Apple 已按构建号签发,多数新构建会被拒签)")
args = parser.parse_args()
if args.check:
return verify()
print("\033[1m下载开发者镜像到应用内(供离线使用)\033[0m")
print(f" 输出目录:{OUT}\n")
ok_all = True
if not args.personalized_only and (args.only or args.classic):
versions = [args.only] if args.only else CLASSIC_VERSIONS
for version in versions:
print(f"[经典镜像] iOS {version}")
for name in CLASSIC_FILES:
rel = f"DeveloperDiskImages/{version}/{name}"
if not download(rel, OUT / version / name, args.force):
ok_all = False
if not args.only:
print("[个性化镜像] iOS 17+ 全系共用(含 27.x)")
for name in PERSONALIZED_FILES:
rel = f"{PERSONALIZED_DIR}/{name}"
if not download(rel, OUT / PERSONALIZED_OUT / name, args.force):
ok_all = False
print()
verify()
return 0 if ok_all else 1
if __name__ == "__main__":
raise SystemExit(main())