Files
ios-location-track/core/setup_window.py
T

703 lines
28 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
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