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

+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())