Files
ios-location-track/core/setup_window.py
T
jbjf6666 4d55e2415f 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 → 两次下发 → 恢复真实定位 → 干净退出
2026-10-16 01:41:59 +08:00

607 lines
24 KiB
Python
Raw 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.
"""启动时的「环境准备」窗口(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