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

180 lines
7.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""验证打包后的 .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())