feat: Windows v1.1.1 — 修复与改进(DSH 工作区归档)
This commit is contained in:
1 parent
4a5d676c7b
commit
e6880efdc6
21 files changed
+807
-106
No files matched your search
+94
-13
@@ -161,6 +161,28 @@ def start_service() -> tuple[bool, str]:
|
||||
return False, output.strip() or f"sc start 返回 {code}"
|
||||
|
||||
|
||||
def restart_service() -> tuple[bool, str]:
|
||||
"""重启 Apple 服务(先停再起)。
|
||||
|
||||
用于「服务装着、也在跑,但 27015 协议往返失败」的场景 —— 通常是服务卡死。
|
||||
比重新安装(几十到几百 MB 下载)便宜得多,应该优先尝试。
|
||||
"""
|
||||
if not plat.IS_WINDOWS:
|
||||
return False, "非 Windows 平台"
|
||||
_run(["sc", "stop", SERVICE_NAME], timeout=90)
|
||||
# 等它真的停下来(服务停止是异步的,立刻 start 可能报 1056/1052)
|
||||
deadline = time.time() + 25
|
||||
while time.time() < deadline:
|
||||
code, output = _run(["sc", "query", SERVICE_NAME], timeout=15)
|
||||
if "STOPPED" in output.upper() or "1060" in output:
|
||||
break
|
||||
time.sleep(1.0)
|
||||
code, output = _run(["sc", "start", SERVICE_NAME], timeout=90)
|
||||
if code == 0 or "1056" in output:
|
||||
return True, "服务已重启"
|
||||
return False, output.strip() or f"sc start 返回 {code}"
|
||||
|
||||
|
||||
def usbmux_probe(
|
||||
message_type: str = "ReadBUID",
|
||||
host: str = USBMUX_HOST,
|
||||
@@ -650,6 +672,29 @@ def ensure(
|
||||
return {"ok": True, "applicable": True, "action": "start_service",
|
||||
"status": after_start}
|
||||
|
||||
# 服务存在(甚至在运行)但 27015 探针不通 —— 多半是服务卡死。
|
||||
# **先重启服务**,不要一上来就走安装阶梯:AMDS 明明已经装好,
|
||||
# 却去重新执行 38MB MSI、再下载 193MB 的 iTunes,完全是浪费且可能装坏。
|
||||
if before["service"]["exists"]:
|
||||
emit("warn", "Apple 服务已安装但 usbmuxd(27015) 不通,先尝试重启服务…")
|
||||
for attempt in (1, 2):
|
||||
ok, message = restart_service()
|
||||
emit("info" if ok else "warn", f"重启服务(第 {attempt} 次):{message}")
|
||||
time.sleep(2.0 * attempt)
|
||||
after_restart = status(deep=True)
|
||||
if after_restart["ready"]:
|
||||
return {"ok": True, "applicable": True, "action": "restart_service",
|
||||
"status": after_restart}
|
||||
if not allow_install or strategy == "none":
|
||||
return {"ok": False, "applicable": True, "action": "restart_service",
|
||||
"status": after_restart,
|
||||
"error": "Apple 服务已安装但 usbmuxd 仍不可用(已尝试重启服务)。"
|
||||
"请检查是否有防火墙/安全软件拦截 127.0.0.1:27015,"
|
||||
"或断开重连数据线后重试。"}
|
||||
# 服务存在但重启无效:仅在用户显式要求时才强制重装,避免无谓的大体积下载
|
||||
emit("warn", "重启服务仍无效。若确认驱动损坏,可手动强制重装 iTunes:"
|
||||
"https://www.apple.com/itunes/download/win64")
|
||||
|
||||
if not allow_install or strategy == "none":
|
||||
return {"ok": False, "applicable": True, "action": "none",
|
||||
"needs_install": True, "status": before,
|
||||
@@ -677,12 +722,9 @@ def ensure(
|
||||
result = function(log)
|
||||
attempts.append({"strategy": name, **{k: v for k, v in result.items()
|
||||
if k not in ("steps",)}})
|
||||
if result.get("ok"):
|
||||
if name == "msi" and not result.get("ok"):
|
||||
continue
|
||||
# 无论成功失败都让服务有机会起来,然后复检
|
||||
# 装成功(或要求重启)时,给服务一次起来的机会再复检;
|
||||
# 失败则直接落到下一个策略(msi → itunes → winget 逐级降级)
|
||||
if result.get("ok") or result.get("reboot_required"):
|
||||
service_state()
|
||||
start_service()
|
||||
time.sleep(2.0)
|
||||
after = status(deep=True)
|
||||
@@ -729,15 +771,32 @@ DDI_FILES = {
|
||||
DDI_SUBDIR = ("resources", "ddi", "personalized")
|
||||
|
||||
|
||||
def ddi_dir(root: Path | str | None = None) -> Path:
|
||||
"""返回开发者镜像的本地目录。"""
|
||||
base = Path(root) if root else plat.resolve_roots()[0]
|
||||
return base.joinpath(*DDI_SUBDIR)
|
||||
def ddi_candidates(root: Path | str | None = None) -> list[Path]:
|
||||
"""按优先级返回镜像**可能所在**的目录。
|
||||
|
||||
打包后资源在 ``sys._MEIPASS``(PyInstaller 解包的临时目录,只读、退出即删),
|
||||
所以下载得到的镜像不能放那里;用户目录 ``~/.ios-location-ddi`` 是第二候选,
|
||||
``core/server.py:_stage_personalized_ddi`` 的搜索列表里已经包含它。
|
||||
"""
|
||||
if root:
|
||||
return [Path(root).joinpath(*DDI_SUBDIR)]
|
||||
resources = plat.resolve_roots()[0]
|
||||
return [
|
||||
resources.joinpath(*DDI_SUBDIR), # 随包内置(只读)
|
||||
Path.home() / ".ios-location-ddi" / "personalized", # 运行期下载(可写)
|
||||
]
|
||||
|
||||
|
||||
def ddi_status(root: Path | str | None = None) -> dict[str, Any]:
|
||||
"""检查本地开发者镜像素材是否齐全。"""
|
||||
directory = ddi_dir(root)
|
||||
def ddi_write_dir(root: Path | str | None = None) -> Path:
|
||||
"""返回**可写**的镜像目录(下载目标)。"""
|
||||
if root:
|
||||
return Path(root).joinpath(*DDI_SUBDIR)
|
||||
if getattr(sys, "frozen", False):
|
||||
return Path.home() / ".ios-location-ddi" / "personalized"
|
||||
return plat.resolve_roots()[0].joinpath(*DDI_SUBDIR)
|
||||
|
||||
|
||||
def _ddi_status_one(directory: Path) -> dict[str, Any]:
|
||||
files: dict[str, Any] = {}
|
||||
missing: list[str] = []
|
||||
total = 0
|
||||
@@ -762,6 +821,27 @@ def ddi_status(root: Path | str | None = None) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def ddi_status(root: Path | str | None = None) -> dict[str, Any]:
|
||||
"""检查本地开发者镜像素材是否齐全(按候选目录优先级,返回第一个齐全的)。"""
|
||||
candidates = ddi_candidates(root)
|
||||
for candidate in candidates:
|
||||
info = _ddi_status_one(candidate)
|
||||
if info["ok"]:
|
||||
return info
|
||||
# 都不齐全:报告优先级最高的那个(通常就是随包内置的),便于用户理解缺什么
|
||||
info = _ddi_status_one(candidates[0])
|
||||
info["searched"] = [str(c) for c in candidates]
|
||||
return info
|
||||
|
||||
|
||||
def ddi_dir(root: Path | str | None = None) -> Path:
|
||||
"""返回镜像实际所在目录:优先齐全的那个,否则返回可写目录。"""
|
||||
for candidate in ddi_candidates(root):
|
||||
if (candidate / "Image.dmg").exists():
|
||||
return candidate
|
||||
return ddi_write_dir(root)
|
||||
|
||||
|
||||
def _ddi_urls(name: str) -> list[str]:
|
||||
path = f"{DDI_DIR}/{name}"
|
||||
return [
|
||||
@@ -784,7 +864,7 @@ def download_developer_image(
|
||||
if log:
|
||||
log(level, message)
|
||||
|
||||
directory = ddi_dir(root)
|
||||
directory = ddi_write_dir(root)
|
||||
try:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
@@ -794,6 +874,7 @@ def download_developer_image(
|
||||
if current["ok"] and not force:
|
||||
return {"ok": True, "action": "none", "status": current,
|
||||
"detail": f"已就绪({current['total_mb']} MB)"}
|
||||
emit("info", f"镜像下载目录:{directory}")
|
||||
|
||||
for name, minimum in DDI_FILES.items():
|
||||
target = directory / name
|
||||
|
||||
@@ -558,6 +558,9 @@ class RealLocationProvider:
|
||||
self._last_location = (lat, lon)
|
||||
self._send_count += 1
|
||||
self._last_send_mono = time.monotonic()
|
||||
# 幂等兜底:会话建立时会启动保活,但 clear_location 之后、或任务曾异常退出时
|
||||
# 需要重新拉起 —— 保证「设了点就一定有保活」。
|
||||
self._start_keepalive()
|
||||
if self._send_count == 1 or self._send_count % 25 == 0:
|
||||
self._log("debug", f"已下发 lat={lat:.6f}, lon={lon:.6f}(第 {self._send_count} 次)")
|
||||
|
||||
@@ -627,7 +630,9 @@ class RealLocationProvider:
|
||||
raise _wrap("清除虚拟定位", exc) from exc
|
||||
self._last_location = None
|
||||
self._last_send_mono = 0.0
|
||||
self._stop_keepalive()
|
||||
# 注意:**不要**在这里 _stop_keepalive()。保活循环本来就会在
|
||||
# _last_location is None 时空转;一旦停掉,之后再 set_location 就永远没有保活了
|
||||
# (这是审查发现的真实 bug:先 stop 再设点,等 30s 也不会有保活重发)。
|
||||
self._log("info", "已清除虚拟定位,设备恢复真实定位")
|
||||
|
||||
async def close(self) -> None:
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import uuid
|
||||
from typing import Any
|
||||
|
||||
APP_NAME = "iOS 虚拟定位轨迹工具"
|
||||
APP_VERSION = "1.1.0"
|
||||
APP_VERSION = "1.1.1"
|
||||
PROTOCOL_VERSION = "v1"
|
||||
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
|
||||
+83
-23
@@ -136,11 +136,26 @@ class App:
|
||||
|
||||
# ---------------- 日志 ----------------
|
||||
def _open_log_file(self):
|
||||
"""打包成窗口程序后没有控制台,日志必须落盘,否则出问题无从排查。"""
|
||||
"""打包成窗口程序后没有控制台,日志必须落盘,否则出问题无从排查。
|
||||
|
||||
带简单滚动:超过 5 MB 就把当前日志改名为 ``app.log.1``(只保留一份旧日志),
|
||||
避免长期使用后日志无限增长。
|
||||
"""
|
||||
try:
|
||||
directory = self.root / "logs"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
handle = open(directory / "app.log", "a", encoding="utf-8")
|
||||
log_path = directory / "app.log"
|
||||
try:
|
||||
if log_path.exists() and log_path.stat().st_size > 5 * 1024 * 1024:
|
||||
backup = directory / "app.log.1"
|
||||
try:
|
||||
backup.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
log_path.replace(backup)
|
||||
except OSError:
|
||||
pass
|
||||
handle = open(log_path, "a", encoding="utf-8")
|
||||
return handle
|
||||
except Exception:
|
||||
return None
|
||||
@@ -252,14 +267,17 @@ class App:
|
||||
rsd = None
|
||||
rsd_obj = None
|
||||
if (device.ios_major or 0) >= 17:
|
||||
if not self.tunnel.ready:
|
||||
snapshot = self.tunnel.ensure(device.udid, allow_sudo=False)
|
||||
if snapshot["state"] != "connected":
|
||||
raise protocol.AppError(
|
||||
snapshot.get("message") or tunnel_mod._HINT_NATIVE_FAILED,
|
||||
"tunnel_required",
|
||||
status=409,
|
||||
)
|
||||
# 必须**无条件**调用 ensure():它内部会判断已建立的隧道是不是绑在当前
|
||||
# 选中的这台设备上,UDID 不一致会自动重建。
|
||||
# 之前这里先用 `if not self.tunnel.ready` 短路,导致切换设备时复用了
|
||||
# 上一台设备的 RSD 对象 —— 表现为"给错的手机下发定位"。
|
||||
snapshot = self.tunnel.ensure(device.udid, allow_sudo=False)
|
||||
if snapshot["state"] != "connected":
|
||||
raise protocol.AppError(
|
||||
snapshot.get("message") or tunnel_mod._HINT_NATIVE_FAILED,
|
||||
"tunnel_required",
|
||||
status=409,
|
||||
)
|
||||
rsd_obj = self.tunnel.rsd_object()
|
||||
if rsd_obj is None:
|
||||
rsd = self.tunnel.address()
|
||||
@@ -270,7 +288,21 @@ class App:
|
||||
udid=device.udid,
|
||||
log=self.log,
|
||||
)
|
||||
self.runner.run(provider.connect(device), timeout=40.0)
|
||||
try:
|
||||
self.runner.run(provider.connect(device), timeout=40.0)
|
||||
except Exception:
|
||||
# 失败时把新建的 provider 关掉,避免 DVT/lockdown 泄漏
|
||||
try:
|
||||
self.runner.run(provider.close(), timeout=15.0)
|
||||
except Exception:
|
||||
pass
|
||||
# 并把隧道标记为失效,下一次连接会重建,
|
||||
# 避免拿着一个已经死掉的 RSD 反复重试。
|
||||
try:
|
||||
self.tunnel.invalidate("DVT 会话建立失败,隧道可能已失效")
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
self.provider = provider
|
||||
self.provider_udid = device.udid
|
||||
self.playback.set_provider(provider)
|
||||
@@ -810,6 +842,8 @@ class App:
|
||||
"""
|
||||
import sys
|
||||
|
||||
from . import apple_driver
|
||||
|
||||
sources: list[Path] = []
|
||||
base = getattr(sys, "_MEIPASS", None)
|
||||
if base:
|
||||
@@ -819,8 +853,15 @@ class App:
|
||||
sources.append(self.root / "resources" / "ddi" / "personalized")
|
||||
sources.append(Path.home() / ".ios-location-ddi" / "personalized")
|
||||
|
||||
src = next((d for d in sources if (d / "Image.dmg").exists()), None)
|
||||
# 必须**三件齐全**的目录才算可用。以前只判断"有没有 Image.dmg",
|
||||
# 可能挑到一个残缺目录(比如缺 Image.dmg.trustcache),拷进去照样挂不上 ——
|
||||
# 与 apple_driver.ddi_status() 的判定标准保持一致。
|
||||
required = tuple(apple_driver.DDI_FILES)
|
||||
src = next((d for d in sources
|
||||
if all((d / name).exists() for name in required)), None)
|
||||
if src is None:
|
||||
self.log("warn", "未找到完整的个性化镜像素材(需要 Image.dmg / "
|
||||
"BuildManifest.plist / Image.dmg.trustcache 三件齐全)")
|
||||
return False
|
||||
|
||||
dest = Path.home() / ".pymobiledevice3" / "Xcode_iOS_DDI_Personalized"
|
||||
@@ -834,8 +875,6 @@ class App:
|
||||
copied = []
|
||||
for source_name, dest_name in mapping.items():
|
||||
source_file = src / source_name
|
||||
if not source_file.exists():
|
||||
continue
|
||||
target = dest / dest_name
|
||||
# 体积一致就跳过,避免每次挂载都拷 15MB
|
||||
if target.exists() and target.stat().st_size == source_file.stat().st_size:
|
||||
@@ -844,18 +883,15 @@ class App:
|
||||
copied.append(dest_name)
|
||||
if copied:
|
||||
self.log("info", "已从应用内置素材安装个性化镜像:" + "、".join(copied))
|
||||
# 落地后再校验一次,避免"拷了但没拷全"
|
||||
missing = [d for d in mapping.values() if not (dest / d).exists()]
|
||||
if missing:
|
||||
self.log("warn", "镜像素材不完整,缺少:" + "、".join(missing))
|
||||
return False
|
||||
return True
|
||||
except Exception as exc:
|
||||
self.log("warn", f"安装内置个性化镜像失败:{exc}")
|
||||
return False
|
||||
device = self.devices.get_selected()
|
||||
if device is None:
|
||||
return {"mounted": None, "running": self._mount_running}
|
||||
return {
|
||||
"mounted": self._developer_image_mounted(device.udid),
|
||||
"running": self._mount_running,
|
||||
"ios_major": device.ios_major,
|
||||
}
|
||||
|
||||
def _cmd_mount_status(self, message: dict[str, Any]) -> dict[str, Any]:
|
||||
"""查询当前设备的开发者镜像/会话状态。"""
|
||||
@@ -984,6 +1020,14 @@ class App:
|
||||
self.runner.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
# 关闭日志文件句柄(打包成窗口程序时这是唯一的排查线索,必须 flush 落盘)
|
||||
try:
|
||||
handle = getattr(self, "_log_file", None)
|
||||
if handle is not None:
|
||||
handle.close()
|
||||
self._log_file = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------- HTTP ----------------
|
||||
@@ -1001,6 +1045,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def handle_one_request(self) -> None:
|
||||
"""浏览器关闭 keep-alive 连接时不要往终端刷一堆 ConnectionResetError 回溯。"""
|
||||
# 同一个 handler 实例会处理 keep-alive 连接上的多个请求,
|
||||
# 请求体缓存必须按请求重置,否则第二个请求会读到上一个请求的 body。
|
||||
self._body_cache = None
|
||||
try:
|
||||
super().handle_one_request()
|
||||
except (ConnectionResetError, BrokenPipeError, ConnectionAbortedError):
|
||||
@@ -1070,15 +1117,22 @@ class Handler(BaseHTTPRequestHandler):
|
||||
pass
|
||||
|
||||
def _read_body(self) -> bytes:
|
||||
# do_POST 会预先读完并缓存,这里直接复用,避免重复读取或漏读
|
||||
cached = getattr(self, "_body_cache", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
except ValueError:
|
||||
length = 0
|
||||
if length <= 0:
|
||||
self._body_cache = b""
|
||||
return b""
|
||||
if length > 64 * 1024 * 1024:
|
||||
raise protocol.AppError("请求体过大", "too_large", status=413)
|
||||
return self.rfile.read(length)
|
||||
data = self.rfile.read(length)
|
||||
self._body_cache = data
|
||||
return data
|
||||
|
||||
def _json_body(self) -> dict[str, Any]:
|
||||
raw = self._read_body()
|
||||
@@ -1120,6 +1174,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
try:
|
||||
# **先把请求体读完并缓存**。有些 handler(如 /api/devices/connect)
|
||||
# 压根不读 body;若不在这里排空,残留的字节会留在 keep-alive 连接里,
|
||||
# 被当成下一个请求的请求行 —— 表现为
|
||||
# 501 Unsupported method ('{}POST')
|
||||
# 而且是间歇性的(取决于客户端是否复用连接),很难查。
|
||||
self._body_cache = self._read_body()
|
||||
self._check_origin()
|
||||
self._check_host()
|
||||
if path.startswith("/api/"):
|
||||
|
||||
+122
-26
@@ -20,6 +20,7 @@ from typing import Any, Callable
|
||||
|
||||
from . import apple_driver
|
||||
from . import platform as plat
|
||||
from . import protocol
|
||||
|
||||
# 颜色(与 web 端保持同一套语义色)
|
||||
COLOR_OK = "#1a7f37"
|
||||
@@ -144,7 +145,23 @@ def _install_developer_image(log: Callable[[str, str], None]) -> tuple[bool, str
|
||||
def _check_permission() -> tuple[bool, str]:
|
||||
if plat.is_admin():
|
||||
return True, "已以管理员身份运行"
|
||||
return False, "未提权(安装 Apple 驱动和管理员操作会失败)"
|
||||
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]:
|
||||
@@ -161,20 +178,29 @@ def _check_device() -> tuple[bool, str]:
|
||||
|
||||
|
||||
def _check_network() -> tuple[bool, str]:
|
||||
"""Apple TSS 签名需要联网(挂载开发者镜像时用)。"""
|
||||
"""检查两个**用途不同**的网络依赖,分开报,不要混为一谈。
|
||||
|
||||
- ``gs.apple.com``:挂载开发者镜像时向 Apple 申请 TSS 签名,**必须**能连。
|
||||
- GitHub / jsDelivr:只在「开发者镜像缺失、需要现下」时用;镜像已内置则无所谓。
|
||||
国内经常只有 GitHub 被墙而 Apple 正常,以前那种"二选一"的判断会给出误导性结论。
|
||||
"""
|
||||
import socket
|
||||
|
||||
hosts = [("gs.apple.com", 443), ("raw.githubusercontent.com", 443)]
|
||||
reachable = []
|
||||
for host, port in hosts:
|
||||
def _reachable(host: str, port: int = 443) -> bool:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=6):
|
||||
reachable.append(host)
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
if reachable:
|
||||
return True, "可连通(" + "、".join(reachable) + ")"
|
||||
return False, "无法连接 Apple 服务器(挂载开发者镜像时需要联网签名)"
|
||||
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]:
|
||||
@@ -204,8 +230,9 @@ def build_items() -> list[EnvItem]:
|
||||
))
|
||||
items.append(EnvItem(
|
||||
"permission", "管理员权限",
|
||||
"安装驱动与系统级操作需要",
|
||||
_check_permission, None, required=True,
|
||||
"安装 Apple 驱动需要;虚拟定位本身不需要",
|
||||
_check_permission, _install_permission, required=True,
|
||||
install_label="重启提权",
|
||||
))
|
||||
items.append(EnvItem(
|
||||
"device", "iPhone 连接",
|
||||
@@ -253,9 +280,34 @@ class SetupWindow:
|
||||
root.resizable(False, True)
|
||||
self._fit_to_screen(root)
|
||||
root.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||
root.after(80, self._pump)
|
||||
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:
|
||||
@@ -286,8 +338,13 @@ class SetupWindow:
|
||||
|
||||
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")
|
||||
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="首次使用需要准备以下组件。缺失的项目点右侧按钮即可自动安装;"
|
||||
@@ -369,6 +426,20 @@ class SetupWindow:
|
||||
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")
|
||||
@@ -439,10 +510,21 @@ class SetupWindow:
|
||||
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()
|
||||
@@ -450,6 +532,9 @@ class SetupWindow:
|
||||
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":
|
||||
@@ -471,10 +556,16 @@ class SetupWindow:
|
||||
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.root.after(80, self._pump)
|
||||
self._schedule_pump()
|
||||
|
||||
# ---------------- 渲染 ----------------
|
||||
def _render(self, key: str) -> None:
|
||||
@@ -521,9 +612,20 @@ class SetupWindow:
|
||||
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 i.required]
|
||||
self.state.get("__x__")
|
||||
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]
|
||||
@@ -554,17 +656,11 @@ class SetupWindow:
|
||||
# ---------------- 结束 ----------------
|
||||
def _on_confirm(self) -> None:
|
||||
self.proceeded = True
|
||||
try:
|
||||
self.root.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
self._destroy()
|
||||
|
||||
def _on_close(self) -> None:
|
||||
self.proceeded = False
|
||||
try:
|
||||
self.root.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
self._destroy()
|
||||
|
||||
|
||||
def run_setup_window(title: str = "环境准备", auto_install_missing: bool = False) -> bool | None:
|
||||
|
||||
+48
-9
@@ -137,7 +137,23 @@ class TunnelManager:
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
with self._lock:
|
||||
return bool(self.host and self.port) and self.state == "connected"
|
||||
if not (bool(self.host and self.port) and self.state == "connected"):
|
||||
return False
|
||||
# 用户态隧道死了之后 pymobiledevice3 会自己 aclose() 并把自己的 rsd 置空,
|
||||
# 但我们这边还留着旧引用、host/port 也还在 —— 不主动判断的话会一直把
|
||||
# 已经死掉的 RSD 注入给 provider,表现为"界面显示已连接但怎么都不生效"。
|
||||
tunnel = self._userspace_tunnel
|
||||
if tunnel is not None:
|
||||
try:
|
||||
if getattr(tunnel, "rsd", None) is None:
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
"""隧道是否仍然健康(供 UI/上层做"是否该重连"的判断)。"""
|
||||
return self.ready
|
||||
|
||||
def address(self) -> tuple[str, int] | None:
|
||||
with self._lock:
|
||||
@@ -392,11 +408,25 @@ class TunnelManager:
|
||||
return self.snapshot()
|
||||
|
||||
def stop(self) -> None:
|
||||
# ---- Windows:释放进程内用户态隧道 ----
|
||||
# 先把状态和地址**在锁内一次性清干净**,再慢慢释放资源。
|
||||
# 之前是"先清 rsd、最后才清 host/port/state",中间那段时间 ready 仍为 True
|
||||
# 而 rsd_object() 已经是 None,并发的 provider_for 会退回 (host, port) 去建
|
||||
# 内核 TCP 连接 → Windows 上直接 OSError [WinError 1231]。
|
||||
with self._lock:
|
||||
tunnel = self._userspace_tunnel
|
||||
self._userspace_tunnel = None
|
||||
self._rsd = None
|
||||
process = self._process
|
||||
self._process = None
|
||||
self.host = None
|
||||
self.port = None
|
||||
self.mode = None
|
||||
self._needs_root = False
|
||||
if self.state != "idle":
|
||||
self.state = "starting" # 任何非 connected 状态都会让 ready 变为 False
|
||||
self.message = "正在释放隧道…"
|
||||
|
||||
# ---- Windows:释放进程内用户态隧道 ----
|
||||
if tunnel is not None:
|
||||
try:
|
||||
if self._runner is not None:
|
||||
@@ -407,21 +437,30 @@ class TunnelManager:
|
||||
self._emit("warn", f"释放用户态隧道时出错(可忽略):{exc}")
|
||||
|
||||
# ---- macOS:结束子进程 ----
|
||||
with self._lock:
|
||||
process = self._process
|
||||
self._process = None
|
||||
if process is not None and process.poll() is None:
|
||||
plat.terminate_process_tree(process)
|
||||
|
||||
with self._lock:
|
||||
self.host = None
|
||||
self.port = None
|
||||
self.mode = None
|
||||
if self.state in ("connected", "starting", "error", "unsupported"):
|
||||
if self.state == "starting":
|
||||
self.state = "idle"
|
||||
self.message = _default_message("idle")
|
||||
_write_pidfile([])
|
||||
|
||||
def invalidate(self, reason: str = "隧道已失效") -> None:
|
||||
"""把隧道标记为失效(DVT 会话建立失败 / 数据通道报错时调用)。
|
||||
|
||||
只改状态、不释放底层资源:让下一次 ``ensure()`` 走"重建"分支,
|
||||
避免拿着一个已经死掉的 RSD 反复重试。真正释放由 ``stop()`` 负责。
|
||||
"""
|
||||
with self._lock:
|
||||
if self.state in ("connected", "error"):
|
||||
self.state = "error"
|
||||
self.message = reason
|
||||
self.host = None
|
||||
self.port = None
|
||||
self._rsd = None
|
||||
self._emit("warn", f"[tunnel] {reason},下次连接会重建隧道")
|
||||
|
||||
|
||||
async def _close_userspace(tunnel: Any) -> None:
|
||||
"""释放 userspace 隧道。
|
||||
|
||||
Reference in new issue
Block a user