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 分离等) - 验收基线(复用现有测试套件)与建议的工作顺序
180 lines
7.2 KiB
Python
180 lines
7.2 KiB
Python
#!/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())
|