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,284 @@
|
||||
#!/usr/bin/env python3
|
||||
"""iOS 虚拟定位轨迹工具 — 程序入口。
|
||||
|
||||
用法:
|
||||
./run.sh # 正常启动(自动打开界面)
|
||||
./.venv/bin/python main.py --mock # 无设备模拟模式(验收/演示用)
|
||||
./.venv/bin/python main.py --port 8899 --no-browser
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
|
||||
def _resolve_roots() -> tuple[Path, Path]:
|
||||
"""返回 (资源根, 数据根)。
|
||||
|
||||
- 源码运行:两者都是项目目录
|
||||
- 打包成 .app:资源在 sys._MEIPASS(只读),数据写到
|
||||
~/Library/Application Support/iOSLocationTrack
|
||||
"""
|
||||
if getattr(sys, "frozen", False):
|
||||
resources = Path(getattr(sys, "_MEIPASS", Path(sys.executable).parent))
|
||||
data = Path.home() / "Library" / "Application Support" / "iOSLocationTrack"
|
||||
data.mkdir(parents=True, exist_ok=True)
|
||||
return resources, data
|
||||
root = Path(__file__).resolve().parent
|
||||
return root, root
|
||||
|
||||
|
||||
ROOT, DATA_ROOT = _resolve_roots()
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from core import device as device_mod # noqa: E402
|
||||
from core import protocol, server # noqa: E402
|
||||
|
||||
BANNER = r"""
|
||||
___ ___ ___ _ _ _
|
||||
|_ _|/ __| | _ \_____ __ _____ _ _ (_)__ _| |_(_)___ _ _
|
||||
| | \__ \ | _/ _ \ V V / -_) ' \| / _` | _| / _ \ ' \
|
||||
|___||___/ |_| \___/\_/\_/\___|_||_|_\__,_|\__|_\___/_||_|
|
||||
"""
|
||||
|
||||
APP_MODE_BROWSERS = [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
||||
]
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="ios-location",
|
||||
description=f"{protocol.APP_NAME} v{protocol.APP_VERSION}",
|
||||
)
|
||||
parser.add_argument("--host", default=protocol.DEFAULT_HOST, help="监听地址(默认 127.0.0.1,仅本机可访问)")
|
||||
parser.add_argument("--port", type=int, default=protocol.DEFAULT_PORT, help=f"监听端口(默认 {protocol.DEFAULT_PORT})")
|
||||
parser.add_argument("--mock", action="store_true", help="无设备模拟模式:不连真机,用于界面演示与验收")
|
||||
parser.add_argument("--no-browser", action="store_true", help="启动后不自动打开界面")
|
||||
parser.add_argument("--window", choices=["app", "browser", "none"], default="app",
|
||||
help="界面打开方式:app=Chrome/Edge 应用窗口(默认,最像原生),browser=默认浏览器,none=不打开")
|
||||
# 内部助手模式:`<可执行文件> tunnel-helper --udid XXX` 建立 RSD 隧道并打印 HOST PORT
|
||||
parser.add_argument("command", nargs="?", default=None,
|
||||
help=argparse.SUPPRESS)
|
||||
parser.add_argument("--udid", default=None,
|
||||
help=argparse.SUPPRESS) # 供 tunnel-helper 指定设备
|
||||
parser.add_argument("--version", action="version", version=f"{protocol.APP_NAME} {protocol.APP_VERSION}")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def run_tunnel_helper(udid: str | None) -> int:
|
||||
"""建立 iOS 17+ 的 RSD 隧道并保持进程存活,向 stdout 打印「HOST PORT」。
|
||||
|
||||
打包成 .app 后不能再调用 `python -m pymobiledevice3`,因此把助手模式做进本程序:
|
||||
主进程用 `sys.executable tunnel-helper` 递归启动自己(见 core/tunnel.py)。
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
from pymobiledevice3.remote.native_tunnel import establish_native_rsd
|
||||
except Exception as exc: # pragma: no cover
|
||||
print(f"无法加载隧道模块:{exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
async def main_async() -> int:
|
||||
try:
|
||||
rsd = await establish_native_rsd(serial=udid)
|
||||
except Exception as exc:
|
||||
print(f"建立隧道失败:{exc}", file=sys.stderr)
|
||||
return 1
|
||||
address, port = rsd.service.address
|
||||
# 关键:这一行会被 core/tunnel.py 解析出 RSD 地址。
|
||||
# 打包成 .app 时若以"无控制台"方式启动,sys.stdout 可能是 None,
|
||||
# 此时打印会抛 AttributeError,导致隧道地址传不出去 → 真机连不上。
|
||||
# 因此 stdout 与 stderr 都写一份(父进程两者都会读取)。
|
||||
line = f"{address} {port}"
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
if stream is None:
|
||||
continue
|
||||
try:
|
||||
stream.write(line + "\n")
|
||||
stream.flush()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await asyncio.Event().wait() # 持有隧道断言直到进程结束
|
||||
except asyncio.CancelledError:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
try:
|
||||
return asyncio.run(main_async())
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
|
||||
|
||||
def port_in_use(host: str, port: int) -> bool:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(0.6)
|
||||
return sock.connect_ex((host, port)) == 0
|
||||
|
||||
|
||||
def wait_health(url: str, timeout: float = 20.0) -> bool:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(url + "/api/health", timeout=1.5) as response:
|
||||
if response.status == 200:
|
||||
return True
|
||||
except Exception:
|
||||
time.sleep(0.3)
|
||||
return False
|
||||
|
||||
|
||||
def open_ui(url: str, mode: str) -> None:
|
||||
if mode == "none":
|
||||
return
|
||||
if mode == "app":
|
||||
for binary in APP_MODE_BROWSERS:
|
||||
if os.path.exists(binary):
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
subprocess.Popen(
|
||||
[binary, f"--app={url}", "--window-size=1440,900",
|
||||
"--user-data-dir=" + str(Path.home() / ".ios-location-appwindow")],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
webbrowser.open(url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def find_free_port(host: str, preferred: int) -> int:
|
||||
for candidate in range(preferred, preferred + 20):
|
||||
if not port_in_use(host, candidate):
|
||||
return candidate
|
||||
return preferred
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
if args.command == "tunnel-helper":
|
||||
# 内部助手模式:由 TunnelManager 递归调用,只负责建隧道并打印 HOST PORT
|
||||
return run_tunnel_helper(args.udid)
|
||||
mock = bool(args.mock or os.environ.get("IOSLOC_MOCK") == "1")
|
||||
host = args.host
|
||||
url_host = "127.0.0.1" if host in ("0.0.0.0", "::") else host
|
||||
|
||||
if port_in_use(host, args.port):
|
||||
# 已有实例在跑:直接复用,避免双开互相抢 RSD 连接
|
||||
url = f"http://{url_host}:{args.port}"
|
||||
if wait_health(url, timeout=3.0):
|
||||
print(f"检测到已有一个实例在运行:{url}")
|
||||
open_ui(url, args.window)
|
||||
return 0
|
||||
args.port = find_free_port(host, args.port + 1)
|
||||
|
||||
print(BANNER)
|
||||
print(f" {protocol.APP_NAME} v{protocol.APP_VERSION}")
|
||||
print(f" Python {sys.version.split()[0]} | 数据目录 {DATA_ROOT}")
|
||||
if mock:
|
||||
print(" 模式:无设备模拟(--mock),不会连接真机")
|
||||
else:
|
||||
print(f" pymobiledevice3:{'已就绪' if device_mod.pymobiledevice3_importable() else '未安装(将使用 libimobiledevice 兜底,请运行 ./run.sh 自动安装)'}")
|
||||
|
||||
app = server.App(root=DATA_ROOT, mock=mock, port=args.port)
|
||||
app.log("info", f"{protocol.APP_NAME} 启动,端口 {args.port}")
|
||||
# 关键依赖自检:打包成 .app 后若漏打模块,这里会立刻暴露,
|
||||
# 而不是等到"设备发现莫名退化成 libimobiledevice"才发现。
|
||||
missing = []
|
||||
for module in (
|
||||
"pymobiledevice3",
|
||||
"pymobiledevice3.lockdown",
|
||||
"pymobiledevice3.usbmux",
|
||||
"pymobiledevice3.remote.native_tunnel",
|
||||
"pymobiledevice3.remote.remote_service_discovery",
|
||||
"pymobiledevice3.services.dvt.instruments.dvt_provider",
|
||||
"pymobiledevice3.services.dvt.instruments.location_simulation",
|
||||
):
|
||||
try:
|
||||
__import__(module)
|
||||
except Exception as exc:
|
||||
missing.append(f"{module}({exc})")
|
||||
if missing:
|
||||
app.log("error", "关键模块缺失,真机功能将不可用:" + ";".join(missing))
|
||||
elif not mock:
|
||||
app.log("info", "关键模块自检通过(pymobiledevice3 / RSD 隧道 / DVT 定位)")
|
||||
if getattr(app.config, "_repaired", False):
|
||||
app.log("warn", "config.json 中有非法项(类型/范围不合法),已自动回退为默认值")
|
||||
|
||||
# 兜底清理:无论怎么退出(SIGINT/SIGTERM/异常),都不能把隧道子进程留在后台,
|
||||
# 否则 iOS 17+ 的单条 RSD 连接会被残留进程占住,下一次定位会失败。
|
||||
import atexit
|
||||
import signal
|
||||
|
||||
atexit.register(app.shutdown)
|
||||
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
||||
try:
|
||||
signal.signal(sig, lambda *_a: app.shutdown_event.set())
|
||||
except (ValueError, OSError, AttributeError):
|
||||
pass
|
||||
if mock:
|
||||
app.log("warn", "当前为模拟模式:定位不会真正下发到设备")
|
||||
app.devices.refresh()
|
||||
app.devices.start_monitor()
|
||||
last_device = app.config.get("last_device")
|
||||
if last_device and app.devices.get(last_device):
|
||||
try:
|
||||
app.devices.select(last_device)
|
||||
except Exception:
|
||||
pass
|
||||
devices = app.devices.list_devices()
|
||||
if devices:
|
||||
for device in devices:
|
||||
app.log("info", f"发现设备:{device.name}(iOS {device.ios_version or '未知'},{device.udid})")
|
||||
elif not mock:
|
||||
app.log("warn", "暂未检测到 iOS 设备:请用数据线连接、解锁并在手机上点「信任」")
|
||||
|
||||
httpd = server.serve(app, host=host, port=args.port)
|
||||
url = f"http://{url_host}:{args.port}"
|
||||
print(f" 界面地址:{url}")
|
||||
print(" 按 Ctrl+C 退出\n")
|
||||
|
||||
thread = threading.Thread(target=httpd.serve_forever, name="http", daemon=True)
|
||||
thread.start()
|
||||
|
||||
if not args.no_browser and args.window != "none":
|
||||
def _open() -> None:
|
||||
if wait_health(url, timeout=15.0):
|
||||
open_ui(url, args.window)
|
||||
|
||||
threading.Thread(target=_open, name="open-ui", daemon=True).start()
|
||||
|
||||
try:
|
||||
while not app.shutdown_event.is_set():
|
||||
app.shutdown_event.wait(0.5)
|
||||
except KeyboardInterrupt:
|
||||
print("\n收到退出信号,正在关闭…")
|
||||
finally:
|
||||
app.log("info", "正在关闭:停止模拟并释放隧道")
|
||||
app.shutdown()
|
||||
try:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
except Exception:
|
||||
pass
|
||||
print("已退出。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user