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