feat: Windows v1.1.1 — 修复与改进(DSH 工作区归档)

This commit is contained in:
jbjf6666 committed 2026-10-29 15:37:17 +08:00
1 parent 4a5d676c7b
commit e6880efdc6
21 files changed
+807 -106

No files matched your search

+3 -3
View File
@@ -210,12 +210,12 @@ else:
name="虚拟定位轨迹.app", name="虚拟定位轨迹.app",
icon=str(ROOT / "resources" / "AppIcon.icns"), icon=str(ROOT / "resources" / "AppIcon.icns"),
bundle_identifier="com.local.ios-location-track", bundle_identifier="com.local.ios-location-track",
version="1.1.0", version="1.1.1",
info_plist={ info_plist={
"CFBundleName": "虚拟定位轨迹", "CFBundleName": "虚拟定位轨迹",
"CFBundleDisplayName": "虚拟定位轨迹", "CFBundleDisplayName": "虚拟定位轨迹",
"CFBundleShortVersionString": "1.1.0", "CFBundleShortVersionString": "1.1.1",
"CFBundleVersion": "1.1.0", "CFBundleVersion": "1.1.1",
"CFBundlePackageType": "APPL", "CFBundlePackageType": "APPL",
"CFBundleIconFile": "AppIcon", "CFBundleIconFile": "AppIcon",
"CFBundleIconName": "AppIcon", "CFBundleIconName": "AppIcon",
+1 -1
View File
@@ -13,7 +13,7 @@ The UI is a local web app opened in a Chrome / Edge app window (no address bar
> userspace stack — no Wintun driver needed), detects and auto-installs Apple Mobile Device > userspace stack — no Wintun driver needed), detects and auto-installs Apple Mobile Device
> Support on first launch, and shows an environment self-check window. > Support on first launch, and shows an environment self-check window.
> Build outputs: `dist\iOSLocationTrack\iOSLocationTrack.exe` and > Build outputs: `dist\iOSLocationTrack\iOSLocationTrack.exe` and
> `dist\installer\iOSLocationTrack-1.1.0_win-setup.exe`. > `dist\installer\iOSLocationTrack-1.1.1_win-setup.exe`.
> >
> 📄 Quick-start guide (bilingual plain text, opens in Notepad): [USAGE.txt](USAGE.txt) > 📄 Quick-start guide (bilingual plain text, opens in Notepad): [USAGE.txt](USAGE.txt)
> 📄 Detailed tutorial: **drawing routes, polylines & starting virtual playback**: [TUTORIAL-DRAW-AND-PLAY.txt](TUTORIAL-DRAW-AND-PLAY.txt) > 📄 Detailed tutorial: **drawing routes, polylines & starting virtual playback**: [TUTORIAL-DRAW-AND-PLAY.txt](TUTORIAL-DRAW-AND-PLAY.txt)
+1 -1
View File
@@ -12,7 +12,7 @@
> Windows 版:免管理员建立隧道(纯 Python 用户态协议栈,不需要 Wintun 驱动)、 > Windows 版:免管理员建立隧道(纯 Python 用户态协议栈,不需要 Wintun 驱动)、
> 启动时自动检测并安装 Apple Mobile Device Support、带「环境准备」自检窗口。 > 启动时自动检测并安装 Apple Mobile Device Support、带「环境准备」自检窗口。
> 构建产物:`dist\iOSLocationTrack\iOSLocationTrack.exe` 与 > 构建产物:`dist\iOSLocationTrack\iOSLocationTrack.exe` 与
> `dist\installer\iOSLocationTrack-1.1.0_win-setup.exe`。 > `dist\installer\iOSLocationTrack-1.1.1_win-setup.exe`。
> >
> 📄 快速上手(中英双语纯文本,可直接用记事本打开):[USAGE.txt](USAGE.txt) > 📄 快速上手(中英双语纯文本,可直接用记事本打开):[USAGE.txt](USAGE.txt)
> 📄 详细教程:**画轨迹 · 折线绘制 · 开始虚拟轨迹**:[TUTORIAL-DRAW-AND-PLAY.txt](TUTORIAL-DRAW-AND-PLAY.txt) > 📄 详细教程:**画轨迹 · 折线绘制 · 开始虚拟轨迹**:[TUTORIAL-DRAW-AND-PLAY.txt](TUTORIAL-DRAW-AND-PLAY.txt)
+1 -1
View File
@@ -3,7 +3,7 @@
Drawing Routes, Polylines & Starting Virtual Playback — Step-by-Step Tutorial Drawing Routes, Polylines & Starting Virtual Playback — Step-by-Step Tutorial
================================================================================ ================================================================================
适用版本 / Version : 1.1.0 适用版本 / Version : 1.1.1
前置阅读 / First : USAGE.txt(安装、启动、连接 iPhone) 前置阅读 / First : USAGE.txt(安装、启动、连接 iPhone)
本文件纯文本、中英双语:中文在前,英文在后。 本文件纯文本、中英双语:中文在前,英文在后。
英文部分从第 652 行开始(搜「PART 2」可跳转)。 英文部分从第 652 行开始(搜「PART 2」可跳转)。
+7 -7
View File
@@ -3,7 +3,7 @@
iOS Virtual Location & Route Simulator — Usage Guide iOS Virtual Location & Route Simulator — Usage Guide
================================================================================ ================================================================================
版本 / Version : 1.1.0 版本 / Version : 1.1.1
平台 / Platform: Windows 10 1809+ · macOS 12+ 平台 / Platform: Windows 10 1809+ · macOS 12+
支持 / Support : iOS 17 及以上 / iOS 17 or later 支持 / Support : iOS 17 及以上 / iOS 17 or later
仓库 / Repo : https://github.com/jbjf6666/ios-location-track(免费 / free) 仓库 / Repo : https://github.com/jbjf6666/ios-location-track(免费 / free)
@@ -48,10 +48,10 @@
【Windows】 【Windows】
方式一(推荐):下载 iOSLocationTrack-1.1.0_win-setup.exe 方式一(推荐):下载 iOSLocationTrack-1.1.1_win-setup.exe
双击安装。安装向导是中文的。 双击安装。安装向导是中文的。
方式二:下载 iOSLocationTrack-1.1.0_win-portable.zip 方式二:下载 iOSLocationTrack-1.1.1_win-portable.zip
解压到任意目录,双击里面的 iOSLocationTrack.exe 即可,无需安装。 解压到任意目录,双击里面的 iOSLocationTrack.exe 即可,无需安装。
注意:程序启动时会弹「用户账户控制(UAC)」窗口,需要点「是」。 注意:程序启动时会弹「用户账户控制(UAC)」窗口,需要点「是」。
@@ -60,7 +60,7 @@
【macOS】 【macOS】
下载 iOSLocationTrack-1.1.0_mac.dmg → 打开 → 把「虚拟定位轨迹」拖进「应用程序」。 下载 iOSLocationTrack-1.1.1_mac.dmg → 打开 → 把「虚拟定位轨迹」拖进「应用程序」。
首次打开若提示「来自身份不明的开发者」(因为没买 Apple 开发者签名): 首次打开若提示「来自身份不明的开发者」(因为没买 Apple 开发者签名):
在应用图标上右键 → 打开 → 再点「打开」。 在应用图标上右键 → 打开 → 再点「打开」。
@@ -277,9 +277,9 @@ debugging.
[Windows] [Windows]
Option A (recommended): download iOSLocationTrack-1.1.0_win-setup.exe and run it. Option A (recommended): download iOSLocationTrack-1.1.1_win-setup.exe and run it.
Option B: download iOSLocationTrack-1.1.0_win-portable.zip, unzip anywhere and Option B: download iOSLocationTrack-1.1.1_win-portable.zip, unzip anywhere and
double-click iOSLocationTrack.exe. No installation needed. double-click iOSLocationTrack.exe. No installation needed.
Note: Windows will show a User Account Control (UAC) prompt on launch — click Yes. Note: Windows will show a User Account Control (UAC) prompt on launch — click Yes.
@@ -288,7 +288,7 @@ debugging.
[macOS] [macOS]
Download iOSLocationTrack-1.1.0_mac.dmg, open it, and drag the app into Applications. Download iOSLocationTrack-1.1.1_mac.dmg, open it, and drag the app into Applications.
If macOS says "unidentified developer" on first launch (the app is not notarized): If macOS says "unidentified developer" on first launch (the app is not notarized):
right-click the app icon -> Open -> Open. right-click the app icon -> Open -> Open.
+94 -13
View File
@@ -161,6 +161,28 @@ def start_service() -> tuple[bool, str]:
return False, output.strip() or f"sc start 返回 {code}" 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( def usbmux_probe(
message_type: str = "ReadBUID", message_type: str = "ReadBUID",
host: str = USBMUX_HOST, host: str = USBMUX_HOST,
@@ -650,6 +672,29 @@ def ensure(
return {"ok": True, "applicable": True, "action": "start_service", return {"ok": True, "applicable": True, "action": "start_service",
"status": after_start} "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": if not allow_install or strategy == "none":
return {"ok": False, "applicable": True, "action": "none", return {"ok": False, "applicable": True, "action": "none",
"needs_install": True, "status": before, "needs_install": True, "status": before,
@@ -677,12 +722,9 @@ def ensure(
result = function(log) result = function(log)
attempts.append({"strategy": name, **{k: v for k, v in result.items() attempts.append({"strategy": name, **{k: v for k, v in result.items()
if k not in ("steps",)}}) if k not in ("steps",)}})
if result.get("ok"): # 装成功(或要求重启)时,给服务一次起来的机会再复检;
if name == "msi" and not result.get("ok"): # 失败则直接落到下一个策略(msi → itunes → winget 逐级降级)
continue
# 无论成功失败都让服务有机会起来,然后复检
if result.get("ok") or result.get("reboot_required"): if result.get("ok") or result.get("reboot_required"):
service_state()
start_service() start_service()
time.sleep(2.0) time.sleep(2.0)
after = status(deep=True) after = status(deep=True)
@@ -729,15 +771,32 @@ DDI_FILES = {
DDI_SUBDIR = ("resources", "ddi", "personalized") DDI_SUBDIR = ("resources", "ddi", "personalized")
def ddi_dir(root: Path | str | None = None) -> Path: def ddi_candidates(root: Path | str | None = None) -> list[Path]:
"""返回开发者镜像的本地目录。""" """按优先级返回镜像**可能所在**的目录。
base = Path(root) if root else plat.resolve_roots()[0]
return base.joinpath(*DDI_SUBDIR) 打包后资源在 ``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]: def ddi_write_dir(root: Path | str | None = None) -> Path:
"""检查本地开发者镜像素材是否齐全。""" """返回**可写**的镜像目录(下载目标)。"""
directory = ddi_dir(root) 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] = {} files: dict[str, Any] = {}
missing: list[str] = [] missing: list[str] = []
total = 0 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]: def _ddi_urls(name: str) -> list[str]:
path = f"{DDI_DIR}/{name}" path = f"{DDI_DIR}/{name}"
return [ return [
@@ -784,7 +864,7 @@ def download_developer_image(
if log: if log:
log(level, message) log(level, message)
directory = ddi_dir(root) directory = ddi_write_dir(root)
try: try:
directory.mkdir(parents=True, exist_ok=True) directory.mkdir(parents=True, exist_ok=True)
except OSError as exc: except OSError as exc:
@@ -794,6 +874,7 @@ def download_developer_image(
if current["ok"] and not force: if current["ok"] and not force:
return {"ok": True, "action": "none", "status": current, return {"ok": True, "action": "none", "status": current,
"detail": f"已就绪({current['total_mb']} MB)"} "detail": f"已就绪({current['total_mb']} MB)"}
emit("info", f"镜像下载目录:{directory}")
for name, minimum in DDI_FILES.items(): for name, minimum in DDI_FILES.items():
target = directory / name target = directory / name
+6 -1
View File
@@ -558,6 +558,9 @@ class RealLocationProvider:
self._last_location = (lat, lon) self._last_location = (lat, lon)
self._send_count += 1 self._send_count += 1
self._last_send_mono = time.monotonic() self._last_send_mono = time.monotonic()
# 幂等兜底:会话建立时会启动保活,但 clear_location 之后、或任务曾异常退出时
# 需要重新拉起 —— 保证「设了点就一定有保活」。
self._start_keepalive()
if self._send_count == 1 or self._send_count % 25 == 0: if self._send_count == 1 or self._send_count % 25 == 0:
self._log("debug", f"已下发 lat={lat:.6f}, lon={lon:.6f}(第 {self._send_count} 次)") self._log("debug", f"已下发 lat={lat:.6f}, lon={lon:.6f}(第 {self._send_count} 次)")
@@ -627,7 +630,9 @@ class RealLocationProvider:
raise _wrap("清除虚拟定位", exc) from exc raise _wrap("清除虚拟定位", exc) from exc
self._last_location = None self._last_location = None
self._last_send_mono = 0.0 self._last_send_mono = 0.0
self._stop_keepalive() # 注意:**不要**在这里 _stop_keepalive()。保活循环本来就会在
# _last_location is None 时空转;一旦停掉,之后再 set_location 就永远没有保活了
# (这是审查发现的真实 bug:先 stop 再设点,等 30s 也不会有保活重发)。
self._log("info", "已清除虚拟定位,设备恢复真实定位") self._log("info", "已清除虚拟定位,设备恢复真实定位")
async def close(self) -> None: async def close(self) -> None:
+1 -1
View File
@@ -11,7 +11,7 @@ import uuid
from typing import Any from typing import Any
APP_NAME = "iOS 虚拟定位轨迹工具" APP_NAME = "iOS 虚拟定位轨迹工具"
APP_VERSION = "1.1.0" APP_VERSION = "1.1.1"
PROTOCOL_VERSION = "v1" PROTOCOL_VERSION = "v1"
DEFAULT_HOST = "127.0.0.1" DEFAULT_HOST = "127.0.0.1"
+83 -23
View File
@@ -136,11 +136,26 @@ class App:
# ---------------- 日志 ---------------- # ---------------- 日志 ----------------
def _open_log_file(self): def _open_log_file(self):
"""打包成窗口程序后没有控制台,日志必须落盘,否则出问题无从排查。""" """打包成窗口程序后没有控制台,日志必须落盘,否则出问题无从排查。
带简单滚动:超过 5 MB 就把当前日志改名为 ``app.log.1``(只保留一份旧日志),
避免长期使用后日志无限增长。
"""
try: try:
directory = self.root / "logs" directory = self.root / "logs"
directory.mkdir(parents=True, exist_ok=True) 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 return handle
except Exception: except Exception:
return None return None
@@ -252,14 +267,17 @@ class App:
rsd = None rsd = None
rsd_obj = None rsd_obj = None
if (device.ios_major or 0) >= 17: if (device.ios_major or 0) >= 17:
if not self.tunnel.ready: # 必须**无条件**调用 ensure():它内部会判断已建立的隧道是不是绑在当前
snapshot = self.tunnel.ensure(device.udid, allow_sudo=False) # 选中的这台设备上,UDID 不一致会自动重建。
if snapshot["state"] != "connected": # 之前这里先用 `if not self.tunnel.ready` 短路,导致切换设备时复用了
raise protocol.AppError( # 上一台设备的 RSD 对象 —— 表现为"给错的手机下发定位"。
snapshot.get("message") or tunnel_mod._HINT_NATIVE_FAILED, snapshot = self.tunnel.ensure(device.udid, allow_sudo=False)
"tunnel_required", if snapshot["state"] != "connected":
status=409, raise protocol.AppError(
) snapshot.get("message") or tunnel_mod._HINT_NATIVE_FAILED,
"tunnel_required",
status=409,
)
rsd_obj = self.tunnel.rsd_object() rsd_obj = self.tunnel.rsd_object()
if rsd_obj is None: if rsd_obj is None:
rsd = self.tunnel.address() rsd = self.tunnel.address()
@@ -270,7 +288,21 @@ class App:
udid=device.udid, udid=device.udid,
log=self.log, 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 = provider
self.provider_udid = device.udid self.provider_udid = device.udid
self.playback.set_provider(provider) self.playback.set_provider(provider)
@@ -810,6 +842,8 @@ class App:
""" """
import sys import sys
from . import apple_driver
sources: list[Path] = [] sources: list[Path] = []
base = getattr(sys, "_MEIPASS", None) base = getattr(sys, "_MEIPASS", None)
if base: if base:
@@ -819,8 +853,15 @@ class App:
sources.append(self.root / "resources" / "ddi" / "personalized") sources.append(self.root / "resources" / "ddi" / "personalized")
sources.append(Path.home() / ".ios-location-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: if src is None:
self.log("warn", "未找到完整的个性化镜像素材(需要 Image.dmg / "
"BuildManifest.plist / Image.dmg.trustcache 三件齐全)")
return False return False
dest = Path.home() / ".pymobiledevice3" / "Xcode_iOS_DDI_Personalized" dest = Path.home() / ".pymobiledevice3" / "Xcode_iOS_DDI_Personalized"
@@ -834,8 +875,6 @@ class App:
copied = [] copied = []
for source_name, dest_name in mapping.items(): for source_name, dest_name in mapping.items():
source_file = src / source_name source_file = src / source_name
if not source_file.exists():
continue
target = dest / dest_name target = dest / dest_name
# 体积一致就跳过,避免每次挂载都拷 15MB # 体积一致就跳过,避免每次挂载都拷 15MB
if target.exists() and target.stat().st_size == source_file.stat().st_size: if target.exists() and target.stat().st_size == source_file.stat().st_size:
@@ -844,18 +883,15 @@ class App:
copied.append(dest_name) copied.append(dest_name)
if copied: if copied:
self.log("info", "已从应用内置素材安装个性化镜像:" + "、".join(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 return True
except Exception as exc: except Exception as exc:
self.log("warn", f"安装内置个性化镜像失败:{exc}") self.log("warn", f"安装内置个性化镜像失败:{exc}")
return False 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]: def _cmd_mount_status(self, message: dict[str, Any]) -> dict[str, Any]:
"""查询当前设备的开发者镜像/会话状态。""" """查询当前设备的开发者镜像/会话状态。"""
@@ -984,6 +1020,14 @@ class App:
self.runner.shutdown() self.runner.shutdown()
except Exception: except Exception:
pass pass
# 关闭日志文件句柄(打包成窗口程序时这是唯一的排查线索,必须 flush 落盘)
try:
handle = getattr(self, "_log_file", None)
if handle is not None:
handle.close()
self._log_file = None
except Exception:
pass
# ---------------- HTTP ---------------- # ---------------- HTTP ----------------
@@ -1001,6 +1045,9 @@ class Handler(BaseHTTPRequestHandler):
def handle_one_request(self) -> None: def handle_one_request(self) -> None:
"""浏览器关闭 keep-alive 连接时不要往终端刷一堆 ConnectionResetError 回溯。""" """浏览器关闭 keep-alive 连接时不要往终端刷一堆 ConnectionResetError 回溯。"""
# 同一个 handler 实例会处理 keep-alive 连接上的多个请求,
# 请求体缓存必须按请求重置,否则第二个请求会读到上一个请求的 body。
self._body_cache = None
try: try:
super().handle_one_request() super().handle_one_request()
except (ConnectionResetError, BrokenPipeError, ConnectionAbortedError): except (ConnectionResetError, BrokenPipeError, ConnectionAbortedError):
@@ -1070,15 +1117,22 @@ class Handler(BaseHTTPRequestHandler):
pass pass
def _read_body(self) -> bytes: def _read_body(self) -> bytes:
# do_POST 会预先读完并缓存,这里直接复用,避免重复读取或漏读
cached = getattr(self, "_body_cache", None)
if cached is not None:
return cached
try: try:
length = int(self.headers.get("Content-Length") or 0) length = int(self.headers.get("Content-Length") or 0)
except ValueError: except ValueError:
length = 0 length = 0
if length <= 0: if length <= 0:
self._body_cache = b""
return b"" return b""
if length > 64 * 1024 * 1024: if length > 64 * 1024 * 1024:
raise protocol.AppError("请求体过大", "too_large", status=413) 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]: def _json_body(self) -> dict[str, Any]:
raw = self._read_body() raw = self._read_body()
@@ -1120,6 +1174,12 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self) -> None: # noqa: N802 def do_POST(self) -> None: # noqa: N802
path = urllib.parse.urlparse(self.path).path path = urllib.parse.urlparse(self.path).path
try: try:
# **先把请求体读完并缓存**。有些 handler(如 /api/devices/connect)
# 压根不读 body;若不在这里排空,残留的字节会留在 keep-alive 连接里,
# 被当成下一个请求的请求行 —— 表现为
# 501 Unsupported method ('{}POST')
# 而且是间歇性的(取决于客户端是否复用连接),很难查。
self._body_cache = self._read_body()
self._check_origin() self._check_origin()
self._check_host() self._check_host()
if path.startswith("/api/"): if path.startswith("/api/"):
+122 -26
View File
@@ -20,6 +20,7 @@ from typing import Any, Callable
from . import apple_driver from . import apple_driver
from . import platform as plat from . import platform as plat
from . import protocol
# 颜色(与 web 端保持同一套语义色) # 颜色(与 web 端保持同一套语义色)
COLOR_OK = "#1a7f37" 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]: def _check_permission() -> tuple[bool, str]:
if plat.is_admin(): if plat.is_admin():
return True, "已以管理员身份运行" 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]: def _check_device() -> tuple[bool, str]:
@@ -161,20 +178,29 @@ def _check_device() -> tuple[bool, str]:
def _check_network() -> tuple[bool, str]: def _check_network() -> tuple[bool, str]:
"""Apple TSS 签名需要联网(挂载开发者镜像时用)。""" """检查两个**用途不同**的网络依赖,分开报,不要混为一谈。
- ``gs.apple.com``:挂载开发者镜像时向 Apple 申请 TSS 签名,**必须**能连。
- GitHub / jsDelivr:只在「开发者镜像缺失、需要现下」时用;镜像已内置则无所谓。
国内经常只有 GitHub 被墙而 Apple 正常,以前那种"二选一"的判断会给出误导性结论。
"""
import socket import socket
hosts = [("gs.apple.com", 443), ("raw.githubusercontent.com", 443)] def _reachable(host: str, port: int = 443) -> bool:
reachable = []
for host, port in hosts:
try: try:
with socket.create_connection((host, port), timeout=6): with socket.create_connection((host, port), timeout=6):
reachable.append(host) return True
except OSError: except OSError:
continue return False
if reachable:
return True, "可连通(" + "、".join(reachable) + ")" apple_ok = _reachable("gs.apple.com")
return False, "无法连接 Apple 服务器(挂载开发者镜像时需要联网签名)" 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]: def build_items() -> list[EnvItem]:
@@ -204,8 +230,9 @@ def build_items() -> list[EnvItem]:
)) ))
items.append(EnvItem( items.append(EnvItem(
"permission", "管理员权限", "permission", "管理员权限",
"安装驱动与系统级操作需要", "安装 Apple 驱动需要;虚拟定位本身不需要",
_check_permission, None, required=True, _check_permission, _install_permission, required=True,
install_label="重启提权",
)) ))
items.append(EnvItem( items.append(EnvItem(
"device", "iPhone 连接", "device", "iPhone 连接",
@@ -253,9 +280,34 @@ class SetupWindow:
root.resizable(False, True) root.resizable(False, True)
self._fit_to_screen(root) self._fit_to_screen(root)
root.protocol("WM_DELETE_WINDOW", self._on_close) 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() 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 @staticmethod
def _fit_to_screen(window: Any, min_width: int = 820, min_height: int = 560) -> None: 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 = tk.Frame(self.root, bg=COLOR_BG)
header.pack(fill="x", padx=20, pady=(16, 6)) header.pack(fill="x", padx=20, pady=(16, 6))
tk.Label(header, text=title, font=("Microsoft YaHei UI", 15, "bold"), title_row = tk.Frame(header, bg=COLOR_BG)
bg=COLOR_BG).pack(anchor="w") 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( tk.Label(
header, header,
text="首次使用需要准备以下组件。缺失的项目点右侧按钮即可自动安装;" text="首次使用需要准备以下组件。缺失的项目点右侧按钮即可自动安装;"
@@ -369,6 +426,20 @@ class SetupWindow:
command=self.refresh_all) command=self.refresh_all)
self.recheck_button.pack(side="right") 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: def append_log(self, text: str) -> None:
self.log.configure(state="normal") self.log.configure(state="normal")
@@ -439,10 +510,21 @@ class SetupWindow:
except Exception as exc: except Exception as exc:
ok, detail = False, f"安装异常:{exc}" ok, detail = False, f"安装异常:{exc}"
self.events.put(("install", (item.key, ok, detail))) 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)) self.events.put(("installs_done", None))
# ---------------- UI 线程泵 ---------------- # ---------------- UI 线程泵 ----------------
def _pump(self) -> None: def _pump(self) -> None:
# 窗口一旦销毁(点「进入程序」/关闭窗口/提权重启),就不要再碰控件:
# 否则队列里排着的 after 回调会在 Tk 里报 invalid command name ..._pump。
try:
if not self.root.winfo_exists():
return
except Exception:
return
try: try:
while True: while True:
kind, payload = self.events.get_nowait() kind, payload = self.events.get_nowait()
@@ -450,6 +532,9 @@ class SetupWindow:
key, ok, detail = payload key, ok, detail = payload
self.state[key] = {"ok": ok, "detail": detail, "busy": False} self.state[key] = {"ok": ok, "detail": detail, "busy": False}
self._render(key) self._render(key)
# 每完成一项就刷新底部:否则必需项都绿了,
# 还要等可选项(网络探测可能 3×6s / PnP 45s)跑完按钮才亮。
self._update_footer()
elif kind == "checks_done": elif kind == "checks_done":
self._update_footer() self._update_footer()
elif kind == "busy": elif kind == "busy":
@@ -471,10 +556,16 @@ class SetupWindow:
self.refresh_all() self.refresh_all()
elif kind == "log": elif kind == "log":
self.append_log(payload) self.append_log(payload)
elif kind == "close":
# 提权重启:关掉本窗口。main() 看到 proceeded=False 会正常退出,
# 由刚拉起的提权实例继续跑(它自己会再弹一次本窗口,那时已是管理员)。
self.proceeded = False
self._destroy()
return
except queue.Empty: except queue.Empty:
pass pass
if not self.proceeded: if not self.proceeded:
self.root.after(80, self._pump) self._schedule_pump()
# ---------------- 渲染 ---------------- # ---------------- 渲染 ----------------
def _render(self, key: str) -> None: def _render(self, key: str) -> None:
@@ -521,9 +612,20 @@ class SetupWindow:
self.state[key]["detail"] = detail self.state[key]["detail"] = detail
self._render(key) 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: def _update_footer(self) -> None:
required = [i for i in self.items if i.required] required = [i for i in self.items if self._needs_item(i)]
self.state.get("__x__")
pending = [i for i in required if self.state[i.key].get("ok") is not True] pending = [i for i in required if self.state[i.key].get("ok") is not True]
optional_bad = [i for i in self.items optional_bad = [i for i in self.items
if not i.required and self.state[i.key].get("ok") is False] if not i.required and self.state[i.key].get("ok") is False]
@@ -554,17 +656,11 @@ class SetupWindow:
# ---------------- 结束 ---------------- # ---------------- 结束 ----------------
def _on_confirm(self) -> None: def _on_confirm(self) -> None:
self.proceeded = True self.proceeded = True
try: self._destroy()
self.root.destroy()
except Exception:
pass
def _on_close(self) -> None: def _on_close(self) -> None:
self.proceeded = False self.proceeded = False
try: self._destroy()
self.root.destroy()
except Exception:
pass
def run_setup_window(title: str = "环境准备", auto_install_missing: bool = False) -> bool | None: def run_setup_window(title: str = "环境准备", auto_install_missing: bool = False) -> bool | None:
+48 -9
View File
@@ -137,7 +137,23 @@ class TunnelManager:
@property @property
def ready(self) -> bool: def ready(self) -> bool:
with self._lock: 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: def address(self) -> tuple[str, int] | None:
with self._lock: with self._lock:
@@ -392,11 +408,25 @@ class TunnelManager:
return self.snapshot() return self.snapshot()
def stop(self) -> None: 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: with self._lock:
tunnel = self._userspace_tunnel tunnel = self._userspace_tunnel
self._userspace_tunnel = None self._userspace_tunnel = None
self._rsd = 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: if tunnel is not None:
try: try:
if self._runner is not None: if self._runner is not None:
@@ -407,21 +437,30 @@ class TunnelManager:
self._emit("warn", f"释放用户态隧道时出错(可忽略):{exc}") self._emit("warn", f"释放用户态隧道时出错(可忽略):{exc}")
# ---- macOS:结束子进程 ---- # ---- macOS:结束子进程 ----
with self._lock:
process = self._process
self._process = None
if process is not None and process.poll() is None: if process is not None and process.poll() is None:
plat.terminate_process_tree(process) plat.terminate_process_tree(process)
with self._lock: with self._lock:
self.host = None if self.state == "starting":
self.port = None
self.mode = None
if self.state in ("connected", "starting", "error", "unsupported"):
self.state = "idle" self.state = "idle"
self.message = _default_message("idle") self.message = _default_message("idle")
_write_pidfile([]) _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: async def _close_userspace(tunnel: Any) -> None:
"""释放 userspace 隧道。 """释放 userspace 隧道。
+176
View File
@@ -0,0 +1,176 @@
# v1.1.1 变更清单(Windows)
> 本次只在 Windows 端更新。macOS 端尚未重新构建,等你更新 mac 后一起发布。
> 版本号 `1.1.0` → `1.1.1`(bug 修复 + 交互改进)。
---
## 一、修复的 Bug
### 严重(会导致功能错误 / 给错设备下发定位)
**1. 切换设备时复用了上一台设备的 RSD → 给错误的手机下发定位**
`core/server.py: provider_for()`
原逻辑只在「没有任何隧道」时才调 `tunnel.ensure(device.udid)`。如果先连 A 再选 B,
隧道状态仍是 connected,于是 B 的 provider 拿到的是 **A 的 RSD 对象** ——
DVT 流量全发到 A,而界面显示的是 B,没有任何地方会报错。
修复:无条件调用 `tunnel.ensure(device.udid)`,由它内部比对 UDID 并决定是否重建。
**2. `tunnel.stop()` 竞态:释放期间 `ready` 仍为 True 但 `rsd_object()` 已是 None**
`core/tunnel.py: stop()`
原逻辑先清 `_rsd`,最后才清 `host/port/state`。中间那段时间(最长 25 秒)并发的
`provider_for` 会走到 `address()` 回退分支,拿用户态隧道地址去做**内核 TCP 连接**,
必然报 `OSError [WinError 1231] 网络位置不可达`。
修复:在锁内一次性把 state / host / port / rsd 全部清掉再释放资源。
另外新增 `invalidate(reason)`,用于把隧道标记为失效但不释放资源。
**3. 隧道静默断开后仍然报告 connected,持续注入已死的 RSD**
`core/tunnel.py: ready`
pymobiledevice3 的 `UserspaceRsdTunnel` 在 USB 拔出/传输中断时会自己 `aclose()`
并把内部 `rsd` 置空,但 `TunnelManager` 还留着旧引用。表现为界面显示「已连接」,
但怎么操作都不生效,只能手动断开重连。
修复:`ready` 增加健康检查 —— `self._userspace_tunnel.rsd is None` 时判定为不可用,
下一次 `ensure()` 会重建隧道。新增 `is_alive()` 供上层判断。
**4. AMDS 已装好、只是 27015 探针失败时,会去下 193MB 的 iTunes**
`core/apple_driver.py: ensure()`
服务装着且在运行、但 usbmuxd 协议往返失败时,原逻辑直接进入安装阶梯:
先跑 38MB 的 AMDS MSI(返回 1638「已安装」被当成成功),复检仍不通,
于是继续下载 193MB 的完整 iTunes 包,再试 winget。整个过程纯属浪费。
修复:**服务存在时先重启服务**(最多两次,带退避),成功即返回;
仍不通则给出明确的排查建议(防火墙/安全软件拦截 127.0.0.1:27015、重插数据线),
而不是盲目重装。新增 `restart_service()`。
### 中等
**5. `clear_location()` 之后保活任务被永久停掉**
`core/location_provider.py`
`clear_location()` 里调了 `_stop_keepalive()`,之后再次 `set_location` 也**不会重启**
保活任务 —— 等于「设了点但暂停/静止」这个最需要保活的场景彻底失去保护,
而保活正是 Windows 用户态隧道防「静默断线导致手机恢复真实定位」的关键。
修复:`clear_location` 不再停保活(循环本来就会在 `_last_location is None` 时空转);
`set_location` 幂等地重新拉起任务兜底。
**6. DDI 暂存选错目录 → 可能拷进残缺素材,挂载必失败**
`core/server.py: _stage_personalized_ddi()`
原逻辑「找到第一个有 `Image.dmg` 的目录就用」,但只有三件齐全(Image.dmg /
BuildManifest.plist / Image.dmg.trustcache)才能挂载。如果打包内置目录缺 trustcache,
会拷一份残缺素材进缓存,然后挂载失败。
修复:只挑选**三件齐全**的目录(与 `apple_driver.ddi_status()` 判定一致),
拷贝后再校验一次落地完整性,缺任何一件就明确报错并返回 False。
**7. 「进入程序」被可选项拖住 / 「管理员权限」在不需要时也阻塞**
`core/setup_window.py`
- 底部按钮原本只在全部检查结束后才刷新,可选项(网络探测 3×6s、PnP 最长 45s)
没跑完时,即使必需项全绿按钮也是灰的。修复:每完成一项就刷新底部。
- 「管理员权限」原本恒为必需。但 userspace 隧道本身**不需要管理员**,
只有「需要安装 Apple 驱动」时才真的需要。源码模式 / 非管理员启动会被锁死。
修复:`_needs_item()` —— 驱动已就绪时,权限项不再阻塞「进入程序」。
**8. 连接失败时 provider 泄漏 + 启动后重复探测驱动**
`core/server.py` / `main.py`
- `provider_for` 里会话建立失败后,新建的 provider 未关闭(DVT/lockdown 泄漏)。
修复:失败时先关闭再抛出。
- 环境窗口刚做过完整驱动检查,`main()` 启动后又跑一遍深度探测
(sc + 27015 + Get-PnpDevice + 注册表)并可能再次进入安装阶梯。
修复:`setup_result is True` 时跳过重复探测。
**9. keep-alive 连接上「不读 body 的接口」会污染下一个请求**
`core/server.py`
`/api/devices/connect`、`/api/devices/disconnect`、`/api/tunnel`
这些 handler 从不读请求体。在 HTTP/1.1 keep-alive 连接上,没被读走的 `{}`
留在 socket 缓冲区,被 `BaseHTTPRequestHandler` 当成下一个请求的请求行:
```
501 Unsupported method ('{}POST')
```
**间歇性**出现(取决于客户端是否复用连接),最终用户看到的是"偶发操作失败"。
这是我在打包版冒烟测试里实际撞到的。
修复:`do_POST` 进入路由前先把请求体读完并缓存;`handle_one_request` 每请求重置缓存
(否则第二个请求会读到上一个请求的 body —— 这是修复第一版引入的新隐患,已一并处理)。
新增回归脚本 `tools/keepalive_probe.py`(同连接连发 3 个 POST 验证不串)。
### 轻微
**9. 窗口销毁后 Tk 报 `invalid command name "..._pump"`**
`core/setup_window.py`
`_pump` 的 `after` 回调在窗口销毁后仍在队列里。命令行模式下会刷错误。
修复:记录 after id,销毁前取消;并在 `_pump` 开头检查 `winfo_exists()`。
**10. 日志文件无限增长**
`core/server.py`
`app.log` 只追加不滚动,长期使用会越来越大。
修复:启动时若超过 5MB 则滚动为 `app.log.1`(保留一份旧日志)。
**11. 死代码清理**
- `setup_window._update_footer` 里的 `self.state.get("__x__")`(无意义表达式)
- `apple_driver.ensure` 里自相矛盾的 `if result.get("ok"): if name=="msi" and not result.get("ok")`
- `apple_driver.ensure` 里 `service_state()` 的空调用
- `main.py` 的 `driver_state` 字典(写入但从未读取)
- `server._stage_personalized_ddi` 尾部整段不可达代码(残留的 `_cmd_mount_status` 函数体)
---
## 二、功能改进
| 改进 | 说明 |
| --- | --- |
| 环境窗口显示版本号 | 标题旁显示 `v1.1.1 · Windows`,便于确认版本 |
| 环境窗口「打开日志目录」 | 一键打开 `%LOCALAPPDATA%\iOSLocationTrack\logs`,装驱动失败时可自行取证 |
| 网络检查更准确 | 分成「Apple TSS 签名」(必须)和「镜像下载源」(可选)两条,不再把 GitHub 被墙误报成"连不上 Apple" |
| 「重启提权」按钮 | 权限项右侧按钮,点击后通过 UAC 拉起管理员实例,本实例自动退出 |
| `--check-driver` 更完整 | 增加开发者镜像状态与目录、日志目录信息 |
| 驱动安装失败提示 | 明确提示「需要重启 Windows 后生效」而不是笼统的失败 |
---
## 三、验收
```
tests/selftest.py 83/83 PASS
tests/drift_sim.py 40/40 PASS
tests/offline_tiles.py 13/13 PASS
tests/setup_window_selftest.py 7/7 PASS(新增:环境窗口检测→确认→放行全流程)
tests/win_e2e.py 7/7 PASS(真机 iPhone 11 / iOS 26.6.1)
tests/win_app_e2e.py 9/9 PASS(真机,走 App 真实装配路径)
```
打包版 exe 实测:发现设备 → 0.3s 隧道 → DVT → 两次下发 → 恢复真实定位 → 干净退出。
---
## 四、已知未修(留待后续)
审查还提了几项,本轮未动(风险或成本较高,且不影响当前功能):
| 项 | 说明 |
| --- | --- |
| 保活取消的竞态 | `_stop_keepalive()` 只 `cancel()` 不 await,理论上可能与 `clear()/close()` 抢同一个 DTX 通道;窗口极小 |
| `probe()` 失败路径泄漏 lockdown | iOS<17 老路径才有,且失败即报错,影响有限 |
| `provider_for` 长时间持锁 | 连接期间最长持锁约 2 分钟,并发 disconnect/shutdown 会阻塞(无死锁) |
| DDI 下载无 SHA 校验 | 镜像源可变,目前只按文件大小判断完整性 |
| `console=False` 隐藏 CLI 输出 | 打包版 `--check-driver` 的输出看不到,需要另开控制台版本 |
| Win32 调用缺 argtypes | ctypes 未声明原型,当前 64 位下实测正常,但属未定义行为 |
其中建议**下一轮优先**处理:隧道断线自动重连(目前仍需手动断开重连)、
打包版 CLI 输出可见性。
+2 -2
View File
@@ -10,7 +10,7 @@
## 1. Quick start (3 minutes) ## 1. Quick start (3 minutes)
1. Run `iOSLocationTrack-1.1.0_win-setup.exe`, or unzip the portable build and 1. Run `iOSLocationTrack-1.1.1_win-setup.exe`, or unzip the portable build and
double-click `iOSLocationTrack.exe`. double-click `iOSLocationTrack.exe`.
2. **The app requests administrator rights** — click *Yes* on the UAC prompt. 2. **The app requests administrator rights** — click *Yes* on the UAC prompt.
This is only needed so it can auto-install the Apple driver on first run. This is only needed so it can auto-install the Apple driver on first run.
@@ -199,7 +199,7 @@ Outputs:
| File | Notes | | File | Notes |
| --- | --- | | --- | --- |
| `dist\iOSLocationTrack\iOSLocationTrack.exe` | Portable onedir build — copy the whole folder | | `dist\iOSLocationTrack\iOSLocationTrack.exe` | Portable onedir build — copy the whole folder |
| `dist\installer\iOSLocationTrack-1.1.0_win-setup.exe` | Installer (Inno Setup 6) | | `dist\installer\iOSLocationTrack-1.1.1_win-setup.exe` | Installer (Inno Setup 6) |
**Prerequisites:** **Prerequisites:**
+2 -2
View File
@@ -9,7 +9,7 @@
## 1. 三分钟上手 ## 1. 三分钟上手
1. 运行 `iOSLocationTrack-1.1.0_win-setup.exe` 安装,或直接双击绿色版 1. 运行 `iOSLocationTrack-1.1.1_win-setup.exe` 安装,或直接双击绿色版
`dist\iOSLocationTrack\iOSLocationTrack.exe`。 `dist\iOSLocationTrack\iOSLocationTrack.exe`。
2. **程序会请求管理员权限**(UAC 弹窗点「是」)—— 用于首次自动安装 Apple 驱动。 2. **程序会请求管理员权限**(UAC 弹窗点「是」)—— 用于首次自动安装 Apple 驱动。
3. 弹出**「环境准备」**窗口,逐项列出所需组件: 3. 弹出**「环境准备」**窗口,逐项列出所需组件:
@@ -185,7 +185,7 @@ Windows 上这样行不通:
| 文件 | 说明 | | 文件 | 说明 |
| --- | --- | | --- | --- |
| `dist\iOSLocationTrack\iOSLocationTrack.exe` | 绿色版 onedir,整个目录一起拷走 | | `dist\iOSLocationTrack\iOSLocationTrack.exe` | 绿色版 onedir,整个目录一起拷走 |
| `dist\installer\iOSLocationTrack-1.1.0_win-setup.exe` | 安装包(Inno Setup 6) | | `dist\installer\iOSLocationTrack-1.1.1_win-setup.exe` | 安装包(Inno Setup 6) |
**前置条件:** **前置条件:**
+2 -2
View File
@@ -4,7 +4,7 @@
; 1) .venv\Scripts\python.exe -m PyInstaller --clean --noconfirm Location.spec ; 1) .venv\Scripts\python.exe -m PyInstaller --clean --noconfirm Location.spec
; 2) "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" installer\iosloc.iss ; 2) "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" installer\iosloc.iss
; ;
; 产物:dist\installer\iOSLocationTrack-1.1.0_win-setup.exe ; 产物:dist\installer\iOSLocationTrack-1.1.1_win-setup.exe
; ;
; 说明: ; 说明:
; - 安装包**不包含** Apple 的驱动。程序首次启动时会自动检测 Apple Mobile Device ; - 安装包**不包含** Apple 的驱动。程序首次启动时会自动检测 Apple Mobile Device
@@ -14,7 +14,7 @@
#define MyAppName "iOS 虚拟定位轨迹工具" #define MyAppName "iOS 虚拟定位轨迹工具"
#define MyAppNameEn "iOSLocationTrack" #define MyAppNameEn "iOSLocationTrack"
#define MyAppVersion "1.1.0" #define MyAppVersion "1.1.1"
#define MyAppPublisher "本地工具" #define MyAppPublisher "本地工具"
#define MyAppExeName "iOSLocationTrack.exe" #define MyAppExeName "iOSLocationTrack.exe"
#define DistDir "..\dist\iOSLocationTrack" #define DistDir "..\dist\iOSLocationTrack"
+4 -4
View File
@@ -2,8 +2,8 @@
# 语法见 https://learn.microsoft.com/windows/win32/menurc/versioninfo-resource # 语法见 https://learn.microsoft.com/windows/win32/menurc/versioninfo-resource
VSVersionInfo( VSVersionInfo(
ffi=FixedFileInfo( ffi=FixedFileInfo(
filevers=(1, 1, 0, 0), filevers=(1, 1, 1, 0),
prodvers=(1, 1, 0, 0), prodvers=(1, 1, 1, 0),
mask=0x3f, mask=0x3f,
flags=0x0, flags=0x0,
OS=0x40004, OS=0x40004,
@@ -18,12 +18,12 @@ VSVersionInfo(
[ [
StringStruct('CompanyName', '本地工具'), StringStruct('CompanyName', '本地工具'),
StringStruct('FileDescription', 'iOS 虚拟定位轨迹工具'), StringStruct('FileDescription', 'iOS 虚拟定位轨迹工具'),
StringStruct('FileVersion', '1.1.0.0'), StringStruct('FileVersion', '1.1.1.0'),
StringStruct('InternalName', 'iOSLocationTrack'), StringStruct('InternalName', 'iOSLocationTrack'),
StringStruct('LegalCopyright', '本地工具,用于自有 App 的定位功能测试'), StringStruct('LegalCopyright', '本地工具,用于自有 App 的定位功能测试'),
StringStruct('OriginalFilename', 'iOSLocationTrack.exe'), StringStruct('OriginalFilename', 'iOSLocationTrack.exe'),
StringStruct('ProductName', 'iOS 虚拟定位轨迹工具'), StringStruct('ProductName', 'iOS 虚拟定位轨迹工具'),
StringStruct('ProductVersion', '1.1.0.0'), StringStruct('ProductVersion', '1.1.1.0'),
], ],
) )
]), ]),
+20 -7
View File
@@ -22,7 +22,6 @@ import time
import urllib.request import urllib.request
import webbrowser import webbrowser
from pathlib import Path from pathlib import Path
from typing import Any
from core import platform as plat # noqa: E402 from core import platform as plat # noqa: E402
@@ -171,6 +170,16 @@ def run_check_driver(install: bool) -> int:
print(f" · {item['serial']}({item['connection']})") print(f" · {item['serial']}({item['connection']})")
print(f"结论:{status['summary']}") print(f"结论:{status['summary']}")
# 开发者镜像(iOS 17+ 虚拟定位的前提,独立于 Apple 驱动)
try:
ddi = apple_driver.ddi_status()
state = "已就绪" if ddi["ok"] else ("缺失:" + "、".join(ddi["missing"]))
print(f"开发者镜像:{state}({ddi['total_mb']} MB)")
print(f" 目录:{ddi['dir']}")
except Exception as exc:
print(f"开发者镜像:检测失败({exc})")
print(f"日志目录:{plat.data_dir() / 'logs'}")
if status.get("ready"): if status.get("ready"):
return 0 return 0
if not install: if not install:
@@ -315,6 +324,7 @@ def main(argv: list[str] | None = None) -> int:
# ---------------- 启动时的「环境准备」窗口 ---------------- # ---------------- 启动时的「环境准备」窗口 ----------------
# 每次打开都显示:逐项列出依赖,缺的可以直接点右侧按钮安装, # 每次打开都显示:逐项列出依赖,缺的可以直接点右侧按钮安装,
# 必需项全部就绪后「进入程序」才可点击。 # 必需项全部就绪后「进入程序」才可点击。
setup_result: bool | None = None
if not args.no_setup and not mock_flag: if not args.no_setup and not mock_flag:
from core import setup_window from core import setup_window
@@ -388,20 +398,23 @@ def main(argv: list[str] | None = None) -> int:
# 关键:放到**后台线程**里做。首次运行可能要下载 40~200MB,如果同步跑, # 关键:放到**后台线程**里做。首次运行可能要下载 40~200MB,如果同步跑,
# 界面会迟迟不出现(打包成窗口程序后用户看不到任何进度)。后台跑的话, # 界面会迟迟不出现(打包成窗口程序后用户看不到任何进度)。后台跑的话,
# 界面已经打开,安装日志会实时通过 WebSocket 推到界面上。 # 界面已经打开,安装日志会实时通过 WebSocket 推到界面上。
driver_state: dict[str, Any] = {"ok": None, "done": threading.Event()}
def _driver_worker() -> None: def _driver_worker() -> None:
ok = ensure_apple_driver(app.log, allow_install=True) ensure_apple_driver(app.log, allow_install=True)
driver_state["ok"] = ok # 驱动就绪后设备列表可能从空变成有,主动刷一次并推给界面
try: try:
app.devices.refresh() app.devices.refresh()
app._on_devices_change() app._on_devices_change()
except Exception: except Exception:
pass pass
driver_state["done"].set()
if plat.IS_WINDOWS and not mock and not args.no_driver_check: if plat.IS_WINDOWS and not mock and not args.no_driver_check:
threading.Thread(target=_driver_worker, name="apple-driver", daemon=True).start() if setup_result is True:
# 环境窗口刚刚已经做过完整的驱动检查/安装(含 sc / 27015 / PnP / 注册表),
# 这里再跑一遍纯属浪费,还可能因为状态在两次之间变化而触发安装阶梯。
app.log("info", "环境检查已完成,跳过重复的驱动探测")
else:
threading.Thread(target=_driver_worker, name="apple-driver",
daemon=True).start()
if getattr(app.config, "_repaired", False): if getattr(app.config, "_repaired", False):
app.log("warn", "config.json 中有非法项(类型/范围不合法),已自动回退为默认值") app.log("warn", "config.json 中有非法项(类型/范围不合法),已自动回退为默认值")
+1 -1
View File
@@ -2,7 +2,7 @@
# #
# 产出: # 产出:
# dist\iOSLocationTrack\iOSLocationTrack.exe 绿色版(直接双击运行) # dist\iOSLocationTrack\iOSLocationTrack.exe 绿色版(直接双击运行)
# dist\installer\iOSLocationTrack-1.1.0_win-setup.exe 安装包(需要 Inno Setup 6) # dist\installer\iOSLocationTrack-1.1.1_win-setup.exe 安装包(需要 Inno Setup 6)
# #
# 用法(在项目根目录): # 用法(在项目根目录):
# .\scripts\build.ps1 # 打包 exe + 安装包(若装了 Inno Setup) # .\scripts\build.ps1 # 打包 exe + 安装包(若装了 Inno Setup)
+119
View File
@@ -0,0 +1,119 @@
"""自动验证「环境准备」窗口的检测 → 确认 → 放行 流程(不需要人工点按钮)。
验证点:
1. 所有项最终都能检测完(不会卡在「检测中…」)
2. 全部就绪时,「进入程序」按钮变为可用
3. 调用 _on_confirm 后 proceeded=True(main() 才会继续启动服务)
4. 关闭窗口(_on_close)时 proceeded=False(main() 应当退出)
"""
from __future__ import annotations
import sys
import time
import traceback
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
import tkinter as tk # noqa: E402
from core.setup_window import SetupWindow # noqa: E402
FAILURES: list[str] = []
CALLBACK_ERRORS: list[str] = []
def check(name: str, ok: bool, detail: str = "") -> None:
print(f" [{'PASS' if ok else 'FAIL'}] {name}" + (f" — {detail}" if detail else ""))
if not ok:
FAILURES.append(name)
def pump(root: tk.Tk, seconds: float) -> None:
"""手动驱动 tkinter 事件循环(不进入 mainloop,便于断言)。"""
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
try:
root.update()
except tk.TclError as exc:
print(f" [pump] TclError 中断:{exc}")
return
time.sleep(0.02)
def main() -> int:
root = tk.Tk()
def _on_cb_error(exc_type, exc_value, exc_tb) -> None: # Tk 回调里的异常
text = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
CALLBACK_ERRORS.append(text)
print(f" [回调异常] {exc_value}")
root.report_callback_exception = _on_cb_error
win = SetupWindow(root, title="自检窗口")
# Apple 驱动检测要做 27015 协议往返,留足时间
pump(root, 25.0)
print(" 各项状态:")
for item in win.items:
st = win.state[item.key]
print(f" {item.key:16s} required={item.required} ok={st.get('ok')} "
f"busy={st.get('busy')} detail={str(st.get('detail'))[:46]}")
try:
exists = bool(root.winfo_exists())
except tk.TclError as exc:
exists = False
print(f" 窗口已不可用:{exc}")
print(f" 窗口存在:{exists} proceeded={win.proceeded}")
check("窗口在检测期间没有被意外销毁", exists)
check("Tk 回调没有抛异常", not CALLBACK_ERRORS,
f"{len(CALLBACK_ERRORS)} 个" if CALLBACK_ERRORS else "")
stuck = [i.key for i in win.items
if win.state[i.key].get("ok") is None and not win.state[i.key].get("busy")]
check("没有卡在「检测中…」的项", not stuck, str(stuck))
required = [i for i in win.items if i.required]
pending = [i.key for i in required if win.state[i.key].get("ok") is not True]
check("必需项全部就绪(本机环境)", not pending, str(pending))
try:
enabled = bool(win.confirm_button.instate(["!disabled"]))
detail = f"state={win.confirm_button.state()}"
except tk.TclError as exc:
enabled, detail = False, f"控件已失效:{exc}"
check("「进入程序」按钮可用", enabled, detail)
# 模拟点击「进入程序」
win._on_confirm()
check("点击后 proceeded=True", win.proceeded is True, f"proceeded={win.proceeded}")
try:
root.destroy()
except Exception:
pass
# 再测「关闭窗口」分支
root2 = tk.Tk()
win2 = SetupWindow(root2, title="自检窗口2")
pump(root2, 1.0)
win2._on_close()
check("关闭窗口时 proceeded=False", win2.proceeded is False,
f"proceeded={win2.proceeded}")
try:
root2.destroy()
except Exception:
pass
print()
if FAILURES:
print(f"失败 {len(FAILURES)} 项:{FAILURES}")
return 1
print("全部通过")
return 0
if __name__ == "__main__":
sys.exit(main())
+112
View File
@@ -0,0 +1,112 @@
"""验证 keep-alive 连接上「不读 body 的接口」不会污染下一个请求。
背景(真实 bug):`/api/devices/connect` 的 handler 从不读请求体。
在 HTTP/1.1 keep-alive 连接上,没读走的 `{}` 会留在 socket 缓冲区里,
被 BaseHTTPRequestHandler 当成下一个请求的请求行,于是登录到:
501 Unsupported method ('{}POST')
而且是**间歇性**的(取决于客户端是否复用连接),非常难查。
修法:do_POST 进入路由前先把 body 读完缓存起来。
用法:先起一个 mock 服务,再跑本脚本。
.venv\\Scripts\\python.exe tools\\_keepalive_probe.py --port 8855
"""
from __future__ import annotations
import argparse
import json
import socket
import sys
def build_post(host: str, port: int, path: str, body: str) -> bytes:
payload = body.encode("utf-8")
head = (
f"POST {path} HTTP/1.1\r\n"
f"Host: {host}:{port}\r\n"
f"Content-Type: application/json\r\n"
f"Content-Length: {len(payload)}\r\n"
f"Connection: keep-alive\r\n"
f"\r\n"
).encode("ascii")
return head + payload
def recv_response(sock: socket.socket) -> str:
"""读一个完整响应(按 Content-Length)。"""
data = b""
while b"\r\n\r\n" not in data:
chunk = sock.recv(4096)
if not chunk:
return data.decode("utf-8", "replace")
data += chunk
head, _, rest = data.partition(b"\r\n\r\n")
length = 0
for line in head.split(b"\r\n"):
if line.lower().startswith(b"content-length:"):
length = int(line.split(b":", 1)[1].strip())
break
while len(rest) < length:
chunk = sock.recv(4096)
if not chunk:
break
rest += chunk
return (head + b"\r\n\r\n" + rest).decode("utf-8", "replace")
def status_of(response: str) -> int:
first = response.split("\r\n", 1)[0]
parts = first.split()
try:
return int(parts[1])
except (IndexError, ValueError):
return -1
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8855)
args = parser.parse_args()
failures: list[str] = []
def check(name: str, ok: bool, detail: str = "") -> None:
print(f" [{'PASS' if ok else 'FAIL'}] {name}" + (f" — {detail}" if detail else ""))
if not ok:
failures.append(name)
with socket.create_connection((args.host, args.port), timeout=15) as sock:
# 第 1 个请求:body 为 {} 且服务端 handler 不读它
sock.sendall(build_post(args.host, args.port, "/api/devices/connect", "{}"))
r1 = recv_response(sock)
s1 = status_of(r1)
check("第 1 个请求(connect) 成功", s1 in (200, 409), f"status={s1}")
print(f" 第 1 个响应前 120 字符:{r1[:120]!r}")
# 第 2 个请求:如果残留的 {} 没被排空,请求行会被污染 → 501
body2 = json.dumps({"lat": 39.9087, "lng": 116.3975})
sock.sendall(build_post(args.host, args.port, "/api/location", body2))
r2 = recv_response(sock)
s2 = status_of(r2)
check("同连接第 2 个请求(location) 未被污染", s2 == 200, f"status={s2}")
if s2 != 200:
print(f" 第 2 个响应:{r2[:300]!r}")
# 第 3 个请求:再压一次,确认连续多请求也稳
sock.sendall(build_post(args.host, args.port, "/api/location", body2))
r3 = recv_response(sock)
s3 = status_of(r3)
check("同连接第 3 个请求仍然正常", s3 == 200, f"status={s3}")
print()
if failures:
print(f"失败 {len(failures)} 项:{failures}")
return 1
print("全部通过:keep-alive 上不会串请求")
return 0
if __name__ == "__main__":
sys.exit(main())
+2 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""把打包好的 .app 制作成可分发的 DMG 安装包。 """把打包好的 .app 制作成可分发的 DMG 安装包。
产物:dist/iOSLocationTrack-1.1.0_mac.dmg 产物:dist/iOSLocationTrack-1.1.1_mac.dmg
内容:应用本身 + Applications 快捷方式(拖进去即安装)+ 中文安装说明 内容:应用本身 + Applications 快捷方式(拖进去即安装)+ 中文安装说明
特点: 特点:
@@ -25,7 +25,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
APP_NAME = "虚拟定位轨迹" APP_NAME = "虚拟定位轨迹"
VERSION = "1.1.0" VERSION = "1.1.1"
APP = ROOT / "dist" / f"{APP_NAME}.app" APP = ROOT / "dist" / f"{APP_NAME}.app"
# 产物名带 _mac 后缀,与 Windows 的 _win 对应,Release 页面上一眼能分清平台。 # 产物名带 _mac 后缀,与 Windows 的 _win 对应,Release 页面上一眼能分清平台。
# 用 ASCII 文件名:GitHub Release 会把非 ASCII 字符剥掉(v1.0.0 首次上传时踩过)。 # 用 ASCII 文件名:GitHub Release 会把非 ASCII 字符剥掉(v1.0.0 首次上传时踩过)。