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:
commit
85e9a53750
65 files changed
+18603
No files matched your search
@@ -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>"
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user