diff --git a/Location.spec b/Location.spec index aa0cd37..e9c3989 100644 --- a/Location.spec +++ b/Location.spec @@ -210,12 +210,12 @@ else: name="虚拟定位轨迹.app", icon=str(ROOT / "resources" / "AppIcon.icns"), bundle_identifier="com.local.ios-location-track", - version="1.1.0", + version="1.1.1", info_plist={ "CFBundleName": "虚拟定位轨迹", "CFBundleDisplayName": "虚拟定位轨迹", - "CFBundleShortVersionString": "1.1.0", - "CFBundleVersion": "1.1.0", + "CFBundleShortVersionString": "1.1.1", + "CFBundleVersion": "1.1.1", "CFBundlePackageType": "APPL", "CFBundleIconFile": "AppIcon", "CFBundleIconName": "AppIcon", diff --git a/README.en.md b/README.en.md index 7b5025e..a6194eb 100644 --- a/README.en.md +++ b/README.en.md @@ -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 > Support on first launch, and shows an environment self-check window. > 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) > 📄 Detailed tutorial: **drawing routes, polylines & starting virtual playback**: [TUTORIAL-DRAW-AND-PLAY.txt](TUTORIAL-DRAW-AND-PLAY.txt) diff --git a/README.md b/README.md index 1159dfa..63d28c7 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ > Windows 版:免管理员建立隧道(纯 Python 用户态协议栈,不需要 Wintun 驱动)、 > 启动时自动检测并安装 Apple Mobile Device Support、带「环境准备」自检窗口。 > 构建产物:`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) > 📄 详细教程:**画轨迹 · 折线绘制 · 开始虚拟轨迹**:[TUTORIAL-DRAW-AND-PLAY.txt](TUTORIAL-DRAW-AND-PLAY.txt) diff --git a/TUTORIAL-DRAW-AND-PLAY.txt b/TUTORIAL-DRAW-AND-PLAY.txt index 1a3397b..9ef0a00 100644 --- a/TUTORIAL-DRAW-AND-PLAY.txt +++ b/TUTORIAL-DRAW-AND-PLAY.txt @@ -3,7 +3,7 @@ Drawing Routes, Polylines & Starting Virtual Playback — Step-by-Step Tutorial ================================================================================ - 适用版本 / Version : 1.1.0 + 适用版本 / Version : 1.1.1 前置阅读 / First : USAGE.txt(安装、启动、连接 iPhone) 本文件纯文本、中英双语:中文在前,英文在后。 英文部分从第 652 行开始(搜「PART 2」可跳转)。 diff --git a/USAGE.txt b/USAGE.txt index 5a72f5d..77d0ae5 100644 --- a/USAGE.txt +++ b/USAGE.txt @@ -3,7 +3,7 @@ iOS Virtual Location & Route Simulator — Usage Guide ================================================================================ - 版本 / Version : 1.1.0 + 版本 / Version : 1.1.1 平台 / Platform: Windows 10 1809+ · macOS 12+ 支持 / Support : iOS 17 及以上 / iOS 17 or later 仓库 / Repo : https://github.com/jbjf6666/ios-location-track(免费 / free) @@ -48,10 +48,10 @@ 【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 即可,无需安装。 注意:程序启动时会弹「用户账户控制(UAC)」窗口,需要点「是」。 @@ -60,7 +60,7 @@ 【macOS】 - 下载 iOSLocationTrack-1.1.0_mac.dmg → 打开 → 把「虚拟定位轨迹」拖进「应用程序」。 + 下载 iOSLocationTrack-1.1.1_mac.dmg → 打开 → 把「虚拟定位轨迹」拖进「应用程序」。 首次打开若提示「来自身份不明的开发者」(因为没买 Apple 开发者签名): 在应用图标上右键 → 打开 → 再点「打开」。 @@ -277,9 +277,9 @@ debugging. [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. Note: Windows will show a User Account Control (UAC) prompt on launch — click Yes. @@ -288,7 +288,7 @@ debugging. [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): right-click the app icon -> Open -> Open. diff --git a/core/apple_driver.py b/core/apple_driver.py index 00a9736..4a239fc 100644 --- a/core/apple_driver.py +++ b/core/apple_driver.py @@ -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 diff --git a/core/location_provider.py b/core/location_provider.py index bf2ae5f..a60f426 100644 --- a/core/location_provider.py +++ b/core/location_provider.py @@ -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: diff --git a/core/protocol.py b/core/protocol.py index cc4a883..7f5191e 100644 --- a/core/protocol.py +++ b/core/protocol.py @@ -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" diff --git a/core/server.py b/core/server.py index dc337e4..10f29bc 100644 --- a/core/server.py +++ b/core/server.py @@ -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/"): diff --git a/core/setup_window.py b/core/setup_window.py index 3576b5b..55e54f6 100644 --- a/core/setup_window.py +++ b/core/setup_window.py @@ -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: diff --git a/core/tunnel.py b/core/tunnel.py index 4e704ae..b687b13 100644 --- a/core/tunnel.py +++ b/core/tunnel.py @@ -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 隧道。 diff --git a/docs/CHANGELOG-v1.1.1.md b/docs/CHANGELOG-v1.1.1.md new file mode 100644 index 0000000..9f9f022 --- /dev/null +++ b/docs/CHANGELOG-v1.1.1.md @@ -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 输出可见性。 diff --git a/docs/README-WINDOWS.en.md b/docs/README-WINDOWS.en.md index f39c63d..5fb3f73 100644 --- a/docs/README-WINDOWS.en.md +++ b/docs/README-WINDOWS.en.md @@ -10,7 +10,7 @@ ## 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`. 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. @@ -199,7 +199,7 @@ Outputs: | File | Notes | | --- | --- | | `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:** diff --git a/docs/README-WINDOWS.md b/docs/README-WINDOWS.md index cb8b54e..888c870 100644 --- a/docs/README-WINDOWS.md +++ b/docs/README-WINDOWS.md @@ -9,7 +9,7 @@ ## 1. 三分钟上手 -1. 运行 `iOSLocationTrack-1.1.0_win-setup.exe` 安装,或直接双击绿色版 +1. 运行 `iOSLocationTrack-1.1.1_win-setup.exe` 安装,或直接双击绿色版 `dist\iOSLocationTrack\iOSLocationTrack.exe`。 2. **程序会请求管理员权限**(UAC 弹窗点「是」)—— 用于首次自动安装 Apple 驱动。 3. 弹出**「环境准备」**窗口,逐项列出所需组件: @@ -185,7 +185,7 @@ Windows 上这样行不通: | 文件 | 说明 | | --- | --- | | `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) | **前置条件:** diff --git a/installer/iosloc.iss b/installer/iosloc.iss index 304f286..06accd1 100644 --- a/installer/iosloc.iss +++ b/installer/iosloc.iss @@ -4,7 +4,7 @@ ; 1) .venv\Scripts\python.exe -m PyInstaller --clean --noconfirm Location.spec ; 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 @@ -14,7 +14,7 @@ #define MyAppName "iOS 虚拟定位轨迹工具" #define MyAppNameEn "iOSLocationTrack" -#define MyAppVersion "1.1.0" +#define MyAppVersion "1.1.1" #define MyAppPublisher "本地工具" #define MyAppExeName "iOSLocationTrack.exe" #define DistDir "..\dist\iOSLocationTrack" diff --git a/installer/version_info.txt b/installer/version_info.txt index b792db5..f291921 100644 --- a/installer/version_info.txt +++ b/installer/version_info.txt @@ -2,8 +2,8 @@ # 语法见 https://learn.microsoft.com/windows/win32/menurc/versioninfo-resource VSVersionInfo( ffi=FixedFileInfo( - filevers=(1, 1, 0, 0), - prodvers=(1, 1, 0, 0), + filevers=(1, 1, 1, 0), + prodvers=(1, 1, 1, 0), mask=0x3f, flags=0x0, OS=0x40004, @@ -18,12 +18,12 @@ VSVersionInfo( [ StringStruct('CompanyName', '本地工具'), StringStruct('FileDescription', 'iOS 虚拟定位轨迹工具'), - StringStruct('FileVersion', '1.1.0.0'), + StringStruct('FileVersion', '1.1.1.0'), StringStruct('InternalName', 'iOSLocationTrack'), StringStruct('LegalCopyright', '本地工具,用于自有 App 的定位功能测试'), StringStruct('OriginalFilename', 'iOSLocationTrack.exe'), StringStruct('ProductName', 'iOS 虚拟定位轨迹工具'), - StringStruct('ProductVersion', '1.1.0.0'), + StringStruct('ProductVersion', '1.1.1.0'), ], ) ]), diff --git a/main.py b/main.py index cb646d4..763ab5f 100644 --- a/main.py +++ b/main.py @@ -22,7 +22,6 @@ import time import urllib.request import webbrowser from pathlib import Path -from typing import Any 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"结论:{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"): return 0 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: from core import setup_window @@ -388,20 +398,23 @@ def main(argv: list[str] | None = None) -> int: # 关键:放到**后台线程**里做。首次运行可能要下载 40~200MB,如果同步跑, # 界面会迟迟不出现(打包成窗口程序后用户看不到任何进度)。后台跑的话, # 界面已经打开,安装日志会实时通过 WebSocket 推到界面上。 - driver_state: dict[str, Any] = {"ok": None, "done": threading.Event()} - def _driver_worker() -> None: - ok = ensure_apple_driver(app.log, allow_install=True) - driver_state["ok"] = ok + ensure_apple_driver(app.log, allow_install=True) + # 驱动就绪后设备列表可能从空变成有,主动刷一次并推给界面 try: app.devices.refresh() app._on_devices_change() except Exception: pass - driver_state["done"].set() 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): app.log("warn", "config.json 中有非法项(类型/范围不合法),已自动回退为默认值") diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 7e58d9a..9ce8940 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -2,7 +2,7 @@ # # 产出: # 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) diff --git a/tests/setup_window_selftest.py b/tests/setup_window_selftest.py new file mode 100644 index 0000000..73d8bcc --- /dev/null +++ b/tests/setup_window_selftest.py @@ -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()) diff --git a/tools/keepalive_probe.py b/tools/keepalive_probe.py new file mode 100644 index 0000000..2e0ada9 --- /dev/null +++ b/tools/keepalive_probe.py @@ -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()) diff --git a/tools/make_dmg.py b/tools/make_dmg.py index eb5e18b..fddc7ed 100644 --- a/tools/make_dmg.py +++ b/tools/make_dmg.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """把打包好的 .app 制作成可分发的 DMG 安装包。 -产物:dist/iOSLocationTrack-1.1.0_mac.dmg +产物:dist/iOSLocationTrack-1.1.1_mac.dmg 内容:应用本身 + Applications 快捷方式(拖进去即安装)+ 中文安装说明 特点: @@ -25,7 +25,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent APP_NAME = "虚拟定位轨迹" -VERSION = "1.1.0" +VERSION = "1.1.1" APP = ROOT / "dist" / f"{APP_NAME}.app" # 产物名带 _mac 后缀,与 Windows 的 _win 对应,Release 页面上一眼能分清平台。 # 用 ASCII 文件名:GitHub Release 会把非 ASCII 字符剥掉(v1.0.0 首次上传时踩过)。