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:
1 parent
85e9a53750
commit
4d55e2415f
26 files changed
+4515
-306
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user