"""启动时的「环境准备」窗口(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 from . import protocol # 颜色(与 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 _install_permission(log: Callable[[str, str], None]) -> tuple[bool, str]: """「安装」管理员权限 = 以管理员身份重新启动本程序。 之前这一项没有安装动作,导致**非管理员用户永远无法点「进入程序」**(死锁)。 现在点这个按钮会通过 UAC 拉起一个提权的新实例,本实例随即退出。 """ if plat.is_admin(): return True, "已经是管理员身份" log("info", "正在通过 UAC 以管理员身份重新启动程序…") launched = plat.relaunch_elevated() if launched: log("info", "已发起提权重启;请在系统弹窗里点「是」") return True, "已发起提权重启,本窗口即将关闭" return False, "无法自动提权,请关闭程序后右键 →「以管理员身份运行」" 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]: """检查两个**用途不同**的网络依赖,分开报,不要混为一谈。 - ``gs.apple.com``:挂载开发者镜像时向 Apple 申请 TSS 签名,**必须**能连。 - GitHub / jsDelivr:只在「开发者镜像缺失、需要现下」时用;镜像已内置则无所谓。 国内经常只有 GitHub 被墙而 Apple 正常,以前那种"二选一"的判断会给出误导性结论。 """ import socket def _reachable(host: str, port: int = 443) -> bool: try: with socket.create_connection((host, port), timeout=6): return True except OSError: return False apple_ok = _reachable("gs.apple.com") mirror_ok = _reachable("raw.githubusercontent.com") or _reachable("cdn.jsdelivr.net") if apple_ok and mirror_ok: return True, "Apple 签名服务与镜像下载源均可连通" if apple_ok: return True, "Apple 签名服务可连通(镜像源不可达,但镜像已内置,通常无影响)" return False, "无法连接 Apple 签名服务器 gs.apple.com —— 挂载开发者镜像时会失败" 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", "管理员权限", "安装 Apple 驱动需要;虚拟定位本身不需要", _check_permission, _install_permission, required=True, install_label="重启提权", )) 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) self._pump_after_id: str | None = None self._schedule_pump() self.refresh_all() def _schedule_pump(self) -> None: """排下一次 UI 泵,并记住 after id 以便销毁前取消。""" try: self._pump_after_id = self.root.after(80, self._pump) except Exception: self._pump_after_id = None def _cancel_pump(self) -> None: """销毁窗口前取消待执行的 after,否则 Tk 会打印 ``invalid command name "..._pump"``(窗口程序看不到,命令行会刷屏)。""" if self._pump_after_id is not None: try: self.root.after_cancel(self._pump_after_id) except Exception: pass self._pump_after_id = None def _destroy(self) -> None: self._cancel_pump() try: self.root.destroy() except Exception: pass # ---------------- 布局 ---------------- @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)) title_row = tk.Frame(header, bg=COLOR_BG) title_row.pack(fill="x", anchor="w") tk.Label(title_row, text=title, font=("Microsoft YaHei UI", 15, "bold"), bg=COLOR_BG).pack(side="left") tk.Label(title_row, text=f"v{protocol.APP_VERSION} · {plat.platform_label()}", font=("Microsoft YaHei UI", 9), fg=COLOR_MUTED, bg=COLOR_BG).pack(side="left", padx=(10, 0), pady=(4, 0)) 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") self.open_logs_button = ttk.Button(footer, text="打开日志目录", width=13, command=self._open_logs_dir) self.open_logs_button.pack(side="right", padx=(0, 8)) def _open_logs_dir(self) -> None: """打开 %LOCALAPPDATA%\\iOSLocationTrack\\logs —— 装驱动失败时让用户能自己取证。""" try: target = plat.data_dir() / "logs" target.mkdir(parents=True, exist_ok=True) if not plat.open_in_file_manager(target): self.append_log(f"日志目录:{target}") except Exception as exc: self.append_log(f"无法打开日志目录:{exc}") # ---------------- 日志 ---------------- 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))) if item.key == "permission" and ok: # 提权重启已发起:关掉本窗口,本进程退出,由新实例接管 self.events.put(("close", None)) return self.events.put(("installs_done", None)) # ---------------- UI 线程泵 ---------------- def _pump(self) -> None: # 窗口一旦销毁(点「进入程序」/关闭窗口/提权重启),就不要再碰控件: # 否则队列里排着的 after 回调会在 Tk 里报 invalid command name ..._pump。 try: if not self.root.winfo_exists(): return except Exception: return 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) # 每完成一项就刷新底部:否则必需项都绿了, # 还要等可选项(网络探测可能 3×6s / PnP 45s)跑完按钮才亮。 self._update_footer() 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) elif kind == "close": # 提权重启:关掉本窗口。main() 看到 proceeded=False 会正常退出, # 由刚拉起的提权实例继续跑(它自己会再弹一次本窗口,那时已是管理员)。 self.proceeded = False self._destroy() return except queue.Empty: pass if not self.proceeded: self._schedule_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 _needs_item(self, item: EnvItem) -> bool: """判断某一项此刻是否真的"必需"。 「管理员权限」只在**需要安装 Apple 驱动**时才是必需的:驱动已就绪时, 建隧道(userspace 免管理员)和下发定位都不需要提权,不该因为它把 「进入程序」锁死 —— 否则源码模式 / 非管理员启动就永远进不去。 """ if item.key == "permission": driver = self.state.get("apple_driver") or {} return driver.get("ok") is not True return item.required def _update_footer(self) -> None: required = [i for i in self.items if self._needs_item(i)] 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 self._destroy() def _on_close(self) -> None: self.proceeded = False self._destroy() 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