feat(windows): 新增 Windows 移植(免管理员隧道 + 自动装 Apple 驱动 + 环境自检)

按 docs/WINDOWS_PORT.md 完成 Windows 移植,并修正该文档中若干与实测不符之处。
macOS 构建链完整保留,单一代码库双平台构建。

核心:iOS 17+ RSD 隧道在 Windows 上换了实现方式
- macOS 借 Apple remoted(--native,内核可路由),Windows 改走 UserspaceRsdTunnel
  (纯 Python PyTCP 用户态协议栈)
- 实测:不需要管理员权限、不需要 Wintun 驱动(pytun_pmd3 自带 wintun.dll)、
  建立耗时约 0.3 秒
- 关键坑:用户态隧道的设备地址**只在本进程可达**。拿 (host, port) 另建 RSD 会得到
  OSError [WinError 1231] 网络位置不可达。必须把 aopen() 返回的 rsd 对象本身注入
  DVT 层(RealLocationProvider 早有 rsd= 注入口),因此 Windows 不能沿用
  「隧道助手子进程 + 解析 HOST PORT」那套架构
- 修正 WINDOWS_PORT.md 的错误:类名是 UserspaceRsdTunnel 不是 UserspaceTunnel;
  remote start-tunnel 没有 --userspace;Wintun 不是必需项

新增
- core/platform.py      平台抽象层(数据目录/浏览器探测/进程管理/提权/信号)
- core/apple_driver.py  Apple Mobile Device Support 检测与自动安装
                        (服务启动 → 独立 AMDS MSI → 完整 iTunes 包 → winget 逐级降级)
                        + 开发者镜像下载
- core/setup_window.py  启动时的「环境准备」窗口:逐项列出依赖,缺失项右侧可点安装,
                        必需项全部就绪后「进入程序」才可点击
- scripts/run.ps1, build.ps1, installer/iosloc.iss, installer/version_info.txt
- tools/make_ico.py(由已有 PNG 图标集生成多尺寸 .ico)
- tools/probe_tunnel_win.py(隧道可行性诊断)
- tests/win_e2e.py(7 项)、tests/win_app_e2e.py(9 项)真机验收
- docs/README-WINDOWS.md
- .gitattributes(避免 Windows 写出 CRLF 的 .sh 把 macOS 构建搞坏)

修改
- core/tunnel.py            Windows 走进程内 userspace 隧道;macOS 路径不变
- core/server.py            TunnelManager 注入 runner;provider_for 注入 rsd 对象;
                            资源目录与数据目录分离;日志落盘
- core/location_provider.py 平台化错误提示;新增保活(隧道静默断开会让设备
                            偷偷恢复真实定位,停止播放时每 15s 重发)
- main.py                   「环境准备」窗口、Apple 驱动后台检查、
                            --check-driver / --install-driver、跨平台浏览器与信号
- Location.spec             平台分支;Windows 用 .ico + uac_admin=True(启动即请求管理员)
                            + version_info.txt;单一 spec 不分叉
- tests/drift_sim.py / offline_tiles.py  去掉硬编码 /tmp,改用 tempfile.gettempdir()

打包
- exe 清单写入 requireAdministrator(用户要求:启动时直接请求管理员权限)
- 绿色版 dist\iOSLocationTrack\iOSLocationTrack.exe
- 安装包 dist\installer\iOSLocationTrack-1.0.0-setup.exe(Inno Setup)

实测(Windows 11 x64 / Python 3.12.14 / pymobiledevice3 11.31.0 / iPhone 11 iOS 26.6.1)
- 隧道→镜像→DVT→下发定位→恢复真实定位 全链路跑通,连接 0.8s
- selftest 83/83、drift_sim 40/40、offline_tiles 13/13
- win_e2e 7/7、win_app_e2e 9/9
- 打包版 exe:发现设备 → 0.3s 隧道 → DVT → 两次下发 → 恢复真实定位 → 干净退出
This commit is contained in:
jbjf6666 committed 2026-10-16 01:41:59 +08:00
1 parent 85e9a53750
commit 4d55e2415f
26 files changed
+4515 -306

No files matched your search

