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 分离等)
- 验收基线(复用现有测试套件)与建议的工作顺序
This commit is contained in:
jbjf6666 committed 2026-10-16 01:05:09 +08:00
commit 85e9a53750
65 files changed
+18603

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
#!/bin/bash
# 离线抓取 DeveloperDiskImage 素材(绕过 raw.githubusercontent.com 的 SNI 阻断)
#
# 背景(2026-10-14 实测,本机 jbjf-Mac-mini):
# raw.githubusercontent.com 解析到 4 个 GitHub Pages IP,其中 3 个在 TLS ClientHello 后被 RST / 黑洞:
# 185.199.108.133 -> 连接超时(curl exit 28)
# 185.199.109.133 -> RST(curl exit 35)
# 185.199.110.133 -> RST(curl exit 35)
# 185.199.111.133 -> HTTP 200 正常
# DNS 轮询会随机选中坏 IP,而 developer_disk_image/repo.py 的 requests.get 没有重试也没有超时,
# 所以单次 RST 就会让 mounter auto-mount 整体失败(ConnectionResetError(54))。
#
# 本脚本:固定走可用 IP + 重试 + 校验字节数,把素材放到 pymobiledevice3 期望的位置。
# 用法:tools/ddi_offline_fetch.sh [输出目录] 默认输出 /tmp/ddi
set -uo pipefail
OUT="${1:-/tmp/ddi}"
GOOD_IPS=("185.199.111.133" "185.199.108.133" "185.199.109.133" "185.199.110.133")
BASE="https://raw.githubusercontent.com/doronz88/DeveloperDiskImage/main"
CACHE="$HOME/.pymobiledevice3/Xcode_iOS_DDI_Personalized"
PY="$(cd "$(dirname "$0")/.." && pwd)/.venv/bin/python"
mkdir -p "$OUT/16.5" "$CACHE"
# fetch <仓库相对路径> <期望字节数> <输出文件>
fetch() {
local path="$1" want="$2" dest="$3"
for ip in "${GOOD_IPS[@]}"; do
echo "--- $path (期望 $want 字节, 尝试 $ip)"
if curl -sS -m 300 --retry 3 --retry-all-errors --resolve "raw.githubusercontent.com:443:$ip" \
-o "$dest.part" -w " http=%{http_code} size=%{size_download} t=%{time_total}s\n" "$BASE/$path"; then
local got; got=$(stat -f%z "$dest.part" 2>/dev/null || echo 0)
if [ "$got" = "$want" ]; then mv "$dest.part" "$dest"; echo " OK ($ip)"; return 0; fi
echo " 字节数不符 ($got != $want),换 IP"
fi
done
echo "!!! $path 全部 IP 失败"; return 1
}
# iOS 17+(personalized)素材 —— 例如 iPhone 11 / iOS 26.6.1
fetch "PersonalizedImages/Xcode_iOS_DDI_Personalized/Image.dmg" 15733248 "$OUT/Image.dmg"
fetch "PersonalizedImages/Xcode_iOS_DDI_Personalized/BuildManifest.plist" 801505 "$OUT/BuildManifest.plist"
fetch "PersonalizedImages/Xcode_iOS_DDI_Personalized/Image.dmg.trustcache" 1895 "$OUT/Image.dmg.trustcache"
# iOS <17(经典 developer image)素材 —— 例如 iOS 16.5
fetch "DeveloperDiskImages/16.5/DeveloperDiskImage.dmg" 7427565 "$OUT/16.5/DeveloperDiskImage.dmg"
fetch "DeveloperDiskImages/16.5/DeveloperDiskImage.dmg.signature" 128 "$OUT/16.5/DeveloperDiskImage.dmg.signature"
# 写进 pymobiledevice3 缓存:fetch_personalized_ddi() 会直接命中,彻底不再联网
# 注意缓存里的 trustcache 文件名必须是 Image.trustcache(不是 Image.dmg.trustcache)
cp "$OUT/BuildManifest.plist" "$CACHE/BuildManifest.plist"
cp "$OUT/Image.dmg" "$CACHE/Image.dmg"
cp "$OUT/Image.dmg.trustcache" "$CACHE/Image.trustcache"
echo
echo "=== 校验缓存(ProductBuildVersion 必须等于 pymobiledevice3 的 LATEST_DDI_BUILD_ID)==="
"$PY" - <<'PY'
import plistlib, pathlib
from pymobiledevice3.services.mobile_image_mounter import LATEST_DDI_BUILD_ID, fetch_personalized_ddi
c = pathlib.Path.home()/".pymobiledevice3"/"Xcode_iOS_DDI_Personalized"
bm = plistlib.loads((c/"BuildManifest.plist").read_bytes())
print("缓存 BuildVersion :", bm["ProductBuildVersion"])
print("期望 LATEST_DDI :", LATEST_DDI_BUILD_ID)
print("缓存有效(不会重下):", bm["ProductBuildVersion"] == LATEST_DDI_BUILD_ID)
PY
echo
echo "=== 接下来可以直接离线挂载(不再触碰 GitHub)==="
echo " # iOS 17+ 设备(personalized,走缓存)"
echo " ./.venv/bin/python -m pymobiledevice3 mounter auto-mount --udid <UDID>"
echo " # iOS <17 设备(经典镜像,显式指定本地文件)"
echo " ./.venv/bin/python -m pymobiledevice3 mounter mount-developer \\"
echo " $OUT/16.5/DeveloperDiskImage.dmg $OUT/16.5/DeveloperDiskImage.dmg.signature --udid <UDID>"
+231
View File
@@ -0,0 +1,231 @@
#!/usr/bin/env python3
"""校园跑类 App 定位诊断:检查我们下发给手机的定位点是否"对 App 有效"。
运动世界校园这类 App 不显示里程,常见原因不在"定位有没有送到",而在
"送到的点是否满足 App 的过滤条件"。本脚本按跑步 App 的典型过滤规则体检:
1. **速度是否合理**:配速过高(如用了驾车/飞行模式)会被判异常而不计里程;
2. **每帧位移是否够大**:位移太小会被"静止过滤"掉;
3. **下发频率**:运动类 App 通常按 1Hz 采样算里程,低于 1Hz 会丢点;
4. **坐标精度**:6 位小数(约 0.1 米)足够;
5. **轨迹是否在指定区域内**(校园跑要求在校内有效区域,这一点需要你提供学校坐标)。
用法:
./.venv/bin/python tools/diagnose_run.py # 只做参数体检
./.venv/bin/python tools/diagnose_run.py --live 20 # 连真机跑 20 秒并统计真实下发
./.venv/bin/python tools/diagnose_run.py --live 20 --lat 30.2741 --lng 120.1551
"""
from __future__ import annotations
import argparse
import statistics
import sys
import threading
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "tests"))
from core import geoutils, server as server_mod # noqa: E402
# 跑步 App 的常见阈值(不同学校/版本略有差异,这里取保守值)
APP_MIN_SPEED = 0.4 # m/s,低于此速度通常判为"站着不动"
APP_MAX_SPEED_WALK = 4.0 # m/s,约 14.4 km/h;超过常被判"非跑步/作弊"
APP_MIN_PER_FRAME = 0.05 # 米,位移小于此值容易被静止过滤
APP_MAX_PER_FRAME = 300.0 # 米,位移过大(跳点)会被判异常
APP_MIN_HZ = 1.0 # App 侧通常按 ≥1Hz 采样
def banner(text: str) -> None:
print(f"\n\033[1m{text}\033[0m")
def check_parameters(hz: float, mode_speed: float, drift_m: float, jitter: float) -> int:
problems = []
banner("1) 参数体检(按下发频率与配速推算)")
per_frame = mode_speed / hz if hz else 0
print(f" 下发频率 : {hz:g} Hz → 每帧 {1000 / hz:.0f} ms" if hz else " 下发频率: 非法")
print(f" 基准速度 : {mode_speed:.2f} m/s({mode_speed * 3.6:.1f} km/h)")
print(f" 每帧位移 : {per_frame:.3f} 米")
print(f" 漂移半径 : {drift_m:g} 米 | 速度浮动: ±{jitter * 100:g}%")
if hz < APP_MIN_HZ:
problems.append(f"下发频率 {hz:g}Hz 低于 {APP_MIN_HZ:g}Hz,App 可能采样不到足够的点")
if mode_speed < APP_MIN_SPEED:
problems.append(f"速度 {mode_speed:.2f} m/s 过慢,可能被判为静止")
if mode_speed > APP_MAX_SPEED_WALK:
problems.append(
f"速度 {mode_speed:.2f} m/s({mode_speed * 3.6:.1f} km/h)对跑步来说过快,"
f"运动类 App 常因此判为异常并**不记录里程**(建议 ≤{APP_MAX_SPEED_WALK * 3.6:.0f} km/h)"
)
if per_frame < APP_MIN_PER_FRAME:
problems.append(f"每帧位移仅 {per_frame:.3f} 米,可能被静止过滤(建议提高下发频率或速度)")
if per_frame > APP_MAX_PER_FRAME:
problems.append(f"每帧位移 {per_frame:.0f} 米过大,会被判为跳点")
if drift_m > 15:
problems.append(f"漂移半径 {drift_m:g} 米偏大,可能触发精度过滤(建议 4~8 米)")
if problems:
print("\n \033[33m⚠ 发现问题:\033[0m")
for item in problems:
print(f" - {item}")
else:
print("\n \033[32m✓ 参数组合合理\033[0m")
return len(problems)
def check_area(lat: float | None, lng: float | None) -> None:
banner("2) 区域检查(校园跑的关键!)")
if lat is None or lng is None:
print(" 未提供学校坐标。运动世界校园只在**校内有效区域**内计里程,")
print(" 请用 --lat/--lng 指定你学校的位置,或直接在界面上用「快速定位到坐标」跳过去画轨迹。")
return
print(f" 目标坐标: {lat:.6f}, {lng:.6f}")
print(" ⚠ 请确认这个坐标落在你学校的有效跑步区域内(该 App 会校验)。")
print(" 获取方式:手机上打开地图 App,长按学校操场位置,即可看到经纬度。")
def live_test(seconds: float, hz: float, mode: str, lat: float | None, lng: float | None) -> int:
banner(f"3) 真机实测:连续下发 {seconds:.0f} 秒并统计")
port = 8810
app = server_mod.App(root=ROOT, mock=False, port=port)
devices = app.devices.refresh()
if not devices:
print(" \033[31m✗ 未发现设备\033[0m")
return 1
device = app.devices.get_selected()
print(f" 设备: {device.name} | iOS {device.ios_version} | {device.connection}")
from selftest import WSClient # 延迟导入,避免非真机场景的依赖
httpd = server_mod.serve(app, host="127.0.0.1", port=port)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
time.sleep(0.4)
client = WSClient("127.0.0.1", port, timeout=180)
try:
client.send({"type": "hello", "id": "h1"})
client.wait_for(lambda m: m.get("id") == "h1", timeout=20)
client.send({"type": "device.connect", "id": "c1"})
ack = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "c1", timeout=180)
if not (ack and ack.get("ok", True)):
print(f" \033[31m✗ 连接设备失败:{ack}\033[0m")
return 1
print(" 设备已连接(DVT 会话就绪)")
if lat is None or lng is None:
print(" 未提供坐标,跳过实测(用 --lat/--lng 指定学校位置)")
return 0
# 以给定坐标为中心,造一条 1 公里左右的环形轨迹
d = 0.0045 # 约 500 米
waypoints = [
[lng - d, lat - d], [lng + d, lat - d],
[lng + d, lat + d], [lng - d, lat + d],
[lng - d, lat - d],
]
route = app.routes.build(waypoints, mode=mode, use_osrm=False)
print(f" 轨迹: {route.distance:.0f} 米({mode},基准 {route.speed_at_distance(0):.2f} m/s)")
client.send({"type": "play.start", "id": "s1", "route": route.to_json(),
"speed_scale": 1.0,
"tuning": {"drift_enabled": True, "drift_m": 4,
"speed_jitter_enabled": True, "speed_jitter": 0.08}})
started = client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "s1", timeout=30)
if not (started and started.get("ok", True)):
print(f" \033[31m✗ 播放启动失败:{started}\033[0m")
return 1
print(f" 采集中…({seconds:.0f} 秒)")
time.sleep(seconds)
with client.lock:
samples = [m for m in client.messages if m.get("type") == "location" and m.get("source") == "play"]
client.send({"type": "play.stop", "id": "st1"})
client.wait_for(lambda m: m.get("type") == "ack" and m.get("id") == "st1", timeout=20)
if len(samples) < 3:
print(f" \033[31m✗ 只收到 {len(samples)} 个样本,定位可能没下发\033[0m")
return 1
gaps = [samples[i]["sent_at"] - samples[i - 1]["sent_at"] for i in range(1, len(samples))]
hops = [
geoutils.haversine(samples[i - 1]["lat"], samples[i - 1]["lng"],
samples[i]["lat"], samples[i]["lng"])
for i in range(1, len(samples))
]
speeds = [s["speed"] for s in samples if s.get("speed")]
real_hz = 1.0 / statistics.mean(gaps) if gaps else 0
total = sum(hops)
print(f"\n 实际下发频率: {real_hz:.2f} Hz({statistics.mean(gaps) * 1000:.0f} ms/点)")
print(f" 每帧位移 : 平均 {statistics.mean(hops):.3f} 米(最小 {min(hops):.3f})")
print(f" 累计位移 : {total:.1f} 米 / {seconds:.0f} 秒")
print(f" 上报速度 : 平均 {statistics.mean(speeds):.2f} m/s({statistics.mean(speeds) * 3.6:.1f} km/h)")
verdict = []
if real_hz < APP_MIN_HZ:
verdict.append(f"下发频率 {real_hz:.2f}Hz 偏低")
if min(hops) < APP_MIN_PER_FRAME:
verdict.append(f"存在 {min(hops):.3f} 米的极小位移(可能被静止过滤)")
if max(hops) > APP_MAX_PER_FRAME:
verdict.append(f"存在 {max(hops):.0f} 米的跳点")
if statistics.mean(speeds) > APP_MAX_SPEED_WALK:
verdict.append(f"速度 {statistics.mean(speeds) * 3.6:.1f} km/h 对跑步偏快")
print()
if verdict:
print(" \033[33m⚠ 下发的点可能被 App 过滤:\033[0m")
for item in verdict:
print(f" - {item}")
else:
print(" \033[32m✓ 下发的点在频率、位移、速度上都符合跑步 App 的接受范围\033[0m")
print("\n 请同时在手机上确认:")
print(" 1. 该 App 的定位权限是「使用 App 期间」或「始终」,并且「精确位置」已打开;")
print(" 2. 手机上打开系统地图,蓝点应沿着你画的轨迹移动(验证定位确实生效);")
print(" 3. 轨迹必须覆盖该 App 要求的必打点/必经点,否则里程不计入成绩。")
return 1 if verdict else 0
finally:
client.close()
app.shutdown()
try:
httpd.shutdown()
httpd.server_close()
except Exception:
pass
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--live", type=float, default=0, help="连真机实测多少秒(0=不实测)")
parser.add_argument("--mode", default="walking", choices=["walking", "cycling", "driving"])
parser.add_argument("--lat", type=float, default=None, help="学校纬度(WGS84)")
parser.add_argument("--lng", type=float, default=None, help="学校经度(WGS84)")
args = parser.parse_args()
print("\033[1m校园跑类 App 定位诊断\033[0m")
from core.config_store import ConfigStore
cfg = ConfigStore(ROOT / "config.json")
hz = float(cfg.get("send_hz") or 5)
mode_speed = float((cfg.get("mode_defaults") or {}).get(args.mode, 1.4))
drift_m = float(cfg.get("drift_m") or 0)
jitter = float(cfg.get("speed_jitter") or 0)
problems = check_parameters(hz, mode_speed, drift_m, jitter)
check_area(args.lat, args.lng)
live_problems = 0
if args.live > 0:
live_problems = live_test(args.live, hz, args.mode, args.lat, args.lng)
else:
banner("3) 真机实测")
print(" 跳过(加 --live 20 可连真机实测 20 秒)")
banner("结论")
if problems or live_problems:
print(" 参数或下发质量存在问题,见上面 ⚠ 项。")
return 1
print(" 定位下发本身没问题。如果 App 仍不显示里程,问题在 App 侧:")
print(" · 是否在**校内有效区域**内跑(校园跑的核心规则)")
print(" · 是否跑过该次要求的**必打点 / 必经点**")
print(" · 有效里程是否达到最低门槛(很多学校要求单次 ≥1~2 公里)")
print(" · App 是否要求前台运行 + 屏幕常亮 + 后台定位权限")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+245
View File
@@ -0,0 +1,245 @@
#!/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())
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""把打包好的 .app 制作成可分发的 DMG 安装包。
产物:dist/虚拟定位轨迹-1.0.0.dmg
内容:应用本身 + Applications 快捷方式(拖进去即安装)+ 中文安装说明
特点:
- UDZO 压缩(zlib),体积最小且兼容性好
- 固定卷名、固定图标位置(打开 DMG 后两个图标位置整齐)
- 自定义背景图(程序化生成,无需设计稿)
- 生成后自动校验挂载与内容
用法:./.venv/bin/python tools/make_dmg.py
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
APP_NAME = "虚拟定位轨迹"
VERSION = "1.0.0"
APP = ROOT / "dist" / f"{APP_NAME}.app"
DMG = ROOT / "dist" / f"{APP_NAME}-{VERSION}.dmg"
VOLUME = APP_NAME
BG_WIDTH, BG_HEIGHT = 600, 400
def run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess:
return subprocess.run(cmd, capture_output=True, text=True, **kwargs)
def make_background(path: Path) -> None:
"""程序化生成 DMG 背景图(深色,与应用图标风格一致)。"""
try:
from PIL import Image, ImageDraw
except ImportError:
return
img = Image.new("RGB", (BG_WIDTH, BG_HEIGHT), (24, 30, 42))
draw = ImageDraw.Draw(img)
# 顶部渐变更深一点,营造层次
for y in range(BG_HEIGHT // 2):
t = y / (BG_HEIGHT / 2)
color = (int(24 + 10 * t), int(30 + 12 * t), int(42 + 16 * t))
draw.line([(0, y), (BG_WIDTH, y)], fill=color)
# 一条虚线轨迹指向右侧(呼应应用图标)
for x in range(70, 520, 18):
draw.line([(x, 300), (x + 9, 300)], fill=(70, 90, 120), width=3)
# 中间箭头提示"拖过去"
draw.line([(250, 190), (330, 190)], fill=(120, 150, 190), width=4)
draw.polygon([(330, 178), (352, 190), (330, 202)], fill=(120, 150, 190))
img.save(path)
def main() -> int:
if not APP.exists():
print(f"未找到 {APP},请先运行 ./scripts/build_app.sh")
return 1
py = sys.executable
print("\033[1m制作 DMG 安装包\033[0m")
print(f" 应用:{APP}")
app_size = sum(f.stat().st_size for f in APP.rglob("*") if f.is_file()) / 1048576
print(f" 应用体积:{app_size:.1f} MB")
# 注意:/tmp 在系统只读卷上(可用空间很小),而 UDRW 中间镜像需要约 2 倍应用体积,
# 因此临时目录必须放在数据卷(用户目录)下。
staging_root = Path.home() / "Library" / "Caches" / "iosloc-dmg"
staging_root.mkdir(parents=True, exist_ok=True)
staging = Path(tempfile.mkdtemp(prefix="build-", dir=str(staging_root)))
try:
# 1) 准备素材
print("\n1) 准备 DMG 内容")
target_app = staging / f"{APP_NAME}.app"
shutil.copytree(APP, target_app, symlinks=True)
# Applications 快捷方式:用户把 app 拖进去即完成安装
os.symlink("/Applications", staging / "Applications")
bg = staging / ".background.png"
make_background(bg)
readme = staging / "安装说明.txt"
readme.write_text(
f"""{APP_NAME} v{VERSION}
========================================
安装:
把「{APP_NAME}.app」拖到右侧的 Applications 文件夹即可。
首次打开:
本工具未做 Apple 公证,若提示"来自身份不明的开发者",
请在应用上点右键 → 打开 → 再点"打开"。
首次运行还会请求"控制 Chrome/Edge"权限(用于以应用窗口方式打开界面)。
需要什么:
· macOS 12 或更高(Apple Silicon / M 系列芯片)
· 一根数据线 + 一台 iOS 设备(iOS 17 及以上需开启开发者模式并挂载开发者镜像)
· 不需要安装 Python 或任何依赖,全部已打包在应用内
数据位置:
~/Library/Application Support/iOSLocationTrack/
(配置与地图瓦片缓存;删除该目录即可重置)
完整使用说明见应用内的 README,或访问项目目录下的 README.md
""",
encoding="utf-8",
)
print(f" 已放入:应用 + Applications 快捷方式 + 安装说明 + 背景图")
# 2) 生成可读写 DMG 以便布局图标
temp_dmg = staging / "temp.dmg"
print("\n2) 生成磁盘映像并设置窗口布局")
result = run([
"hdiutil", "create", "-srcfolder", str(staging), "-volname", VOLUME,
"-fs", "HFS+", "-format", "UDRW", "-ov", str(temp_dmg),
])
if result.returncode != 0:
print(" hdiutil create 失败:", result.stderr[-500:])
return 1
mount_point = Path(f"/Volumes/{VOLUME}")
if mount_point.exists():
run(["hdiutil", "detach", str(mount_point), "-force"])
result = run(["hdiutil", "attach", str(temp_dmg), "-readwrite", "-noverify", "-noautoopen"])
if result.returncode != 0:
print(" 挂载失败:", result.stderr[-400:])
return 1
time.sleep(2)
# 用 AppleScript 设置 Finder 视图(图标位置 / 背景 / 大小)
script = f'''
tell application "Finder"
tell disk "{VOLUME}"
open
set current view of container window to icon view
set toolbar visible of container window to false
set statusbar visible of container window to false
set the bounds of container window to {{200, 150, 800, 550}}
set viewOptions to the icon view options of container window
set arrangement of viewOptions to not arranged
set icon size of viewOptions to 110
try
set background picture of viewOptions to file ".background.png"
end try
set position of item "{APP_NAME}.app" of container window to {{150, 190}}
set position of item "Applications" of container window to {{450, 190}}
set position of item "安装说明.txt" of container window to {{300, 320}}
update without registering applications
delay 1
close
end tell
end tell
'''
result = run(["osascript", "-e", script])
if result.returncode != 0:
print(" 布局脚本部分失败(不影响使用):", result.stderr.strip()[:200])
# 收尾:确保 Finder 写入的 .DS_Store 落盘
run(["sync"])
time.sleep(1)
detach = run(["hdiutil", "detach", str(mount_point)])
if detach.returncode != 0:
run(["hdiutil", "detach", str(mount_point), "-force"])
time.sleep(1)
# 3) 压缩成最终 DMG
print("\n3) 压缩为最终 DMG")
if DMG.exists():
DMG.unlink()
result = run([
"hdiutil", "convert", str(temp_dmg), "-format", "UDZO",
"-imagekey", "zlib-level=9", "-o", str(DMG),
])
if result.returncode != 0:
print(" 压缩失败:", result.stderr[-400:])
return 1
finally:
shutil.rmtree(staging, ignore_errors=True)
try:
shutil.rmtree(staging_root, ignore_errors=True)
except Exception:
pass
# 4) 校验
print("\n4) 校验 DMG")
size_mb = DMG.stat().st_size / 1048576
print(f" 文件:{DMG}")
print(f" 体积:{size_mb:.1f} MB")
verify_mount = Path(f"/Volumes/{VOLUME}")
result = run(["hdiutil", "attach", str(DMG), "-nobrowse", "-readonly", "-noautoopen"])
ok_mount = result.returncode == 0
print(f" 挂载:{'成功' if ok_mount else '失败 ' + result.stderr[-200:]}")
if ok_mount:
try:
time.sleep(2)
contents = sorted(p.name for p in verify_mount.iterdir())
print(f" 内容:{contents}")
has_app = (verify_mount / f"{APP_NAME}.app").exists()
has_link = (verify_mount / "Applications").is_symlink()
has_readme = (verify_mount / "安装说明.txt").exists()
binary = verify_mount / f"{APP_NAME}.app" / "Contents" / "MacOS" / "iOSLocationTrack"
print(f" 应用存在:{has_app} | Applications 快捷方式:{has_link} | 安装说明:{has_readme}")
if binary.exists():
arch = run(["lipo", "-archs", str(binary)]).stdout.strip()
print(f" 可执行架构:{arch}")
# 校验签名/完整性(未签名时的常规检查)
quarantine = run(["xattr", "-l", str(verify_mount / f"{APP_NAME}.app")]).stdout
print(f" 扩展属性:{'有' if quarantine.strip() else '无'}")
finally:
run(["hdiutil", "detach", str(verify_mount), "-force"])
print(f"\n\033[32m完成:{DMG}\033[0m")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""生成应用图标(程序化绘制,无需设计文件)。
设计:
- 深色圆角方块底(与界面深色主题一致),叠加轻微纵向渐变
- 一条虚线轨迹(用点表示途经点),从左上延伸到中心的定位针
- 定位针用「描边 + 内部圆点」造型,16px 下仍然可辨识
- 右下角一枚小圆点表示"当前位置"的脉冲
输出:resources/AppIcon.iconset/*.png + resources/AppIcon.icns
用法:./.venv/bin/python tools/make_icon.py
"""
from __future__ import annotations
import math
import shutil
import subprocess
import sys
from pathlib import Path
try:
from PIL import Image, ImageDraw
except ImportError:
print("需要 Pillow:./.venv/bin/python -m pip install pillow")
raise SystemExit(1)
ROOT = Path(__file__).resolve().parent.parent
OUT_DIR = ROOT / "resources"
ICONSET = OUT_DIR / "AppIcon.iconset"
ICNS = OUT_DIR / "AppIcon.icns"
# 主题色(与界面一致)
BG_TOP = (26, 36, 52)
BG_BOTTOM = (14, 20, 30)
ACCENT = (59, 130, 246) # 蓝
ACCENT_HI = (96, 165, 250)
ROUTE = (148, 163, 184) # 轨迹灰
PIN = (248, 113, 113) # 定位针红
PIN_HI = (254, 202, 202)
# macOS 图标规范:内容四周留出约 10% 安全边距,圆角半径约 22.37%
SAFE_RATIO = 0.085
CORNER_RATIO = 0.2237
def _bezier(points, steps=200):
"""二次贝塞尔采样(用于画出平滑的轨迹线)。"""
(x1, y1), (x2, y2), (x3, y3) = points
out = []
for i in range(steps + 1):
t = i / steps
mt = 1 - t
out.append((
mt * mt * x1 + 2 * mt * t * x2 + t * t * x3,
mt * mt * y1 + 2 * mt * t * y2 + t * t * y3,
))
return out
def _stroke(draw, points, width, color):
"""用填充多边形画出平滑的粗线(PIL 的宽 line 逐段绘制会出现"肋纹")。"""
if len(points) < 2:
return
half = width / 2.0
left, right = [], []
n = len(points)
for i, (x, y) in enumerate(points):
# 用前后点估计切线方向
px, py = points[max(0, i - 1)]
nx, ny = points[min(n - 1, i + 1)]
dx, dy = nx - px, ny - py
length = math.hypot(dx, dy)
if length < 1e-9:
dx, dy, length = 1.0, 0.0, 1.0
ux, uy = -dy / length, dx / length # 法线
left.append((x + ux * half, y + uy * half))
right.append((x - ux * half, y - uy * half))
draw.polygon(left + right[::-1], fill=color)
# 两端补圆头
r = width / 2.0
for (ex, ey) in (points[0], points[-1]):
draw.ellipse((ex - r, ey - r, ex + r, ey + r), fill=color)
def draw_icon(size: int) -> Image.Image:
"""在 size×size 画布上绘制图标(4 倍超采样后缩小,边缘平滑)。"""
ss = 4
canvas = size * ss
img = Image.new("RGBA", (canvas, canvas), (0, 0, 0, 0))
margin = int(canvas * SAFE_RATIO)
box = (margin, margin, canvas - margin, canvas - margin)
radius = int((box[2] - box[0]) * CORNER_RATIO)
inner = box[2] - box[0]
# 1) 圆角底 + 纵向渐变(对角向,更像 macOS 应用图标)
gradient = Image.new("RGBA", (canvas, canvas), (0, 0, 0, 0))
gdraw = ImageDraw.Draw(gradient)
for y in range(box[1], box[3]):
t = (y - box[1]) / max(1, box[3] - box[1])
color = tuple(int(BG_TOP[i] + (BG_BOTTOM[i] - BG_TOP[i]) * t) for i in range(3))
gdraw.line([(box[0], y), (box[2], y)], fill=color + (255,))
mask = Image.new("L", (canvas, canvas), 0)
ImageDraw.Draw(mask).rounded_rectangle(box, radius=radius, fill=255)
img.paste(gradient, (0, 0), mask)
draw = ImageDraw.Draw(img)
line_w = max(2, int(inner * 0.048))
# 2) 平滑轨迹线:从左下到右上的贝塞尔曲线(实线 + 途经点圆点,小尺寸下最清晰)
curve = _bezier([
(box[0] + inner * 0.14, box[1] + inner * 0.82),
(box[0] + inner * 0.44, box[1] + inner * 0.76),
(box[0] + inner * 0.60, box[1] + inner * 0.56),
])
_stroke(draw, curve, line_w, ROUTE + (215,))
# 途经点小圆(沿轨迹均匀分布,表达"添加途经点"这一核心功能)
dot_r = line_w * 0.78
for frac in (0.18, 0.45):
wx, wy = curve[int(len(curve) * frac)]
draw.ellipse((wx - dot_r, wy - dot_r, wx + dot_r, wy + dot_r), fill=ACCENT_HI + (255,))
# 3) 定位针(水滴形 + 白色内圆)
pin_cx = box[0] + inner * 0.68
pin_cy = box[1] + inner * 0.38
pin_r = inner * 0.185
tip_y = pin_cy + pin_r * 2.16
# 针身:圆 + 收窄到底部的三角,拼成水滴
draw.ellipse((pin_cx - pin_r, pin_cy - pin_r, pin_cx + pin_r, pin_cy + pin_r), fill=PIN + (255,))
draw.polygon([
(pin_cx - pin_r * 0.60, pin_cy + pin_r * 0.78),
(pin_cx + pin_r * 0.60, pin_cy + pin_r * 0.78),
(pin_cx, tip_y),
], fill=PIN + (255,))
# 白色内圆:小尺寸下也能看出是"定位标记"
hole = pin_r * 0.40
draw.ellipse((pin_cx - hole, pin_cy - hole, pin_cx + hole, pin_cy + hole), fill=(255, 255, 255, 255))
# 4) 针下方落点阴影(让针"站"在地图上)
sh_w = pin_r * 0.95
sh_h = max(1, int(pin_r * 0.22))
shadow = Image.new("RGBA", (canvas, canvas), (0, 0, 0, 0))
ImageDraw.Draw(shadow).ellipse(
(pin_cx - sh_w, tip_y - sh_h * 0.4, pin_cx + sh_w, tip_y + sh_h * 1.6),
fill=(0, 0, 0, 90),
)
img = Image.alpha_composite(img, shadow)
draw = ImageDraw.Draw(img)
# 5) 当前位置:轨迹上的一个高亮圆点(在针的左下侧)
px, py = curve[int(len(curve) * 0.72)]
pr = line_w * 0.72
draw.ellipse((px - pr * 1.8, py - pr * 1.8, px + pr * 1.8, py + pr * 1.8),
fill=ACCENT + (60,))
draw.ellipse((px - pr, py - pr, px + pr, py + pr), fill=ACCENT_HI + (255,))
return img.resize((size, size), Image.LANCZOS)
def main() -> int:
OUT_DIR.mkdir(parents=True, exist_ok=True)
shutil.rmtree(ICONSET, ignore_errors=True)
ICONSET.mkdir(parents=True, exist_ok=True)
# iconset 需要的尺寸组合
specs = [
(16, "icon_16x16.png"), (32, "icon_16x16@2x.png"),
(32, "icon_32x32.png"), (64, "icon_32x32@2x.png"),
(128, "icon_128x128.png"), (256, "icon_128x128@2x.png"),
(256, "icon_256x256.png"), (512, "icon_256x256@2x.png"),
(512, "icon_512x512.png"), (1024, "icon_512x512@2x.png"),
]
cache: dict[int, Image.Image] = {}
for size, name in specs:
if size not in cache:
cache[size] = draw_icon(size)
cache[size].save(ICONSET / name)
print(f"已生成 {len(specs)} 个尺寸 → {ICONSET}")
# 预览图(512)
preview = OUT_DIR / "AppIcon-preview.png"
cache[512].save(preview)
print(f"预览图 → {preview}")
# 合成 icns
try:
subprocess.run(["iconutil", "-c", "icns", str(ICONSET), "-o", str(ICNS)], check=True)
print(f"已生成 {ICNS}({ICNS.stat().st_size // 1024} KB)")
except Exception as exc:
print(f"iconutil 失败:{exc}")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env python3
"""列出 iOS 设备并打印 iOS 版本 / DVT 虚拟定位可用性。
用法(项目根目录下执行):
./.venv/bin/python tools/probe_device.py
./.venv/bin/python tools/probe_device.py --rsd 10.7.0.1 51234
./.venv/bin/python tools/probe_device.py --json
说明:
* 没有连接设备时打印中文提示并以退出码 2 结束,不会抛栈。
* ``--rsd HOST PORT`` 时会对 iOS 17+ 设备真正建立一次 RSD+DVT 会话做连通性验证
(不设置任何位置,验证后立即断开)。
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.location_provider import ( # noqa: E402
PMD3_MISSING_HINT,
RealLocationProvider,
pymobiledevice3_version,
pymobiledevice3_available,
)
#: iOS 17 起 DVT 服务只能通过 RSD 隧道访问
RSD_REQUIRED_IOS_MAJOR = 17
def _print(text: str = "") -> None:
print(text, flush=True)
async def _enumerate_devices(udid: str | None) -> list[dict]:
"""优先用 lead 的 core/device.py,不可用时退回直接查 usbmux。"""
devices: list[dict] = []
try: # lead 的 DeviceManager(可能尚未实现或签名不同,失败则退化)
from core.device import DeviceManager # type: ignore
for factory in (lambda: DeviceManager(), lambda: DeviceManager(mock=False)):
try:
manager = factory()
raw = manager.list_devices()
if asyncio.iscoroutine(raw):
raw = await raw
for item in raw or []:
devices.append(dict(item))
if devices:
return devices
except Exception:
continue
except Exception:
pass
# 退化路径:直接用 pymobiledevice3 的 usbmux
from pymobiledevice3 import usbmux
from pymobiledevice3.lockdown import create_using_usbmux
for mux_device in await usbmux.list_devices():
if udid and not mux_device.matches_udid(udid):
continue
entry: dict = {
"udid": mux_device.serial,
"connection": (mux_device.connection_type or "").lower(),
"name": None,
"product_type": None,
"ios_version": None,
"ios_major": 0,
"developer_mode": None,
"state": "connected",
"error": None,
}
try:
lockdown = await create_using_usbmux(serial=mux_device.serial, autopair=False)
except Exception as exc:
entry["state"] = "unpaired"
entry["error"] = f"{type(exc).__name__}: {exc}"
devices.append(entry)
continue
try:
entry["name"] = lockdown.all_values.get("DeviceName")
entry["product_type"] = lockdown.all_values.get("ProductType")
version = str(lockdown.product_version or "")
entry["ios_version"] = version
try:
entry["ios_major"] = int(version.split(".", 1)[0])
except (ValueError, IndexError):
entry["ios_major"] = 0
try:
entry["developer_mode"] = await lockdown.get_developer_mode_status()
except Exception:
entry["developer_mode"] = None
finally:
try:
await lockdown.close()
except Exception:
pass
devices.append(entry)
return devices
def _judge(device: dict, rsd: tuple | None) -> tuple[bool, str]:
"""返回 (DVT 是否可能可用, 中文说明)。"""
major = int(device.get("ios_major") or 0)
state = device.get("state")
if state == "unpaired":
return False, "未配对:请在 iPhone 上点击「信任此电脑」后重试"
if device.get("developer_mode") is False:
return False, "未开启开发者模式:设置 → 隐私与安全性 → 开发者模式(iOS16+ 必须开启)"
if major >= RSD_REQUIRED_IOS_MAJOR:
if rsd:
return True, f"iOS {major} ≥ 17:需要 RSD 隧道,已提供 {rsd[0]}:{rsd[1]}(待实测)"
return False, (
f"iOS {major} ≥ 17:必须提供 RSD 隧道(--rsd HOST PORT)。"
"免 root:pymobiledevice3 remote start-tunnel --native --script-mode;"
"需 root:sudo pymobiledevice3 remote start-tunnel --script-mode"
)
if major == 0:
return False, "无法识别 iOS 版本:设备可能未解锁或未配对"
return True, f"iOS {major} < 17:可经 usbmux/lockdown 直连 DVT(或老版 simulatelocation 服务)"
async def _probe_dvt(device: dict, rsd: tuple | None) -> dict:
"""真正建立一次会话验证 DVT 是否可用(不设置位置)。"""
provider = RealLocationProvider(
rsd_host=rsd[0] if rsd else None,
rsd_port=rsd[1] if rsd else None,
udid=device.get("udid"),
)
try:
info = await provider.connect(device)
except Exception as exc:
return {"ok": False, "detail": str(exc)}
try:
await provider.close()
except Exception:
pass
return {"ok": True, "detail": info.get("detail", "连接成功"), "strategy": info.get("strategy")}
async def _main_async(args: argparse.Namespace) -> int:
rsd = (args.rsd[0], int(args.rsd[1])) if args.rsd else None
if not pymobiledevice3_available():
_print(f"[错误] {PMD3_MISSING_HINT}")
return 1
_print(f"pymobiledevice3 版本:{pymobiledevice3_version()}")
_print(f"Python:{sys.version.split()[0]}")
if rsd:
_print(f"RSD 隧道:{rsd[0]}:{rsd[1]}")
_print("")
try:
devices = await _enumerate_devices(args.udid)
except Exception as exc:
_print(f"[错误] 枚举设备失败:{type(exc).__name__}: {exc}")
return 1
if not devices:
_print("未发现 iOS 设备。请:")
_print(" 1) 用 USB 数据线连接 iPhone(不是仅充电线);")
_print(" 2) 解锁手机并在弹窗中点击「信任此电脑」;")
_print(" 3) 若使用 Wi-Fi 连接,请确认与电脑在同一网络。")
return 2
results = []
for device in devices:
available, reason = _judge(device, rsd)
entry = {"device": device, "available": available, "reason": reason, "probe": None}
if args.probe and available:
entry["probe"] = await _probe_dvt(device, rsd)
entry["available"] = bool(entry["probe"].get("ok"))
results.append(entry)
if args.json:
_print(json.dumps(results, ensure_ascii=False, indent=2))
return 0
for index, entry in enumerate(results, 1):
device = entry["device"]
_print(f"=== 设备 {index} ===")
_print(f" UDID : {device.get('udid')}")
_print(f" 名称 : {device.get('name') or '(未知)'}")
_print(f" 型号 : {device.get('product_type') or '(未知)'}")
_print(f" iOS 版本 : {device.get('ios_version') or '(未知)'} (major={device.get('ios_major')})")
_print(f" 连接方式 : {device.get('connection') or '(未知)'}")
_print(f" 配对状态 : {device.get('state')}")
developer_mode = device.get("developer_mode")
_print(f" 开发者模式 : {'已开启' if developer_mode else ('未开启' if developer_mode is False else '未知')}")
if device.get("error"):
_print(f" 错误 : {device['error']}")
_print(f" DVT 可用性 : {'可能可用' if entry['available'] else '不可用'} —— {entry['reason']}")
if entry["probe"]:
probe = entry["probe"]
_print(f" 实测连接 : {'成功' if probe.get('ok') else '失败'} —— {probe.get('detail')}")
_print("")
_print("提示:iOS 17+ 必须先建立 RSD 隧道;不带 --rsd 时只做静态判断。")
_print(" 加 --probe --rsd HOST PORT 可实测建立一次 DVT 会话。")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="列出 iOS 设备并检查 DVT 虚拟定位可用性")
parser.add_argument("--rsd", nargs=2, metavar=("HOST", "PORT"), help="RSD 隧道地址(iOS 17+ 必需)")
parser.add_argument("--udid", help="只检查指定 UDID")
parser.add_argument("--probe", action="store_true", help="真正建立一次 DVT 会话做连通性验证")
parser.add_argument("--json", action="store_true", help="以 JSON 输出")
args = parser.parse_args()
try:
return asyncio.run(_main_async(args))
except KeyboardInterrupt:
return 130
if __name__ == "__main__":
sys.exit(main())
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""命令行测试真机虚拟定位(单点设置 / 清除)。
用法(项目根目录下执行):
# iOS<17:USB 直连,设置单点位置并保持(Ctrl-C 结束,结束时自动恢复真实定位)
./.venv/bin/python tools/probe_location.py --lat 39.908 --lon 116.397
# iOS 17+:先建隧道再指定 RSD 地址
./.venv/bin/python tools/probe_location.py --rsd 10.7.0.1 51234 --lat 39.908 --lon 116.397
# 只恢复真实定位
./.venv/bin/python tools/probe_location.py --rsd 10.7.0.1 51234 --clear
# 自动化:只保持 10 秒后自动清除
./.venv/bin/python tools/probe_location.py --lat 39.908 --lon 116.397 --duration 10
注意:DVT 虚拟定位依赖**保持连接**(连接断开即失效),所以本工具在 --duration 期间
一直持有同一个 DVT 会话;退出前会调用 clear_location() 让手机恢复真实定位。
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.location_provider import ( # noqa: E402
PMD3_MISSING_HINT,
RealLocationProvider,
pymobiledevice3_available,
pymobiledevice3_version,
)
def _print(text: str = "") -> None:
print(text, flush=True)
def _log(level: str, msg: str) -> None:
prefix = {"error": "[错误]", "warning": "[警告]", "success": "[成功]"}.get(level, "[信息]")
_print(f"{prefix} {msg}")
async def _main_async(args: argparse.Namespace) -> int:
if not pymobiledevice3_available():
_print(f"[错误] {PMD3_MISSING_HINT}")
return 1
if not args.clear and (args.lat is None or args.lon is None):
_print("[错误] 需要同时提供 --lat 与 --lon(或使用 --clear 仅恢复真实定位)")
return 1
provider = RealLocationProvider(
rsd_host=args.rsd[0] if args.rsd else None,
rsd_port=int(args.rsd[1]) if args.rsd else None,
udid=args.udid,
connect_timeout=args.timeout,
on_log=_log,
)
_print(f"pymobiledevice3 版本:{pymobiledevice3_version()}")
if args.rsd:
_print(f"RSD 隧道:{args.rsd[0]}:{args.rsd[1]}")
else:
_print("未指定 --rsd:将尝试 usbmux/lockdown 直连(仅 iOS<17 可用)")
_print("")
try:
info = await provider.connect(None)
except Exception as exc:
_print(f"[错误] 连接失败:{exc}")
return 1
_print(f"[信息] 会话已建立:{info.get('detail')}")
_print(f"[信息] 策略={info.get('strategy')} iOS={info.get('ios_version') or info.get('ios_major')}")
exit_code = 0
try:
if args.clear:
await provider.clear_location()
_print("[成功] 已恢复真实定位")
else:
await provider.set_location(float(args.lat), float(args.lon))
_print(f"[成功] 已设置虚拟定位 lat={float(args.lat):.6f}, lon={float(args.lon):.6f}")
if args.json:
_print(json.dumps({"ok": True, "lat": args.lat, "lon": args.lon}, ensure_ascii=False))
if args.duration and args.duration > 0:
_print(f"[信息] 保持 {args.duration:.0f} 秒(期间不要断开连接)…")
await asyncio.sleep(args.duration)
else:
_print("[信息] 定位保持中:按 Ctrl-C 结束并恢复真实定位…")
try:
while True:
await asyncio.sleep(1.0)
except (KeyboardInterrupt, asyncio.CancelledError):
_print("")
_print("[信息] 收到中断,准备恢复真实定位…")
except KeyboardInterrupt:
_print("")
_print("[信息] 收到中断,准备恢复真实定位…")
except Exception as exc:
_print(f"[错误] {exc}")
exit_code = 1
finally:
try:
await provider.clear_location()
_print("[信息] 已调用 clear_location(),设备恢复真实定位")
except Exception as exc:
_print(f"[警告] 恢复真实定位失败:{exc}")
exit_code = exit_code or 1
try:
await provider.close()
except Exception as exc:
_print(f"[警告] 关闭会话时出错:{exc}")
return exit_code
def main() -> int:
parser = argparse.ArgumentParser(description="命令行测试真机虚拟定位(DVT)")
parser.add_argument("--lat", type=float, help="纬度(-90~90,WGS84)")
parser.add_argument("--lon", type=float, help="经度(-180~180,WGS84)")
parser.add_argument("--clear", action="store_true", help="只清除虚拟定位,恢复真实定位")
parser.add_argument("--rsd", nargs=2, metavar=("HOST", "PORT"), help="RSD 隧道地址(iOS 17+ 必需)")
parser.add_argument("--udid", help="目标设备 UDID(默认第一台 USB 设备)")
parser.add_argument("--duration", type=float, default=0.0, help="保持秒数;0 表示等待 Ctrl-C(默认 0)")
parser.add_argument("--timeout", type=float, default=15.0, help="建立会话超时秒数(默认 15)")
parser.add_argument("--json", action="store_true", help="成功后额外输出一行 JSON")
args = parser.parse_args()
try:
return asyncio.run(_main_async(args))
except KeyboardInterrupt:
return 130
if __name__ == "__main__":
sys.exit(main())
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""给界面截图(视觉验收用)。
做法:headless Chrome 的一次性 --screenshot 模式 + 独立 user-data-dir,
不依赖 CDP WebSocket(本机没有 websocket-client 库,Chrome 的 CDP 又需要它)。
--screenshot 在页面 load 事件后立即截图,用一次注入脚本做等待/交互,
脚本通过 URL fragment 传不进去,所以这里用临时 HTML 包装页来间接执行前置动作。
用法:
./.venv/bin/python tools/screenshot.py http://127.0.0.1:8787/ out.png [--wait 9]
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import signal
import subprocess
import sys
import time
from pathlib import Path
CHROME_CANDIDATES = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
]
def find_chrome() -> str:
for path in CHROME_CANDIDATES:
if os.path.exists(path):
return path
raise SystemExit("未找到 Chrome / Edge / Chromium")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("url")
parser.add_argument("out", nargs="?", default="screenshot.png")
parser.add_argument("--wait", type=float, default=9.0, help="页面加载后额外等待秒数(等瓦片/WS 渲染)")
parser.add_argument("--width", type=int, default=1440)
parser.add_argument("--height", type=int, default=900)
parser.add_argument("--profile", default="/tmp/iosloc-shot-profile")
parser.add_argument("--json", action="store_true", help="同时打印页面控制台日志(--enable-logging)")
args = parser.parse_args()
chrome = find_chrome()
profile = Path(args.profile)
if profile.exists():
shutil.rmtree(profile, ignore_errors=True)
profile.mkdir(parents=True, exist_ok=True)
out = Path(args.out).resolve()
if out.exists():
out.unlink()
command = [
chrome,
"--headless=new",
"--disable-gpu",
"--no-first-run",
"--no-default-browser-check",
"--hide-scrollbars",
"--disable-extensions",
"--force-device-scale-factor=2",
f"--window-size={args.width},{args.height}",
f"--user-data-dir={profile}",
# Chrome 没有"加载后等待 N 秒"的开关,用虚拟时间预算让定时器与网络请求跑完
f"--virtual-time-budget={int(args.wait * 1000)}",
f"--screenshot={out}",
args.url,
]
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, start_new_session=True)
try:
output, _ = process.communicate(timeout=max(60.0, args.wait * 6))
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
except Exception:
process.kill()
output, _ = process.communicate()
print("Chrome 超时被终止")
if args.json and output:
for line in output.splitlines():
if any(key in line for key in ("ERROR", "SEVERE", "Uncaught", "console")):
print(" [chrome]", line[:220])
if not out.exists() or out.stat().st_size == 0:
print("截图失败")
if output:
print(output[-1500:])
return 1
print(f"截图已保存:{out}({out.stat().st_size // 1024} KB)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+284
View File
@@ -0,0 +1,284 @@
#!/usr/bin/env python3
"""离线自测:不接任何 iOS 设备,验证 Mock/Real 两个 provider 的契约与中文降级逻辑。
用法:
./.venv/bin/python tools/selftest_location.py
覆盖:
1. `core.location_mock` / `core.location_provider` 可 import(即使未安装 pymobiledevice3)。
2. MockLocationProvider 五个接口 + 5~10ms 延时 + 事件循环内运行。
3. RealLocationProvider 的降级:未连接下发 / 未配对 / 未开开发者模式 / iOS17+ 无隧道 /
probe() 永不抛异常 / close() 幂等 —— 全部应为**中文可读信息**,不得是 AttributeError。
"""
from __future__ import annotations
import asyncio
import os
import sys
import threading
import time
import traceback
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.location_mock import MockLocationProvider # noqa: E402
from core.location_provider import ( # noqa: E402
LocationProviderError,
RealLocationProvider,
pymobiledevice3_available,
pymobiledevice3_version,
)
PASSED: list[str] = []
FAILED: list[str] = []
def check(name: str, condition: bool, detail: str = "") -> None:
if condition:
PASSED.append(name)
print(f" [通过] {name}" + (f" —— {detail}" if detail else ""))
else:
FAILED.append(f"{name}: {detail}")
print(f" [失败] {name} —— {detail}")
def has_chinese(text: str) -> bool:
return any("\u4e00" <= ch <= "\u9fff" for ch in text)
async def test_mock() -> None:
print("\n== 1. MockLocationProvider ==")
provider = MockLocationProvider()
check("初始 connected=False", provider.connected is False)
info = await provider.connect({"udid": "MOCK-0001", "ios_version": "26.0", "ios_major": 26})
check("connect 返回 dict 且 ok=True", isinstance(info, dict) and info.get("ok") is True, str(info.get("detail")))
check("connect 后 connected=True", provider.connected is True)
check("connect 返回中文说明", has_chinese(str(info.get("detail"))))
started = time.perf_counter()
await provider.set_location(39.908, 116.397)
elapsed_ms = (time.perf_counter() - started) * 1000
check("set_location 延时 5~10ms", 4.0 <= elapsed_ms <= 60.0, f"实测 {elapsed_ms:.2f}ms")
check("send_count 递增", provider.send_count == 1)
check("last_location 记录正确", provider.last_location == (39.908, 116.397))
await provider.clear_location()
check("clear_location 后 last_location=None", provider.last_location is None)
await provider.close()
check("close 后 connected=False", provider.connected is False)
await provider.close() # 幂等
check("close 幂等", provider.connected is False)
probe = await provider.probe()
check("mock probe ok=True", probe.get("ok") is True and probe.get("ios_major") == 26, str(probe))
async def test_real_degradation() -> None:
print("\n== 2. RealLocationProvider 降级逻辑 ==")
installed = pymobiledevice3_available()
print(f" pymobiledevice3 已安装:{installed}(版本 {pymobiledevice3_version()})")
provider = RealLocationProvider()
check("初始 connected=False", provider.connected is False)
# 2.1 未连接就下发
try:
await provider.set_location(39.9, 116.4)
check("未连接下发应抛异常", False, "没有抛异常")
except LocationProviderError as exc:
check("未连接下发抛 LocationProviderError", True, str(exc))
check("未连接下发错误为中文", has_chinese(str(exc)), str(exc))
except Exception as exc: # noqa: BLE001
check("未连接下发抛 LocationProviderError", False, f"{type(exc).__name__}: {exc}")
# 2.2 未连接 clear 应为 no-op(不抛)
try:
await provider.clear_location()
check("未连接 clear_location 为 no-op", True)
except Exception as exc: # noqa: BLE001
check("未连接 clear_location 为 no-op", False, f"{type(exc).__name__}: {exc}")
# 2.3 未配对
try:
await RealLocationProvider().connect({"udid": "X", "state": "unpaired", "ios_major": 16})
check("未配对设备应拒绝连接", False, "没有抛异常")
except LocationProviderError as exc:
check("未配对设备报中文错误", has_chinese(str(exc)) and "信任" in str(exc), str(exc))
except Exception as exc: # noqa: BLE001
check("未配对设备报中文错误", False, f"{type(exc).__name__}: {exc}")
# 2.4 未开开发者模式
try:
await RealLocationProvider().connect({"udid": "X", "developer_mode": False, "ios_major": 16})
check("未开开发者模式应拒绝连接", False, "没有抛异常")
except LocationProviderError as exc:
check("未开开发者模式报中文错误", has_chinese(str(exc)) and "开发者模式" in str(exc), str(exc))
except Exception as exc: # noqa: BLE001
check("未开开发者模式报中文错误", False, f"{type(exc).__name__}: {exc}")
# 2.5 iOS 17+ 没有隧道
try:
await RealLocationProvider().connect(
{"udid": "X", "ios_version": "26.0", "ios_major": 26, "developer_mode": True, "state": "connected"}
)
check("iOS17+ 无隧道应拒绝连接", False, "没有抛异常")
except LocationProviderError as exc:
text = str(exc)
if installed:
check("iOS17+ 无隧道提示建隧道", has_chinese(text) and ("隧道" in text or "run.sh" in text), text)
else:
check("未装 pymobiledevice3 时提示安装", has_chinese(text) and "pymobiledevice3" in text, text)
except Exception as exc: # noqa: BLE001
check("iOS17+ 无隧道提示建隧道", False, f"{type(exc).__name__}: {exc}")
# 2.6 经纬度越界
provider_range = RealLocationProvider()
provider_range._connected = True # 仅用于测试参数校验分支
try:
await provider_range.set_location(123.0, 116.0)
check("越界纬度应拒绝", False, "没有抛异常")
except LocationProviderError as exc:
check("越界纬度报中文错误", has_chinese(str(exc)), str(exc))
except Exception as exc: # noqa: BLE001
check("越界纬度报中文错误", False, f"{type(exc).__name__}: {exc}")
# 2.7 probe 永不抛异常
probe = await RealLocationProvider(rsd_host=None, rsd_port=None).probe()
check(
"probe 返回 ok/detail/ios_major",
isinstance(probe, dict) and set(probe) >= {"ok", "detail", "ios_major"},
str(probe),
)
check("probe detail 为中文", has_chinese(str(probe.get("detail"))), str(probe.get("detail")))
probe_tunnel = await RealLocationProvider(rsd_host="127.0.0.1", rsd_port=1).probe()
check("probe(隧道不可达) 不抛异常且 ok=False", probe_tunnel.get("ok") is False, str(probe_tunnel.get("detail")))
check("probe(隧道不可达) 文案为中文", has_chinese(str(probe_tunnel.get("detail"))))
# 2.7b probe(device):带设备快照时做静态判断并回填 ios_major
probe_dev = await RealLocationProvider().probe({"ios_major": 26, "developer_mode": False, "udid": "X"})
check(
"probe(device) 未开开发者模式 → ok=False 且中文",
probe_dev.get("ok") is False and "开发者模式" in str(probe_dev.get("detail")),
str(probe_dev.get("detail")),
)
check("probe(device) 回填 ios_major=26", probe_dev.get("ios_major") == 26, str(probe_dev.get("ios_major")))
probe_dev17 = await RealLocationProvider().probe({"ios_major": 26, "developer_mode": True, "udid": "X"})
check(
"probe(device) iOS17+ 无隧道 → 提示建隧道",
probe_dev17.get("ok") is False and has_chinese(str(probe_dev17.get("detail"))),
str(probe_dev17.get("detail"))[:80],
)
# 2.8 close 幂等
fresh = RealLocationProvider()
await fresh.close()
await fresh.close()
check("close 幂等且 connected=False", fresh.connected is False)
# 2.9 真实 RSD 地址不可达时的中文错误
unreachable = RealLocationProvider(rsd_host="127.0.0.1", rsd_port=1, connect_timeout=3.0)
if installed:
try:
await unreachable.connect({"udid": "X", "ios_major": 26, "developer_mode": True})
check("不可达隧道应报错", False, "没有抛异常")
except LocationProviderError as exc:
check("不可达隧道报中文错误", has_chinese(str(exc)), str(exc)[:120])
except Exception as exc: # noqa: BLE001
check("不可达隧道报中文错误", False, f"{type(exc).__name__}: {exc}")
finally:
await unreachable.close()
class _MiniRunner:
"""``core/server.py`` 的 ``AsyncRunner`` 最小替身(后台线程 + 独立事件循环)。"""
def __init__(self) -> None:
self._loop = asyncio.new_event_loop()
self._ready = threading.Event()
self._thread = threading.Thread(target=self._run, name="selftest-runner", daemon=True)
self._thread.start()
self._ready.wait(5.0)
def _run(self) -> None:
asyncio.set_event_loop(self._loop)
self._ready.set()
self._loop.run_forever()
def run(self, coro, timeout: float = 30.0, wait: bool = True):
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
return future.result(timeout=timeout) if wait else future
def shutdown(self) -> None:
try:
self._loop.call_soon_threadsafe(self._loop.stop)
self._thread.join(timeout=3.0)
except Exception:
pass
def test_lead_integration() -> None:
"""照抄 core/server.py 的真实用法:关键字 log=、connect_sync(runner)、runner.run(coro)。"""
print("\n== 3. 与 lead 的 core/server.py 集成 ==")
runner = _MiniRunner()
logs: list[tuple[str, str]] = []
try:
mock = MockLocationProvider(log=lambda level, msg: logs.append((level, msg)))
info = mock.connect_sync(runner)
check("MockLocationProvider(log=...) 构造成功", isinstance(info, dict) and info.get("ok") is True)
check("connect_sync(runner) 建立会话", mock.connected is True)
check("log= 回调收到日志", len(logs) > 0, f"{len(logs)} 条")
runner.run(mock.set_location(39.908, 116.397))
check("set_location(lat, lon) 经 runner 调用成功", mock.send_count == 1)
runner.run(mock.clear_location())
runner.run(mock.close())
check("close 后 connected=False", mock.connected is False)
real = RealLocationProvider(rsd_host=None, rsd_port=None, udid="X", log=lambda level, msg: None)
check("RealLocationProvider(log=...) 构造成功", real.connected is False)
detail = runner.run(real.probe({"ios_major": 26, "developer_mode": False}))
check("probe(device) 经 runner 调用不抛异常", isinstance(detail, dict) and detail.get("ok") is False)
try:
real.connect_sync(runner, {"udid": "X", "ios_major": 26, "developer_mode": True})
check("connect_sync 无隧道时抛中文异常", False, "没有抛异常")
except LocationProviderError as exc:
check("connect_sync 无隧道时抛中文异常", has_chinese(str(exc)), str(exc)[:60])
runner.run(real.close())
finally:
runner.shutdown()
async def main_async() -> int:
print("=" * 68)
print("虚拟定位后端离线自测(无需 iOS 设备)")
print(f"Python {sys.version.split()[0]} | cwd={os.getcwd()}")
print("=" * 68)
await test_mock()
await test_real_degradation()
test_lead_integration()
print("\n" + "=" * 68)
print(f"通过 {len(PASSED)} 项,失败 {len(FAILED)} 项")
if FAILED:
for item in FAILED:
print(f" - {item}")
return 1
print("全部通过 ✅")
return 0
def main() -> int:
try:
return asyncio.run(main_async())
except KeyboardInterrupt:
return 130
except Exception: # noqa: BLE001
traceback.print_exc()
return 2
if __name__ == "__main__":
sys.exit(main())
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""验证打包后的 .app 能否**独立运行**(不依赖项目目录、不依赖 venv、不依赖系统 Python)。
检查项:
1. .app 结构完整(可执行文件、Info.plist、图标、前端资源)
2. 用干净环境启动(清空 PYTHON* 环境变量、不加载 .pth),端口能起来
3. 关键 API 可用:/api/health、/api/devices、/api/settings/tiles、/ 返回前端
4. 前端资源确实打包进去了(index.html / js / css 都能取到)
5. 瓦片代理可用(说明网络与缓存路径都对)
6. tunnel-helper 子命令可执行(真机连接依赖它;打包后不能再依赖 python -m pymobiledevice3)
7. 数据目录写在 ~/Library/Application Support/iOSLocationTrack(只读包外可写)
用法:./.venv/bin/python tools/verify_bundle.py
"""
from __future__ import annotations
import json
import os
import shutil
import signal
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
APP = ROOT / "dist" / "虚拟定位轨迹.app"
BIN = APP / "Contents" / "MacOS" / "iOSLocationTrack"
PORT = 8830
PASS = 0
FAIL = 0
FAILURES: list[str] = []
def check(name: str, ok: bool, detail: str = "") -> None:
global PASS, FAIL
if ok:
PASS += 1
print(f" \033[32m✓\033[0m {name}")
else:
FAIL += 1
FAILURES.append(f"{name} {detail}")
print(f" \033[31m✗\033[0m {name} {detail}")
def main() -> int:
print("\033[1m打包产物独立运行验证\033[0m")
if not APP.exists():
print(f" \033[31m✗ 未找到 {APP},请先运行 PyInstaller\033[0m")
return 1
print("\n1) 包结构")
check("可执行文件存在", BIN.exists(), str(BIN))
check("Info.plist 存在", (APP / "Contents" / "Info.plist").exists())
check("图标已嵌入", (APP / "Contents" / "Resources" / "AppIcon.icns").exists())
web = APP / "Contents" / "Frameworks" / "web"
check("前端资源已打包", (web / "index.html").exists(), str(web))
check("前端 JS 已打包", (web / "js" / "app.js").exists())
check("前端样式已打包", (web / "style.css").exists())
size_mb = sum(f.stat().st_size for f in APP.rglob("*") if f.is_file()) / 1048576
print(f" 包体积:{size_mb:.1f} MB")
print("\n2) Info.plist 关键字段")
try:
plist = subprocess.run(
["/usr/libexec/PlistBuddy", "-c", "Print", str(APP / "Contents" / "Info.plist")],
capture_output=True, text=True, timeout=10,
).stdout
check("包名正确", "虚拟定位轨迹" in plist)
check("Bundle ID 正确", "com.local.ios-location-track" in plist)
check("最低系统版本已声明", "LSMinimumSystemVersion" in plist)
except Exception as exc:
check("读取 Info.plist", False, str(exc)[:80])
print("\n3) 干净环境启动(不依赖 venv / 项目目录)")
env = {
"PATH": "/usr/bin:/bin:/usr/sbin:/sbin",
"HOME": os.path.expanduser("~"),
"LANG": "zh_CN.UTF-8",
"TMPDIR": os.environ.get("TMPDIR", "/tmp"),
# 明确清掉 Python 相关变量,证明不依赖外部环境
}
log = Path("/tmp/bundle-run.log")
proc = subprocess.Popen(
[str(BIN), "--mock", "--no-browser", "--window", "none", "--port", str(PORT)],
env=env, stdout=log.open("wb"), stderr=subprocess.STDOUT,
cwd="/tmp", # 故意用 /tmp 作为工作目录,验证不依赖项目目录
start_new_session=True,
)
base = f"http://127.0.0.1:{PORT}"
ready = False
deadline = time.time() + 60
while time.time() < deadline:
try:
with urllib.request.urlopen(base + "/api/health", timeout=2) as resp:
if resp.status == 200:
ready = True
break
except Exception:
time.sleep(0.5)
check("独立启动成功(不依赖 venv)", ready, f"见 {log}")
if not ready:
try:
print(" 启动日志:")
print(" ", log.read_text(encoding="utf-8", errors="replace")[-800:])
except Exception:
pass
proc.terminate()
print(f"\n通过 {PASS} 项,失败 {FAIL} 项")
return 1
try:
print("\n4) 关键接口")
with urllib.request.urlopen(base + "/api/health", timeout=5) as resp:
health = json.loads(resp.read())
check("GET /api/health", health.get("ok") is True, str(health)[:100])
check("运行在打包模式(无 venv)", health.get("pymobiledevice3") is True, str(health)[:120])
with urllib.request.urlopen(base + "/", timeout=5) as resp:
html = resp.read().decode("utf-8", "replace")
check("GET / 返回前端页面", "<html" in html.lower() and "虚拟定位" in html, "")
for path, label in [("/js/app.js", "app.js"), ("/js/map.js", "map.js"), ("/style.css", "style.css")]:
with urllib.request.urlopen(base + path, timeout=5) as resp:
body = resp.read()
check(f"静态资源 {label} 可取", resp.status == 200 and len(body) > 1000,
f"{resp.status} {len(body)}B")
with urllib.request.urlopen(base + "/api/devices", timeout=8) as resp:
devices = json.loads(resp.read())
check("GET /api/devices", "devices" in devices, str(devices)[:100])
with urllib.request.urlopen(base + "/api/settings/tiles", timeout=8) as resp:
tiles = json.loads(resp.read())
check("GET /api/settings/tiles", bool(tiles.get("sources")), "")
with urllib.request.urlopen(base + "/api/tiles/amap/12/3367/1580.png", timeout=25) as resp:
tile = resp.read()
check("瓦片代理可用", tile[:4] in (b"\x89PNG", b"\xff\xd8"), f"{len(tile)}B")
print("\n5) 数据目录(包外可写)")
data_dir = Path.home() / "Library" / "Application Support" / "iOSLocationTrack"
check("数据目录已创建", data_dir.is_dir(), str(data_dir))
check("config.json 已生成", (data_dir / "config.json").exists(), str(data_dir))
finally:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
time.sleep(2)
except Exception:
proc.terminate()
time.sleep(1)
if proc.poll() is None:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except Exception:
pass
print("\n6) tunnel-helper 子命令(真机连接依赖它)")
helper = subprocess.run(
[str(BIN), "tunnel-helper", "--udid", "NONEXISTENT-UDID"],
env=env, capture_output=True, text=True, timeout=90,
)
output = (helper.stdout or "") + (helper.stderr or "")
# 不存在的设备必然失败,但必须是"能跑起来、给出中文/英文错误",而不是崩溃或无此命令
check("tunnel-helper 可执行(非崩溃)",
helper.returncode in (0, 1, 2) and "Traceback" not in output,
f"rc={helper.returncode} {output.strip()[:120]}")
print(f"\n{'=' * 56}")
print(f"通过 \033[32m{PASS}\033[0m 项,失败 \033[31m{FAIL}\033[0m 项")
for item in FAILURES:
print(" -", item)
return 0 if FAIL == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())