+606
View File
@@ -0,0 +1,606 @@
"""启动时的「环境准备」窗口(Windows)。
需求:每次打开 exe 都弹出一个窗口,列出所有需要的依赖:
- 没装的在右侧有「安装」按钮
- 装好的显示 ✓
- 必需项全部就绪后,「进入程序」按钮才可点击
设计要点:
- 用标准库 **tkinter**(已随 Python 打包进 exe),因此不依赖机器上有浏览器/网络,
也不依赖我们已经启动的 HTTP 服务 —— 它必须能作为"第一个界面"独立工作。
- 检测与安装都放在后台线程,通过队列回传 UI 线程(tkinter 不是线程安全的)。
- 任何异常都不能让程序打不开:tkinter 不可用时返回 None,由调用方降级处理。
"""
from __future__ import annotations
import queue
import threading
from pathlib import Path
from typing import Any, Callable
from . import apple_driver
from . import platform as plat
# 颜色(与 web 端保持同一套语义色)
COLOR_OK = "#1a7f37"
COLOR_BAD = "#c0392b"
COLOR_BUSY = "#b7791f"
COLOR_IDLE = "#6b7280"
COLOR_BG = "#ffffff"
COLOR_MUTED = "#57606a"
ICON_OK = "✓"
ICON_BAD = "✗"
ICON_BUSY = "…"
ICON_OPT = "·"
ICON_IDLE = "○"
class EnvItem:
"""一个待检查/待安装的依赖项。"""
def __init__(
self,
key: str,
label: str,
description: str,
check: Callable[[], tuple[bool, str]],
install: Callable[[Callable[[str, str], None]], tuple[bool, str]] | None = None,
required: bool = True,
install_label: str = "安装",
size_hint: str = "",
) -> None:
self.key = key
self.label = label
self.description = description
self.check = check
self.install = install
self.required = required
self.install_label = install_label
self.size_hint = size_hint
# ---------------------------------------------------------------------------
# 各个依赖项的检测 / 安装实现
# ---------------------------------------------------------------------------
def _check_python() -> tuple[bool, str]:
import sys
return True, f"已内置 Python {sys.version.split()[0]}"
def _check_pymobiledevice3() -> tuple[bool, str]:
try:
import pymobiledevice3
version = getattr(pymobiledevice3, "__version__", None) or "已安装"
return True, f"已内置 pymobiledevice3 {version}"
except Exception as exc:
return False, f"导入失败:{exc}"
def _check_tunnel_deps() -> tuple[bool, str]:
"""userspace 隧道依赖(纯 Python 协议栈,**不需要 Wintun 驱动**)。"""
missing = []
for module in ("pytun_pmd3", "pmd_pytcp", "pmd_net_addr"):
try:
__import__(module)
except Exception:
missing.append(module)
if missing:
return False, "缺少:" + "、".join(missing)
try:
from pymobiledevice3.remote.userspace_tunnel import UserspaceRsdTunnel # noqa: F401
return True, "已内置(纯 Python 用户态协议栈,无需 Wintun 驱动、无需管理员权限)"
except Exception as exc:
return False, f"隧道模块导入失败:{exc}"
def _check_apple_driver() -> tuple[bool, str]:
status = apple_driver.status(deep=True)
if not status.get("applicable", True):
return True, "非 Windows 平台,系统自带 usbmuxd"
if status.get("ready"):
product = status.get("product") or {}
version = product.get("displayversion")
devices = (status.get("devices") or {}).get("count", 0)
detail = "usbmuxd 可通信"
if version:
detail = f"v{version},{detail}"
if devices:
detail += f",已发现 {devices} 台设备"
return True, detail
return False, status.get("summary") or "不可用"
def _install_apple_driver(log: Callable[[str, str], None]) -> tuple[bool, str]:
if not plat.is_admin():
return False, "需要管理员权限(本程序启动时应已提权)"
result = apple_driver.ensure(log=log, allow_install=True)
if result.get("ok"):
if result.get("reboot_required"):
return True, "安装完成,但需要重启 Windows 才生效"
return True, "安装完成"
return False, result.get("error") or "安装失败"
def _check_developer_image() -> tuple[bool, str]:
from . import apple_driver as ad
status = ad.ddi_status()
if status["ok"]:
return True, f"已就绪({status['total_mb']} MB,iOS 17 及以上全系共用)"
return False, "缺少:" + "、".join(status["missing"])
def _install_developer_image(log: Callable[[str, str], None]) -> tuple[bool, str]:
result = apple_driver.download_developer_image(log=log)
if result.get("ok"):
return True, result.get("detail") or "下载完成"
return False, result.get("error") or "下载失败"
def _check_permission() -> tuple[bool, str]:
if plat.is_admin():
return True, "已以管理员身份运行"
return False, "未提权(安装 Apple 驱动和管理员操作会失败)"
def _check_device() -> tuple[bool, str]:
if not plat.IS_WINDOWS:
return True, "非 Windows 平台"
result = apple_driver.list_devices()
if not result.get("ok"):
return False, str(result.get("error") or "usbmuxd 不可用")
count = result.get("count", 0)
if count == 0:
return False, "未检测到 iPhone(用数据线连接、解锁并点「信任」)"
first = result["devices"][0]
return True, f"{count} 台({first.get('serial', '')},{first.get('connection', '?')})"
def _check_network() -> tuple[bool, str]:
"""Apple TSS 签名需要联网(挂载开发者镜像时用)。"""
import socket
hosts = [("gs.apple.com", 443), ("raw.githubusercontent.com", 443)]
reachable = []
for host, port in hosts:
try:
with socket.create_connection((host, port), timeout=6):
reachable.append(host)
except OSError:
continue
if reachable:
return True, "可连通(" + "、".join(reachable) + ")"
return False, "无法连接 Apple 服务器(挂载开发者镜像时需要联网签名)"
def build_items() -> list[EnvItem]:
items = [
EnvItem("python", "Python 运行时", "程序自带的解释器",
_check_python, None, required=True),
EnvItem("pymobiledevice3", "pymobiledevice3", "与 iOS 设备通信的核心库",
_check_pymobiledevice3, None, required=True),
]
if plat.IS_WINDOWS:
items.append(EnvItem(
"apple_driver", "Apple Mobile Device Support",
"Windows 上的 usbmuxd(127.0.0.1:27015),用来发现 iPhone",
_check_apple_driver, _install_apple_driver, required=True,
install_label="安装", size_hint="约 40~200 MB",
))
items.append(EnvItem(
"tunnel_deps", "RSD 隧道依赖",
"iOS 17+ 的用户态隧道(PyTCP)",
_check_tunnel_deps, None, required=True,
))
items.append(EnvItem(
"developer_image", "开发者镜像",
"iOS 17 及以上全系共用的个性化镜像素材",
_check_developer_image, _install_developer_image, required=True,
install_label="下载", size_hint="约 15 MB",
))
items.append(EnvItem(
"permission", "管理员权限",
"安装驱动与系统级操作需要",
_check_permission, None, required=True,
))
items.append(EnvItem(
"device", "iPhone 连接",
"可选项:现在没插也可以,之后接上再点「重新检测」",
_check_device, None, required=False,
))
items.append(EnvItem(
"network", "网络连通",
"可选项:挂载开发者镜像时 Apple 需要在线签名",
_check_network, None, required=False,
))
return items
# ---------------------------------------------------------------------------
# 窗口
# ---------------------------------------------------------------------------
class SetupWindow:
"""环境准备窗口。所有耗时操作都在后台线程里跑。"""
def __init__(self, root: Any, items: list[EnvItem] | None = None,
title: str = "环境准备") -> None:
import tkinter as tk
from tkinter import ttk
self.tk = tk
self.ttk = ttk
self.root = root
self.items = items if items is not None else build_items()
self.state: dict[str, dict[str, Any]] = {
item.key: {"ok": None, "detail": "等待检测…", "busy": False}
for item in self.items
}
self.events: queue.Queue[tuple[str, Any]] = queue.Queue()
self.installing = False
self.proceeded = False
self.auto_install = False
self._rows: dict[str, dict[str, Any]] = {}
root.title(f"{title} — iOS 虚拟定位轨迹工具")
root.configure(bg=COLOR_BG)
# 先建内容再定尺寸:否则 Tk 会按控件的"要求尺寸"把窗口撑高,
# geometry() 设的高度会被覆盖,底部的「进入程序」按钮就被挤出屏幕。
self._build(title)
root.resizable(False, True)
self._fit_to_screen(root)
root.protocol("WM_DELETE_WINDOW", self._on_close)
root.after(80, self._pump)
self.refresh_all()
# ---------------- 布局 ----------------
@staticmethod
def _fit_to_screen(window: Any, min_width: int = 820, min_height: int = 560) -> None:
"""按内容实际需要的高度定尺寸,并保证窗口完整落在屏幕内。"""
window.update_idletasks()
try:
screen_w = window.winfo_screenwidth()
screen_h = window.winfo_screenheight()
# 控件"要求"的尺寸 + 一点余量
need_w = max(min_width, window.winfo_reqwidth() + 24)
need_h = window.winfo_reqheight() + 24
width = min(need_w, max(640, screen_w - 60))
height = min(need_h, max(min_height, screen_h - 100))
x = max(0, (screen_w - width) // 2)
y = max(0, (screen_h - height) // 3)
window.geometry(f"{width}x{height}+{x}+{y}")
window.minsize(min(760, width), min(520, height))
except Exception:
window.geometry("960x760")
def _build(self, title: str) -> None:
tk, ttk = self.tk, self.ttk
style = ttk.Style()
try:
style.theme_use("vista")
except Exception:
pass
header = tk.Frame(self.root, bg=COLOR_BG)
header.pack(fill="x", padx=20, pady=(16, 6))
tk.Label(header, text=title, font=("Microsoft YaHei UI", 15, "bold"),
bg=COLOR_BG).pack(anchor="w")
tk.Label(
header,
text="首次使用需要准备以下组件。缺失的项目点右侧按钮即可自动安装;"
"全部就绪后点「进入程序」。",
font=("Microsoft YaHei UI", 9), fg=COLOR_MUTED, bg=COLOR_BG,
justify="left",
).pack(anchor="w", pady=(4, 0))
ttk.Separator(self.root).pack(fill="x", padx=20, pady=(10, 4))
body = tk.Frame(self.root, bg=COLOR_BG)
body.pack(fill="both", expand=False, padx=20)
body.columnconfigure(1, weight=1)
for row, item in enumerate(self.items):
icon = tk.Label(body, text=ICON_IDLE, font=("Segoe UI Symbol", 13, "bold"),
fg=COLOR_IDLE, bg=COLOR_BG, width=2)
icon.grid(row=row, column=0, sticky="nw", pady=(6, 0))
text = tk.Frame(body, bg=COLOR_BG)
text.grid(row=row, column=1, sticky="ew", padx=(2, 10), pady=(6, 0))
name = tk.Label(text, text=item.label, font=("Microsoft YaHei UI", 10, "bold"),
bg=COLOR_BG, anchor="w")
name.pack(anchor="w")
detail = tk.Label(text, text=item.description, font=("Microsoft YaHei UI", 8),
fg=COLOR_MUTED, bg=COLOR_BG, anchor="w", justify="left",
wraplength=560)
detail.pack(anchor="w")
button = ttk.Button(body, text=item.install_label, width=10,
command=lambda i=item: self.install_one(i))
button.grid(row=row, column=2, sticky="ne", pady=(6, 0))
if item.install is None:
button.state(["disabled"])
tag = tk.Label(body, text="必需" if item.required else "可选",
font=("Microsoft YaHei UI", 8),
fg=COLOR_BAD if item.required else COLOR_MUTED,
bg=COLOR_BG, width=5)
tag.grid(row=row, column=3, sticky="ne", pady=(6, 0))
self._rows[item.key] = {
"icon": icon, "detail": detail, "button": button, "item": item
}
ttk.Separator(self.root).pack(fill="x", padx=20, pady=(12, 6))
log_frame = tk.Frame(self.root, bg=COLOR_BG)
log_frame.pack(fill="both", expand=True, padx=20)
tk.Label(log_frame, text="安装日志", font=("Microsoft YaHei UI", 9, "bold"),
bg=COLOR_BG).pack(anchor="w")
self.log = tk.Text(log_frame, height=6, wrap="word", font=("Consolas", 8),
bg="#f6f8fa", fg="#24292f", relief="flat",
highlightthickness=1, highlightbackground="#d0d7de")
scroll = ttk.Scrollbar(log_frame, command=self.log.yview)
self.log.configure(yscrollcommand=scroll.set)
scroll.pack(side="right", fill="y")
self.log.pack(side="left", fill="both", expand=True, pady=(3, 0))
self.log.configure(state="disabled")
footer = tk.Frame(self.root, bg=COLOR_BG)
footer.pack(fill="x", padx=20, pady=(10, 16))
self.status_label = tk.Label(footer, text="正在检测…",
font=("Microsoft YaHei UI", 9), fg=COLOR_MUTED,
bg=COLOR_BG)
self.status_label.pack(side="left")
self.confirm_button = ttk.Button(footer, text="进入程序", width=16,
command=self._on_confirm)
self.confirm_button.pack(side="right", padx=(8, 0))
self.confirm_button.state(["disabled"])
self.install_all_button = ttk.Button(footer, text="全部安装", width=12,
command=self.install_all)
self.install_all_button.pack(side="right", padx=(8, 0))
self.recheck_button = ttk.Button(footer, text="重新检测", width=12,
command=self.refresh_all)
self.recheck_button.pack(side="right")
# ---------------- 日志 ----------------
def append_log(self, text: str) -> None:
self.log.configure(state="normal")
self.log.insert("end", text.rstrip() + "\n")
self.log.see("end")
self.log.configure(state="disabled")
# ---------------- 检测 ----------------
def refresh_all(self) -> None:
if self.installing:
return
for item in self.items:
self.state[item.key] = {"ok": None, "detail": "检测中…", "busy": False}
self._render(item.key)
self._update_footer()
threading.Thread(target=self._check_worker, name="env-check", daemon=True).start()
def _check_worker(self) -> None:
for item in self.items:
try:
ok, detail = item.check()
except Exception as exc:
ok, detail = False, f"检测异常:{exc}"
self.events.put(("check", (item.key, ok, detail)))
self.events.put(("checks_done", None))
# ---------------- 安装 ----------------
def install_one(self, item: EnvItem) -> None:
if self.installing or item.install is None:
return
state = self.state[item.key]
if state.get("ok") is True:
return
self.installing = True
self.install_all_button.state(["disabled"])
self.recheck_button.state(["disabled"])
self._set_busy(item.key, "正在安装…")
self.append_log(f"===== 开始安装:{item.label} =====")
threading.Thread(target=self._install_worker, args=([item],),
name=f"install-{item.key}", daemon=True).start()
def install_all(self) -> None:
if self.installing:
return
pending = [i for i in self.items
if i.install is not None and self.state[i.key].get("ok") is not True]
if not pending:
self.append_log("没有需要安装的项目。")
return
self.installing = True
self.install_all_button.state(["disabled"])
self.recheck_button.state(["disabled"])
for item in pending:
self._set_busy(item.key, "排队中…")
threading.Thread(target=self._install_worker, args=(pending,),
name="install-all", daemon=True).start()
def _install_worker(self, items: list[EnvItem]) -> None:
for item in items:
self.events.put(("busy", (item.key, "正在安装…")))
def log(level: str, message: str, _label: str = item.label) -> None:
self.events.put(("log", f"[{_label}] {message}"))
try:
assert item.install is not None
ok, detail = item.install(log)
except Exception as exc:
ok, detail = False, f"安装异常:{exc}"
self.events.put(("install", (item.key, ok, detail)))
self.events.put(("installs_done", None))
# ---------------- UI 线程泵 ----------------
def _pump(self) -> None:
try:
while True:
kind, payload = self.events.get_nowait()
if kind == "check":
key, ok, detail = payload
self.state[key] = {"ok": ok, "detail": detail, "busy": False}
self._render(key)
elif kind == "checks_done":
self._update_footer()
elif kind == "busy":
key, detail = payload
self.state[key]["busy"] = True
self.state[key]["detail"] = detail
self._render(key)
elif kind == "install":
key, ok, detail = payload
self.state[key] = {"ok": ok, "detail": detail, "busy": False}
self._render(key)
self.append_log(("安装成功:" if ok else "安装失败:") + detail)
elif kind == "installs_done":
self.installing = False
self.install_all_button.state(["!disabled"])
self.recheck_button.state(["!disabled"])
self._update_footer()
# 安装后可能影响其它项(例如驱动装好后设备就能发现了),重新检测一遍
self.refresh_all()
elif kind == "log":
self.append_log(payload)
except queue.Empty:
pass
if not self.proceeded:
self.root.after(80, self._pump)
# ---------------- 渲染 ----------------
def _render(self, key: str) -> None:
row = self._rows.get(key)
if row is None:
return
state = self.state[key]
item: EnvItem = row["item"]
ok = state.get("ok")
busy = state.get("busy")
if busy:
icon, color = ICON_BUSY, COLOR_BUSY
elif ok is True:
icon, color = ICON_OK, COLOR_OK
elif ok is False:
icon = ICON_BAD if item.required else ICON_OPT
color = COLOR_BAD if item.required else COLOR_MUTED
else:
icon, color = ICON_IDLE, COLOR_IDLE
row["icon"].configure(text=icon, fg=color)
suffix = f" ({item.size_hint})" if item.size_hint and ok is not True else ""
row["detail"].configure(text=f"{state.get('detail', '')}{suffix}",
fg=COLOR_OK if ok is True else COLOR_MUTED)
button = row["button"]
if ok is True:
# 已就绪:按钮文字改成"已就绪"并禁用,避免用户误以为还要再装一次
button.configure(text="已就绪")
button.state(["disabled"])
elif item.install is None:
button.configure(text="—")
button.state(["disabled"])
elif busy:
button.configure(text="安装中…")
button.state(["disabled"])
else:
button.configure(text=item.install_label)
button.state(["!disabled"])
def _set_busy(self, key: str, detail: str) -> None:
self.state[key]["busy"] = True
self.state[key]["detail"] = detail
self._render(key)
def _update_footer(self) -> None:
required = [i for i in self.items if i.required]
self.state.get("__x__")
pending = [i for i in required if self.state[i.key].get("ok") is not True]
optional_bad = [i for i in self.items
if not i.required and self.state[i.key].get("ok") is False]
if self.installing:
self.status_label.configure(text="正在安装,请稍候…", fg=COLOR_BUSY)
elif pending:
names = "、".join(i.label for i in pending)
self.status_label.configure(text=f"还差 {len(pending)} 项:{names}", fg=COLOR_BAD)
else:
text = "全部就绪,可以开始使用"
if optional_bad:
text += "(可选:" + "、".join(i.label for i in optional_bad) + " 未就绪)"
self.status_label.configure(text=text, fg=COLOR_OK)
if not pending and not self.installing:
self.confirm_button.state(["!disabled"])
else:
self.confirm_button.state(["disabled"])
any_missing = any(i.install is not None and self.state[i.key].get("ok") is not True
for i in self.items)
if any_missing and not self.installing:
self.install_all_button.state(["!disabled"])
else:
self.install_all_button.state(["disabled"])
# ---------------- 结束 ----------------
def _on_confirm(self) -> None:
self.proceeded = True
try:
self.root.destroy()
except Exception:
pass
def _on_close(self) -> None:
self.proceeded = False
try:
self.root.destroy()
except Exception:
pass
def run_setup_window(title: str = "环境准备", auto_install_missing: bool = False) -> bool | None:
"""显示环境准备窗口。
返回:
True —— 全部必需项就绪,用户点了「进入程序」
False —— 用户关闭了窗口(调用方应退出)
None —— 无法显示窗口(tkinter 不可用/构建失败),调用方自行降级
"""
import traceback
try:
import tkinter as tk
except Exception as exc:
print(f" [提示] tkinter 不可用:{exc}")
return None
try:
root = tk.Tk()
except Exception as exc:
print(f" [提示] 无法创建窗口(可能没有图形界面):{exc}")
return None
try:
window = SetupWindow(root, title=title)
if auto_install_missing:
root.after(600, window.install_all)
root.mainloop()
return bool(window.proceeded)
except Exception:
# 这里必须把真实原因打出来,否则窗口不显示时无从排查
print(" [错误] 「环境准备」窗口构建失败:")
traceback.print_exc()
try:
root.destroy()
except Exception:
pass
return None