Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6880efdc6 | ||
|
|
4a5d676c7b | ||
|
|
bbf8a4fac4 | ||
|
|
94c152ba00 | ||
|
|
7aed7a05c7 |
No files matched your search
@@ -36,6 +36,10 @@
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
|
||||
# 面向 Windows 用户「直接用记事本打开」的纯文本说明:CRLF + UTF-8 BOM。
|
||||
# git 不碰 BOM;这里只锁换行符。(后面的规则覆盖前面的,所以放在 *.txt 之后。)
|
||||
USAGE.txt text eol=crlf
|
||||
|
||||
# 安装包脚本:CRLF
|
||||
*.iss text eol=crlf
|
||||
*.isl text eol=crlf
|
||||
@@ -55,3 +59,4 @@
|
||||
*.zip binary
|
||||
*.plist binary
|
||||
*.trustcache binary
|
||||
TUTORIAL-DRAW-AND-PLAY.txt text eol=crlf
|
||||
+6
-3
@@ -115,6 +115,9 @@ _datas = _metadata_datas + _extra_datas + [
|
||||
if IS_WINDOWS:
|
||||
_datas += [
|
||||
("resources/AppIcon.ico", "resources"),
|
||||
# 中英双语使用说明,随包一起装到安装目录,用户直接就能看(记事本友好:CRLF + BOM)
|
||||
("USAGE.txt", "."),
|
||||
("TUTORIAL-DRAW-AND-PLAY.txt", "."),
|
||||
("docs/README-WINDOWS.md", "docs") if (ROOT / "docs" / "README-WINDOWS.md").exists() else ("README.md", "docs"),
|
||||
]
|
||||
else:
|
||||
@@ -207,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",
|
||||
|
||||
+4
-1
@@ -13,7 +13,10 @@ 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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
> 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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,505 @@
|
||||
================================================================================
|
||||
iOS 虚拟定位轨迹工具 — 使用方法
|
||||
iOS Virtual Location & Route Simulator — Usage Guide
|
||||
================================================================================
|
||||
|
||||
版本 / 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)
|
||||
|
||||
本文件为纯文本,中英双语:先中文,后英文(英文部分从第 256 行开始)。
|
||||
This is a plain-text bilingual guide: Chinese first, English from line 256.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
目录 / Contents
|
||||
--------------------------------------------------------------------------------
|
||||
1. 这是什么 / What it is
|
||||
2. 安装 / Install
|
||||
3. 第一次启动 / First launch
|
||||
4. 连接 iPhone / Connect your iPhone
|
||||
5. 开始使用 / Basic usage
|
||||
6. 界面功能 / Feature reference
|
||||
7. 数据与日志在哪 / Data & log locations
|
||||
8. 常见问题 / Troubleshooting
|
||||
9. 已知限制 / Known limitations
|
||||
|
||||
|
||||
================================================================================
|
||||
第一部分:中文
|
||||
================================================================================
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
1. 这是什么
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
在电脑上给 iPhone / iPad 做「虚拟定位」和「轨迹模拟」的桌面工具。
|
||||
|
||||
你可以在地图上画一条路线,加上途经点,然后让手机按真实速度沿着这条路线"跑"起来。
|
||||
主要用于测试依赖定位的 App(校园跑、打卡、打车、外卖、运动记录等),不用真的出门。
|
||||
|
||||
它不是"破解"类工具:走的完全是 Apple 官方的开发者接口(开发者镜像 + DVT 定位服务),
|
||||
和 Xcode 里做定位调试用的是同一套东西。
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
2. 安装
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
【Windows】
|
||||
|
||||
方式一(推荐):下载 iOSLocationTrack-1.1.1_win-setup.exe
|
||||
双击安装。安装向导是中文的。
|
||||
|
||||
方式二:下载 iOSLocationTrack-1.1.1_win-portable.zip
|
||||
解压到任意目录,双击里面的 iOSLocationTrack.exe 即可,无需安装。
|
||||
|
||||
注意:程序启动时会弹「用户账户控制(UAC)」窗口,需要点「是」。
|
||||
这一步是为了让它能自动安装 Apple 设备驱动(见下一节),
|
||||
虚拟定位本身并不需要管理员权限。
|
||||
|
||||
【macOS】
|
||||
|
||||
下载 iOSLocationTrack-1.1.1_mac.dmg → 打开 → 把「虚拟定位轨迹」拖进「应用程序」。
|
||||
|
||||
首次打开若提示「来自身份不明的开发者」(因为没买 Apple 开发者签名):
|
||||
在应用图标上右键 → 打开 → 再点「打开」。
|
||||
只需做一次,之后正常双击即可。
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
3. 第一次启动
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
【Windows】会先弹出「环境准备」窗口,逐项检查所需的组件:
|
||||
|
||||
✓ 绿色对勾 = 已就绪,右边按钮显示「已就绪」(不可点)
|
||||
✗ 红色叉号 = 缺失,右边有可点的「安装 / 下载」按钮
|
||||
· 灰色圆点 = 可选项,没就绪也不影响使用
|
||||
|
||||
必需项一共 6 个,全部变绿后,右下角的「进入程序」按钮才会亮起:
|
||||
|
||||
1) Python 运行时 程序自带,不用管
|
||||
2) pymobiledevice3 程序自带,不用管
|
||||
3) Apple Mobile Device Support
|
||||
Windows 上用来发现 iPhone 的系统服务。缺失时点「安装」,
|
||||
程序会自动下载安装(约 40~200 MB,装完可能提示需要重启电脑)。
|
||||
4) RSD 隧道依赖 程序自带,不用管
|
||||
5) 开发者镜像 约 15 MB,点「下载」自动获取
|
||||
6) 管理员权限 已提权则为绿色
|
||||
|
||||
如果你已经装过 iTunes 或爱思助手,第 3 项通常已经是绿的。
|
||||
|
||||
想跳过这个窗口(依赖都已就绪时):启动参数加 --no-setup。
|
||||
想用文字版检查:在命令行执行 iOSLocationTrack.exe --check-driver
|
||||
|
||||
【macOS】没有这个窗口,直接打开界面。
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
4. 连接 iPhone
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
1) 用**数据线**把 iPhone 连到电脑(Wi-Fi 连接不稳定,建议用线)
|
||||
2) 解锁手机,屏幕上会弹「要信任此电脑吗?」→ 点「信任」并输入锁屏密码
|
||||
3) 手机上打开:设置 → 隐私与安全性 → 开发者模式 → 打开(会要求重启手机)
|
||||
* iOS 16 及以上必须开这个,否则连不上
|
||||
4) 回到程序界面,点「连接设备」
|
||||
|
||||
连接成功后,界面顶部会显示设备名称和 iOS 版本,状态变成「已连接」。
|
||||
|
||||
耗时参考:实测约 0.8 秒(含隧道建立与开发者会话)。
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
5. 开始使用
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
基本流程就四步:画路线 → 调参数 → 播放 → 停止。
|
||||
|
||||
第 1 步:画路线
|
||||
在地图上依次点击,每点一下加一个点,连成一条线。
|
||||
也可以用「导入 GPX」载入已有的轨迹文件,或直接粘贴经纬度坐标。
|
||||
|
||||
第 2 步:加途经点(可选)
|
||||
想要路线绕行某处时,把某个点设为途经点,程序会重新规划路径
|
||||
(走 OSRM 在线路径规划;离线时退化为直线连接)。
|
||||
|
||||
第 3 步:调参数
|
||||
· 速度倍率:1x 约等于正常步行,调大即加速
|
||||
· 循环播放:跑到终点后自动回到起点继续
|
||||
· 真实感模拟:
|
||||
GPS 漂移 —— 让定位有小幅随机晃动,看起来不像"贴线走"
|
||||
速度抖动 —— 速度上下浮动,避免匀速被识别
|
||||
发送频率 —— 每秒下发几个点,默认 5 Hz
|
||||
|
||||
第 4 步:播放
|
||||
点「播放」,手机定位就开始沿路线移动。地图上会显示进度和剩余时间。
|
||||
随时可以「暂停」「继续」「拖动进度条跳转」。
|
||||
|
||||
结束:点「停止」
|
||||
手机会**立即恢复真实定位**。程序关闭时也会自动恢复,不会把手机"卡"在虚拟位置。
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
6. 界面功能
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
地图区
|
||||
· 点击加点 / 拖拽调整 / 右键删除
|
||||
· 滚轮缩放,按住拖拽平移
|
||||
· 可切换地图源(高德矢量 / 高德卫星 / Esri 全球卫星 / OSM)
|
||||
· 瓦片会缓存到本地,第二次打开同一区域不用重新下载
|
||||
|
||||
路线区
|
||||
· 显示总距离、预计耗时、点数
|
||||
· 支持 GPX 导入导出
|
||||
|
||||
播放控制
|
||||
· 播放 / 暂停 / 停止 / 循环
|
||||
· 进度条拖动跳转(seek)
|
||||
· 速度倍率 0.5x ~ 10x
|
||||
|
||||
设备区
|
||||
· 设备列表与连接状态
|
||||
· 「挂载开发者镜像」按钮(一般连接时会自动处理)
|
||||
· 「手动 RSD」入口:如果你想用别的工具建的隧道,可以手填地址和端口
|
||||
|
||||
日志区
|
||||
· 实时显示程序在做什么,出问题时先看这里
|
||||
|
||||
模拟模式(无需真机)
|
||||
· 命令行执行 main.py --mock
|
||||
· 界面功能都能用,只是定位不会真的下发到手机,适合先熟悉操作
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
7. 数据与日志在哪
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Windows
|
||||
配置 / 瓦片缓存 / 日志 %LOCALAPPDATA%\iOSLocationTrack\
|
||||
程序日志 %LOCALAPPDATA%\iOSLocationTrack\logs\app.log
|
||||
已下载的安装包 %LOCALAPPDATA%\iOSLocationTrack\installers\
|
||||
|
||||
macOS
|
||||
配置 / 瓦片缓存 / 日志 ~/Library/Application Support/iOSLocationTrack/
|
||||
|
||||
日志是 UTF-8 编码、Tab 分隔(时间 / 级别 / 内容)。
|
||||
用记事本打开即可;若用 PowerShell 5.1 的 Get-Content 看,请加 -Encoding UTF8。
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
8. 常见问题
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Q: 设备列表是空的,看不到 iPhone
|
||||
依次检查:
|
||||
1) 手机是否已解锁,并点了「信任此电脑」
|
||||
2) 换一根数据线(很多线只能充电不能传数据)
|
||||
3) Windows:环境准备窗口里「Apple Mobile Device Support」是不是绿的?
|
||||
不是绿的,点「安装」。已装但服务停了的话,重启一下电脑往往就好了。
|
||||
4) 手机上「开发者模式」开了吗?
|
||||
5) 关掉爱思助手 / i4Tools / Xcode —— 它们会抢占设备的连接
|
||||
|
||||
Q: 点「连接设备」失败,提示隧道建立失败
|
||||
通常是同一时间有别的工具占着设备。把所有同类工具关掉再试。
|
||||
iOS 17+ 的设备同一时刻只允许一条调试连接。
|
||||
|
||||
Q: 播放了,但手机定位没动
|
||||
1) 确认手机屏幕上有没有出现定位图标
|
||||
2) 看日志区有没有「已下发 lat=..., lon=...」的输出
|
||||
3) 确认连的是这台手机(设备列表里有没有多台)
|
||||
4) 部分 App 有自己的防作弊逻辑,会忽略系统定位
|
||||
|
||||
Q: 提示 "Failed to verify the disk image signature"
|
||||
设备拒绝了开发者镜像的签名。本项目面向 **iOS 17 及以上**;
|
||||
iOS 16 及更早的系统需要用 Apple 按构建号签发的经典镜像,社区镜像会被拒签。
|
||||
|
||||
Q: Windows 上装完驱动提示要重启
|
||||
重启电脑再打开程序即可。第一次安装 Apple 驱动时比较常见。
|
||||
|
||||
Q: 杀毒软件 / SmartScreen 报警
|
||||
因为程序和安装包没有代码签名证书(买证书要花钱)。
|
||||
点「更多信息」→「仍要运行」即可。想更保险可以自己从源码构建。
|
||||
|
||||
Q: 能同时开两个吗?
|
||||
不能。iOS 17+ 的用户态隧道在一个进程里只能有一条,
|
||||
而且设备本身也只允许一条调试连接。请只开一个。
|
||||
|
||||
Q: 定位会不会"偷偷"恢复成真实的?
|
||||
Windows 的用户态隧道偶尔会静默断开,断开后手机会自己回到真实定位。
|
||||
程序有保活机制:设了点但没在播放时,每 15 秒重发一次。
|
||||
如果发现定位跳回真实位置,点一次「连接设备」重连即可。
|
||||
|
||||
Q: 怎么卸载干净?
|
||||
Windows:控制面板卸载即可。用户数据在 %LOCALAPPDATA%\iOSLocationTrack,
|
||||
不想要可以手动删(卸载程序会保留,方便重装后继续用配置)。
|
||||
macOS :把 App 拖到废纸篓,再删 ~/Library/Application Support/iOSLocationTrack。
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
9. 已知限制
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
· 只支持 iOS 17 及以上。iOS 16 及更早不支持(原因见上一节)。
|
||||
· 只能改经纬度。海拔、定位精度、移动速度这些由 iOS 自己合成,
|
||||
无法从外部注入 —— 这是 Apple 接口本身的限制,不是本工具的问题。
|
||||
· 需要联网的地方:挂载开发者镜像时(Apple 在线签名)、
|
||||
在线路径规划、地图瓦片下载。其余全部在本机完成。
|
||||
· 手机必须响应用户操作:解锁、信任、开开发者模式。
|
||||
· 一个进程只能有一条隧道,别双开。
|
||||
· 不要用于任何违法违规用途。请只在你自己的设备上、用于测试你自己开发的 App。
|
||||
|
||||
|
||||
================================================================================
|
||||
PART 2: ENGLISH
|
||||
================================================================================
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
1. What it is
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
A desktop tool for **virtual location** and **route simulation** on iPhone / iPad.
|
||||
|
||||
Draw a route on a map, add waypoints, and let the phone "travel" along it at realistic
|
||||
speed. Built for testing location-dependent apps (running trackers, check-in, ride-hailing,
|
||||
delivery, fitness logging) without actually going outside.
|
||||
|
||||
This is not a jailbreak or a hack: it uses Apple's own developer interfaces (developer
|
||||
disk image + the DVT location service) — the same mechanism Xcode uses for location
|
||||
debugging.
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
2. Install
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
[Windows]
|
||||
|
||||
Option A (recommended): download iOSLocationTrack-1.1.1_win-setup.exe and run it.
|
||||
|
||||
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.
|
||||
That is only so the app can auto-install the Apple device driver (see below).
|
||||
Virtual location itself does not require administrator rights.
|
||||
|
||||
[macOS]
|
||||
|
||||
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.
|
||||
You only need to do this once.
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
3. First launch
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
[Windows] An "Environment Check" window appears, listing every required component:
|
||||
|
||||
Green check = ready; the button on the right reads "already installed" (disabled)
|
||||
Red cross = missing; a clickable Install / Download button appears
|
||||
Grey dot = optional; not required to continue
|
||||
|
||||
Six items are required. The "Enter" button at the bottom-right only becomes
|
||||
clickable once all of them are green:
|
||||
|
||||
1) Python runtime bundled, nothing to do
|
||||
2) pymobiledevice3 bundled, nothing to do
|
||||
3) Apple Mobile Device Support
|
||||
The Windows service that discovers iPhones. If missing, click Install and
|
||||
the app downloads it automatically (~40-200 MB; may ask for a reboot).
|
||||
4) RSD tunnel dependencies bundled, nothing to do
|
||||
5) Developer disk image ~15 MB, click Download
|
||||
6) Administrator rights green if already elevated
|
||||
|
||||
If you already have iTunes or 3uTools installed, item 3 is usually green already.
|
||||
|
||||
To skip the window when everything is ready, pass --no-setup.
|
||||
For a text-mode check, run: iOSLocationTrack.exe --check-driver
|
||||
|
||||
[macOS] There is no such window — the UI opens directly.
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
4. Connect your iPhone
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
1) Connect the iPhone with a **USB cable** (Wi-Fi works but is less reliable)
|
||||
2) Unlock it. When "Trust This Computer?" appears, tap Trust and enter your passcode
|
||||
3) On the phone: Settings -> Privacy & Security -> Developer Mode -> turn on
|
||||
(it will ask to restart the phone). Required on iOS 16 and later.
|
||||
4) Back in the app, click "Connect device"
|
||||
|
||||
When it succeeds, the device name and iOS version appear at the top of the window
|
||||
and the status becomes "connected".
|
||||
|
||||
Measured time: about 0.8 seconds including tunnel setup and the developer session.
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
5. Basic usage
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Four steps: draw -> tune -> play -> stop.
|
||||
|
||||
Step 1: Draw a route
|
||||
Click on the map to add points one by one. You can also import a GPX file,
|
||||
or paste latitude/longitude coordinates directly.
|
||||
|
||||
Step 2: Add waypoints (optional)
|
||||
Mark a point as a waypoint to force the route through it; the app re-plans
|
||||
the path (via OSRM online routing, falling back to straight lines offline).
|
||||
|
||||
Step 3: Tune the parameters
|
||||
- Speed multiplier 1x is roughly walking pace; increase to go faster
|
||||
- Loop restart from the beginning after reaching the end
|
||||
- Realism model
|
||||
GPS drift small random wander, so movement doesn't look pinned to the line
|
||||
Speed jitter fluctuates the speed so it isn't suspiciously constant
|
||||
Send rate how many points per second (default 5 Hz)
|
||||
|
||||
Step 4: Play
|
||||
Click Play and the phone's location starts moving along the route. The map shows
|
||||
progress and remaining time. You can Pause / Resume / drag the seek bar at any time.
|
||||
|
||||
Finishing: click Stop
|
||||
The phone **immediately returns to its real location**. Closing the app also
|
||||
restores it — the phone will never be left stuck at a fake position.
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
6. Feature reference
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Map
|
||||
- Click to add points, drag to move, right-click to delete
|
||||
- Scroll to zoom, drag to pan
|
||||
- Switchable tile sources (AMap vector / AMap satellite / Esri imagery / OSM)
|
||||
- Tiles are cached locally, so revisiting an area needs no re-download
|
||||
|
||||
Route
|
||||
- Shows total distance, estimated duration and point count
|
||||
- GPX import / export
|
||||
|
||||
Playback
|
||||
- Play / Pause / Stop / Loop
|
||||
- Seek by dragging the progress bar
|
||||
- Speed multiplier 0.5x - 10x
|
||||
|
||||
Device
|
||||
- Device list and connection state
|
||||
- "Mount developer image" button (normally handled automatically on connect)
|
||||
- "Manual RSD" entry: if you built a tunnel with another tool, enter host and port
|
||||
|
||||
Log
|
||||
- Shows what the app is doing in real time — check this first when something fails
|
||||
|
||||
Mock mode (no device needed)
|
||||
- Run main.py --mock from the command line
|
||||
- The whole UI works; locations just aren't sent to a phone. Good for a first look.
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
7. Where data and logs live
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Windows
|
||||
Config / tile cache / logs %LOCALAPPDATA%\iOSLocationTrack\
|
||||
Application log %LOCALAPPDATA%\iOSLocationTrack\logs\app.log
|
||||
Downloaded installers %LOCALAPPDATA%\iOSLocationTrack\installers\
|
||||
|
||||
macOS
|
||||
Config / tile cache / logs ~/Library/Application Support/iOSLocationTrack/
|
||||
|
||||
The log is UTF-8, tab-separated (timestamp / level / message).
|
||||
Notepad opens it fine. If you use PowerShell 5.1, add -Encoding UTF8 to Get-Content.
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
8. Troubleshooting
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Q: The device list is empty — no iPhone visible
|
||||
Check in order:
|
||||
1) Is the phone unlocked, and did you tap "Trust This Computer"?
|
||||
2) Try another cable (many cables are charge-only, no data lines)
|
||||
3) Windows: is "Apple Mobile Device Support" green in the Environment Check?
|
||||
If not, click Install. If it is installed but the service is stopped,
|
||||
a reboot usually fixes it.
|
||||
4) Is Developer Mode enabled on the phone?
|
||||
5) Close 3uTools / i4Tools / Xcode — they compete for the same device connection
|
||||
|
||||
Q: "Connect device" fails with a tunnel error
|
||||
Usually another tool is holding the device. Close every similar tool and retry.
|
||||
An iOS 17+ device only allows one debugging connection at a time.
|
||||
|
||||
Q: It is playing but the phone's location doesn't move
|
||||
1) Does the location arrow appear on the phone?
|
||||
2) Does the log show "sent lat=..., lon=..." lines?
|
||||
3) Are you connected to the right phone (is more than one listed)?
|
||||
4) Some apps have their own anti-spoofing and ignore the system location
|
||||
|
||||
Q: "Failed to verify the disk image signature"
|
||||
The device rejected the developer image's signature. This project targets
|
||||
**iOS 17 and later**; iOS 16 and earlier need classic images signed by Apple
|
||||
per build number, and community images are rejected.
|
||||
|
||||
Q: Windows asks for a reboot after installing the driver
|
||||
Reboot and start the app again. This is common on the first Apple driver install.
|
||||
|
||||
Q: Antivirus / SmartScreen warnings
|
||||
The app and installer are not code-signed (certificates cost money).
|
||||
Click "More info" -> "Run anyway". If you prefer, build it yourself from source.
|
||||
|
||||
Q: Can I run two instances?
|
||||
No. iOS 17+ allows only one userspace tunnel per process, and the device itself
|
||||
permits only one debugging connection. Run a single instance.
|
||||
|
||||
Q: Does the location silently revert to the real one?
|
||||
The Windows userspace tunnel can occasionally drop, after which the phone returns
|
||||
to its real location by itself. The app has a keepalive: when a location is set but
|
||||
playback is stopped, it re-sends every 15 seconds. If you notice a revert, just
|
||||
click "Connect device" again.
|
||||
|
||||
Q: How do I uninstall cleanly?
|
||||
Windows: uninstall from Control Panel. User data lives in
|
||||
%LOCALAPPDATA%\iOSLocationTrack and is intentionally kept, so a reinstall
|
||||
retains your config — delete the folder manually if you want it gone.
|
||||
macOS: drag the app to the Trash, then delete
|
||||
~/Library/Application Support/iOSLocationTrack.
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
9. Known limitations
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
- iOS 17 and later only. iOS 16 and earlier are not supported (see above).
|
||||
- Latitude and longitude only. Altitude, horizontal accuracy and speed are synthesized
|
||||
by iOS and cannot be injected from outside — that is a limitation of Apple's
|
||||
interface, not of this tool.
|
||||
- Network is needed for: mounting the developer image (Apple signs it online),
|
||||
online route planning, and map tile downloads. Everything else runs locally.
|
||||
- The phone must cooperate: unlock it, trust the computer, enable Developer Mode.
|
||||
- One tunnel per process — do not run two instances.
|
||||
- Do not use this for anything illegal. Use it only on your own devices, to test
|
||||
apps you are developing.
|
||||
|
||||
|
||||
================================================================================
|
||||
链接 / Links
|
||||
================================================================================
|
||||
仓库 / Repo : https://github.com/jbjf6666/ios-location-track
|
||||
下载 / Releases : https://github.com/jbjf6666/ios-location-track/releases
|
||||
Windows 文档 : docs/README-WINDOWS.md(中) / README-WINDOWS.en.md(英)
|
||||
移植与踩坑记录 : docs/WINDOWS_PORT.md
|
||||
协议说明 : docs/PROTOCOL.md
|
||||
|
||||
问题反馈 / Issues:
|
||||
https://github.com/jbjf6666/ios-location-track/issues
|
||||
|
||||
================================================================================
|
||||
+94
-13
@@ -161,6 +161,28 @@ def start_service() -> tuple[bool, str]:
|
||||
return False, output.strip() or f"sc start 返回 {code}"
|
||||
|
||||
|
||||
def restart_service() -> tuple[bool, str]:
|
||||
"""重启 Apple 服务(先停再起)。
|
||||
|
||||
用于「服务装着、也在跑,但 27015 协议往返失败」的场景 —— 通常是服务卡死。
|
||||
比重新安装(几十到几百 MB 下载)便宜得多,应该优先尝试。
|
||||
"""
|
||||
if not plat.IS_WINDOWS:
|
||||
return False, "非 Windows 平台"
|
||||
_run(["sc", "stop", SERVICE_NAME], timeout=90)
|
||||
# 等它真的停下来(服务停止是异步的,立刻 start 可能报 1056/1052)
|
||||
deadline = time.time() + 25
|
||||
while time.time() < deadline:
|
||||
code, output = _run(["sc", "query", SERVICE_NAME], timeout=15)
|
||||
if "STOPPED" in output.upper() or "1060" in output:
|
||||
break
|
||||
time.sleep(1.0)
|
||||
code, output = _run(["sc", "start", SERVICE_NAME], timeout=90)
|
||||
if code == 0 or "1056" in output:
|
||||
return True, "服务已重启"
|
||||
return False, output.strip() or f"sc start 返回 {code}"
|
||||
|
||||
|
||||
def usbmux_probe(
|
||||
message_type: str = "ReadBUID",
|
||||
host: str = USBMUX_HOST,
|
||||
@@ -650,6 +672,29 @@ def ensure(
|
||||
return {"ok": True, "applicable": True, "action": "start_service",
|
||||
"status": after_start}
|
||||
|
||||
# 服务存在(甚至在运行)但 27015 探针不通 —— 多半是服务卡死。
|
||||
# **先重启服务**,不要一上来就走安装阶梯:AMDS 明明已经装好,
|
||||
# 却去重新执行 38MB MSI、再下载 193MB 的 iTunes,完全是浪费且可能装坏。
|
||||
if before["service"]["exists"]:
|
||||
emit("warn", "Apple 服务已安装但 usbmuxd(27015) 不通,先尝试重启服务…")
|
||||
for attempt in (1, 2):
|
||||
ok, message = restart_service()
|
||||
emit("info" if ok else "warn", f"重启服务(第 {attempt} 次):{message}")
|
||||
time.sleep(2.0 * attempt)
|
||||
after_restart = status(deep=True)
|
||||
if after_restart["ready"]:
|
||||
return {"ok": True, "applicable": True, "action": "restart_service",
|
||||
"status": after_restart}
|
||||
if not allow_install or strategy == "none":
|
||||
return {"ok": False, "applicable": True, "action": "restart_service",
|
||||
"status": after_restart,
|
||||
"error": "Apple 服务已安装但 usbmuxd 仍不可用(已尝试重启服务)。"
|
||||
"请检查是否有防火墙/安全软件拦截 127.0.0.1:27015,"
|
||||
"或断开重连数据线后重试。"}
|
||||
# 服务存在但重启无效:仅在用户显式要求时才强制重装,避免无谓的大体积下载
|
||||
emit("warn", "重启服务仍无效。若确认驱动损坏,可手动强制重装 iTunes:"
|
||||
"https://www.apple.com/itunes/download/win64")
|
||||
|
||||
if not allow_install or strategy == "none":
|
||||
return {"ok": False, "applicable": True, "action": "none",
|
||||
"needs_install": True, "status": before,
|
||||
@@ -677,12 +722,9 @@ def ensure(
|
||||
result = function(log)
|
||||
attempts.append({"strategy": name, **{k: v for k, v in result.items()
|
||||
if k not in ("steps",)}})
|
||||
if result.get("ok"):
|
||||
if name == "msi" and not result.get("ok"):
|
||||
continue
|
||||
# 无论成功失败都让服务有机会起来,然后复检
|
||||
# 装成功(或要求重启)时,给服务一次起来的机会再复检;
|
||||
# 失败则直接落到下一个策略(msi → itunes → winget 逐级降级)
|
||||
if result.get("ok") or result.get("reboot_required"):
|
||||
service_state()
|
||||
start_service()
|
||||
time.sleep(2.0)
|
||||
after = status(deep=True)
|
||||
@@ -729,15 +771,32 @@ DDI_FILES = {
|
||||
DDI_SUBDIR = ("resources", "ddi", "personalized")
|
||||
|
||||
|
||||
def ddi_dir(root: Path | str | None = None) -> Path:
|
||||
"""返回开发者镜像的本地目录。"""
|
||||
base = Path(root) if root else plat.resolve_roots()[0]
|
||||
return base.joinpath(*DDI_SUBDIR)
|
||||
def ddi_candidates(root: Path | str | None = None) -> list[Path]:
|
||||
"""按优先级返回镜像**可能所在**的目录。
|
||||
|
||||
打包后资源在 ``sys._MEIPASS``(PyInstaller 解包的临时目录,只读、退出即删),
|
||||
所以下载得到的镜像不能放那里;用户目录 ``~/.ios-location-ddi`` 是第二候选,
|
||||
``core/server.py:_stage_personalized_ddi`` 的搜索列表里已经包含它。
|
||||
"""
|
||||
if root:
|
||||
return [Path(root).joinpath(*DDI_SUBDIR)]
|
||||
resources = plat.resolve_roots()[0]
|
||||
return [
|
||||
resources.joinpath(*DDI_SUBDIR), # 随包内置(只读)
|
||||
Path.home() / ".ios-location-ddi" / "personalized", # 运行期下载(可写)
|
||||
]
|
||||
|
||||
|
||||
def ddi_status(root: Path | str | None = None) -> dict[str, Any]:
|
||||
"""检查本地开发者镜像素材是否齐全。"""
|
||||
directory = ddi_dir(root)
|
||||
def ddi_write_dir(root: Path | str | None = None) -> Path:
|
||||
"""返回**可写**的镜像目录(下载目标)。"""
|
||||
if root:
|
||||
return Path(root).joinpath(*DDI_SUBDIR)
|
||||
if getattr(sys, "frozen", False):
|
||||
return Path.home() / ".ios-location-ddi" / "personalized"
|
||||
return plat.resolve_roots()[0].joinpath(*DDI_SUBDIR)
|
||||
|
||||
|
||||
def _ddi_status_one(directory: Path) -> dict[str, Any]:
|
||||
files: dict[str, Any] = {}
|
||||
missing: list[str] = []
|
||||
total = 0
|
||||
@@ -762,6 +821,27 @@ def ddi_status(root: Path | str | None = None) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def ddi_status(root: Path | str | None = None) -> dict[str, Any]:
|
||||
"""检查本地开发者镜像素材是否齐全(按候选目录优先级,返回第一个齐全的)。"""
|
||||
candidates = ddi_candidates(root)
|
||||
for candidate in candidates:
|
||||
info = _ddi_status_one(candidate)
|
||||
if info["ok"]:
|
||||
return info
|
||||
# 都不齐全:报告优先级最高的那个(通常就是随包内置的),便于用户理解缺什么
|
||||
info = _ddi_status_one(candidates[0])
|
||||
info["searched"] = [str(c) for c in candidates]
|
||||
return info
|
||||
|
||||
|
||||
def ddi_dir(root: Path | str | None = None) -> Path:
|
||||
"""返回镜像实际所在目录:优先齐全的那个,否则返回可写目录。"""
|
||||
for candidate in ddi_candidates(root):
|
||||
if (candidate / "Image.dmg").exists():
|
||||
return candidate
|
||||
return ddi_write_dir(root)
|
||||
|
||||
|
||||
def _ddi_urls(name: str) -> list[str]:
|
||||
path = f"{DDI_DIR}/{name}"
|
||||
return [
|
||||
@@ -784,7 +864,7 @@ def download_developer_image(
|
||||
if log:
|
||||
log(level, message)
|
||||
|
||||
directory = ddi_dir(root)
|
||||
directory = ddi_write_dir(root)
|
||||
try:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
@@ -794,6 +874,7 @@ def download_developer_image(
|
||||
if current["ok"] and not force:
|
||||
return {"ok": True, "action": "none", "status": current,
|
||||
"detail": f"已就绪({current['total_mb']} MB)"}
|
||||
emit("info", f"镜像下载目录:{directory}")
|
||||
|
||||
for name, minimum in DDI_FILES.items():
|
||||
target = directory / name
|
||||
|
||||
@@ -558,6 +558,9 @@ class RealLocationProvider:
|
||||
self._last_location = (lat, lon)
|
||||
self._send_count += 1
|
||||
self._last_send_mono = time.monotonic()
|
||||
# 幂等兜底:会话建立时会启动保活,但 clear_location 之后、或任务曾异常退出时
|
||||
# 需要重新拉起 —— 保证「设了点就一定有保活」。
|
||||
self._start_keepalive()
|
||||
if self._send_count == 1 or self._send_count % 25 == 0:
|
||||
self._log("debug", f"已下发 lat={lat:.6f}, lon={lon:.6f}(第 {self._send_count} 次)")
|
||||
|
||||
@@ -627,7 +630,9 @@ class RealLocationProvider:
|
||||
raise _wrap("清除虚拟定位", exc) from exc
|
||||
self._last_location = None
|
||||
self._last_send_mono = 0.0
|
||||
self._stop_keepalive()
|
||||
# 注意:**不要**在这里 _stop_keepalive()。保活循环本来就会在
|
||||
# _last_location is None 时空转;一旦停掉,之后再 set_location 就永远没有保活了
|
||||
# (这是审查发现的真实 bug:先 stop 再设点,等 30s 也不会有保活重发)。
|
||||
self._log("info", "已清除虚拟定位,设备恢复真实定位")
|
||||
|
||||
async def close(self) -> None:
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import uuid
|
||||
from typing import Any
|
||||
|
||||
APP_NAME = "iOS 虚拟定位轨迹工具"
|
||||
APP_VERSION = "1.1.0"
|
||||
APP_VERSION = "1.1.1"
|
||||
PROTOCOL_VERSION = "v1"
|
||||
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
|
||||
+75
-15
@@ -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,7 +267,10 @@ class App:
|
||||
rsd = None
|
||||
rsd_obj = None
|
||||
if (device.ios_major or 0) >= 17:
|
||||
if not self.tunnel.ready:
|
||||
# 必须**无条件**调用 ensure():它内部会判断已建立的隧道是不是绑在当前
|
||||
# 选中的这台设备上,UDID 不一致会自动重建。
|
||||
# 之前这里先用 `if not self.tunnel.ready` 短路,导致切换设备时复用了
|
||||
# 上一台设备的 RSD 对象 —— 表现为"给错的手机下发定位"。
|
||||
snapshot = self.tunnel.ensure(device.udid, allow_sudo=False)
|
||||
if snapshot["state"] != "connected":
|
||||
raise protocol.AppError(
|
||||
@@ -270,7 +288,21 @@ class App:
|
||||
udid=device.udid,
|
||||
log=self.log,
|
||||
)
|
||||
try:
|
||||
self.runner.run(provider.connect(device), timeout=40.0)
|
||||
except Exception:
|
||||
# 失败时把新建的 provider 关掉,避免 DVT/lockdown 泄漏
|
||||
try:
|
||||
self.runner.run(provider.close(), timeout=15.0)
|
||||
except Exception:
|
||||
pass
|
||||
# 并把隧道标记为失效,下一次连接会重建,
|
||||
# 避免拿着一个已经死掉的 RSD 反复重试。
|
||||
try:
|
||||
self.tunnel.invalidate("DVT 会话建立失败,隧道可能已失效")
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
self.provider = provider
|
||||
self.provider_udid = device.udid
|
||||
self.playback.set_provider(provider)
|
||||
@@ -810,6 +842,8 @@ class App:
|
||||
"""
|
||||
import sys
|
||||
|
||||
from . import apple_driver
|
||||
|
||||
sources: list[Path] = []
|
||||
base = getattr(sys, "_MEIPASS", None)
|
||||
if base:
|
||||
@@ -819,8 +853,15 @@ class App:
|
||||
sources.append(self.root / "resources" / "ddi" / "personalized")
|
||||
sources.append(Path.home() / ".ios-location-ddi" / "personalized")
|
||||
|
||||
src = next((d for d in sources if (d / "Image.dmg").exists()), None)
|
||||
# 必须**三件齐全**的目录才算可用。以前只判断"有没有 Image.dmg",
|
||||
# 可能挑到一个残缺目录(比如缺 Image.dmg.trustcache),拷进去照样挂不上 ——
|
||||
# 与 apple_driver.ddi_status() 的判定标准保持一致。
|
||||
required = tuple(apple_driver.DDI_FILES)
|
||||
src = next((d for d in sources
|
||||
if all((d / name).exists() for name in required)), None)
|
||||
if src is None:
|
||||
self.log("warn", "未找到完整的个性化镜像素材(需要 Image.dmg / "
|
||||
"BuildManifest.plist / Image.dmg.trustcache 三件齐全)")
|
||||
return False
|
||||
|
||||
dest = Path.home() / ".pymobiledevice3" / "Xcode_iOS_DDI_Personalized"
|
||||
@@ -834,8 +875,6 @@ class App:
|
||||
copied = []
|
||||
for source_name, dest_name in mapping.items():
|
||||
source_file = src / source_name
|
||||
if not source_file.exists():
|
||||
continue
|
||||
target = dest / dest_name
|
||||
# 体积一致就跳过,避免每次挂载都拷 15MB
|
||||
if target.exists() and target.stat().st_size == source_file.stat().st_size:
|
||||
@@ -844,18 +883,15 @@ class App:
|
||||
copied.append(dest_name)
|
||||
if copied:
|
||||
self.log("info", "已从应用内置素材安装个性化镜像:" + "、".join(copied))
|
||||
# 落地后再校验一次,避免"拷了但没拷全"
|
||||
missing = [d for d in mapping.values() if not (dest / d).exists()]
|
||||
if missing:
|
||||
self.log("warn", "镜像素材不完整,缺少:" + "、".join(missing))
|
||||
return False
|
||||
return True
|
||||
except Exception as exc:
|
||||
self.log("warn", f"安装内置个性化镜像失败:{exc}")
|
||||
return False
|
||||
device = self.devices.get_selected()
|
||||
if device is None:
|
||||
return {"mounted": None, "running": self._mount_running}
|
||||
return {
|
||||
"mounted": self._developer_image_mounted(device.udid),
|
||||
"running": self._mount_running,
|
||||
"ios_major": device.ios_major,
|
||||
}
|
||||
|
||||
def _cmd_mount_status(self, message: dict[str, Any]) -> dict[str, Any]:
|
||||
"""查询当前设备的开发者镜像/会话状态。"""
|
||||
@@ -984,6 +1020,14 @@ class App:
|
||||
self.runner.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
# 关闭日志文件句柄(打包成窗口程序时这是唯一的排查线索,必须 flush 落盘)
|
||||
try:
|
||||
handle = getattr(self, "_log_file", None)
|
||||
if handle is not None:
|
||||
handle.close()
|
||||
self._log_file = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------- HTTP ----------------
|
||||
@@ -1001,6 +1045,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def handle_one_request(self) -> None:
|
||||
"""浏览器关闭 keep-alive 连接时不要往终端刷一堆 ConnectionResetError 回溯。"""
|
||||
# 同一个 handler 实例会处理 keep-alive 连接上的多个请求,
|
||||
# 请求体缓存必须按请求重置,否则第二个请求会读到上一个请求的 body。
|
||||
self._body_cache = None
|
||||
try:
|
||||
super().handle_one_request()
|
||||
except (ConnectionResetError, BrokenPipeError, ConnectionAbortedError):
|
||||
@@ -1070,15 +1117,22 @@ class Handler(BaseHTTPRequestHandler):
|
||||
pass
|
||||
|
||||
def _read_body(self) -> bytes:
|
||||
# do_POST 会预先读完并缓存,这里直接复用,避免重复读取或漏读
|
||||
cached = getattr(self, "_body_cache", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
except ValueError:
|
||||
length = 0
|
||||
if length <= 0:
|
||||
self._body_cache = b""
|
||||
return b""
|
||||
if length > 64 * 1024 * 1024:
|
||||
raise protocol.AppError("请求体过大", "too_large", status=413)
|
||||
return self.rfile.read(length)
|
||||
data = self.rfile.read(length)
|
||||
self._body_cache = data
|
||||
return data
|
||||
|
||||
def _json_body(self) -> dict[str, Any]:
|
||||
raw = self._read_body()
|
||||
@@ -1120,6 +1174,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
try:
|
||||
# **先把请求体读完并缓存**。有些 handler(如 /api/devices/connect)
|
||||
# 压根不读 body;若不在这里排空,残留的字节会留在 keep-alive 连接里,
|
||||
# 被当成下一个请求的请求行 —— 表现为
|
||||
# 501 Unsupported method ('{}POST')
|
||||
# 而且是间歇性的(取决于客户端是否复用连接),很难查。
|
||||
self._body_cache = self._read_body()
|
||||
self._check_origin()
|
||||
self._check_host()
|
||||
if path.startswith("/api/"):
|
||||
|
||||
+122
-26
@@ -20,6 +20,7 @@ from typing import Any, Callable
|
||||
|
||||
from . import apple_driver
|
||||
from . import platform as plat
|
||||
from . import protocol
|
||||
|
||||
# 颜色(与 web 端保持同一套语义色)
|
||||
COLOR_OK = "#1a7f37"
|
||||
@@ -144,7 +145,23 @@ def _install_developer_image(log: Callable[[str, str], None]) -> tuple[bool, str
|
||||
def _check_permission() -> tuple[bool, str]:
|
||||
if plat.is_admin():
|
||||
return True, "已以管理员身份运行"
|
||||
return False, "未提权(安装 Apple 驱动和管理员操作会失败)"
|
||||
return False, "未提权(安装 Apple 驱动会失败)"
|
||||
|
||||
|
||||
def _install_permission(log: Callable[[str, str], None]) -> tuple[bool, str]:
|
||||
"""「安装」管理员权限 = 以管理员身份重新启动本程序。
|
||||
|
||||
之前这一项没有安装动作,导致**非管理员用户永远无法点「进入程序」**(死锁)。
|
||||
现在点这个按钮会通过 UAC 拉起一个提权的新实例,本实例随即退出。
|
||||
"""
|
||||
if plat.is_admin():
|
||||
return True, "已经是管理员身份"
|
||||
log("info", "正在通过 UAC 以管理员身份重新启动程序…")
|
||||
launched = plat.relaunch_elevated()
|
||||
if launched:
|
||||
log("info", "已发起提权重启;请在系统弹窗里点「是」")
|
||||
return True, "已发起提权重启,本窗口即将关闭"
|
||||
return False, "无法自动提权,请关闭程序后右键 →「以管理员身份运行」"
|
||||
|
||||
|
||||
def _check_device() -> tuple[bool, str]:
|
||||
@@ -161,20 +178,29 @@ def _check_device() -> tuple[bool, str]:
|
||||
|
||||
|
||||
def _check_network() -> tuple[bool, str]:
|
||||
"""Apple TSS 签名需要联网(挂载开发者镜像时用)。"""
|
||||
"""检查两个**用途不同**的网络依赖,分开报,不要混为一谈。
|
||||
|
||||
- ``gs.apple.com``:挂载开发者镜像时向 Apple 申请 TSS 签名,**必须**能连。
|
||||
- GitHub / jsDelivr:只在「开发者镜像缺失、需要现下」时用;镜像已内置则无所谓。
|
||||
国内经常只有 GitHub 被墙而 Apple 正常,以前那种"二选一"的判断会给出误导性结论。
|
||||
"""
|
||||
import socket
|
||||
|
||||
hosts = [("gs.apple.com", 443), ("raw.githubusercontent.com", 443)]
|
||||
reachable = []
|
||||
for host, port in hosts:
|
||||
def _reachable(host: str, port: int = 443) -> bool:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=6):
|
||||
reachable.append(host)
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
if reachable:
|
||||
return True, "可连通(" + "、".join(reachable) + ")"
|
||||
return False, "无法连接 Apple 服务器(挂载开发者镜像时需要联网签名)"
|
||||
return False
|
||||
|
||||
apple_ok = _reachable("gs.apple.com")
|
||||
mirror_ok = _reachable("raw.githubusercontent.com") or _reachable("cdn.jsdelivr.net")
|
||||
|
||||
if apple_ok and mirror_ok:
|
||||
return True, "Apple 签名服务与镜像下载源均可连通"
|
||||
if apple_ok:
|
||||
return True, "Apple 签名服务可连通(镜像源不可达,但镜像已内置,通常无影响)"
|
||||
return False, "无法连接 Apple 签名服务器 gs.apple.com —— 挂载开发者镜像时会失败"
|
||||
|
||||
|
||||
def build_items() -> list[EnvItem]:
|
||||
@@ -204,8 +230,9 @@ def build_items() -> list[EnvItem]:
|
||||
))
|
||||
items.append(EnvItem(
|
||||
"permission", "管理员权限",
|
||||
"安装驱动与系统级操作需要",
|
||||
_check_permission, None, required=True,
|
||||
"安装 Apple 驱动需要;虚拟定位本身不需要",
|
||||
_check_permission, _install_permission, required=True,
|
||||
install_label="重启提权",
|
||||
))
|
||||
items.append(EnvItem(
|
||||
"device", "iPhone 连接",
|
||||
@@ -253,9 +280,34 @@ class SetupWindow:
|
||||
root.resizable(False, True)
|
||||
self._fit_to_screen(root)
|
||||
root.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||
root.after(80, self._pump)
|
||||
self._pump_after_id: str | None = None
|
||||
self._schedule_pump()
|
||||
self.refresh_all()
|
||||
|
||||
def _schedule_pump(self) -> None:
|
||||
"""排下一次 UI 泵,并记住 after id 以便销毁前取消。"""
|
||||
try:
|
||||
self._pump_after_id = self.root.after(80, self._pump)
|
||||
except Exception:
|
||||
self._pump_after_id = None
|
||||
|
||||
def _cancel_pump(self) -> None:
|
||||
"""销毁窗口前取消待执行的 after,否则 Tk 会打印
|
||||
``invalid command name "..._pump"``(窗口程序看不到,命令行会刷屏)。"""
|
||||
if self._pump_after_id is not None:
|
||||
try:
|
||||
self.root.after_cancel(self._pump_after_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._pump_after_id = None
|
||||
|
||||
def _destroy(self) -> None:
|
||||
self._cancel_pump()
|
||||
try:
|
||||
self.root.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---------------- 布局 ----------------
|
||||
@staticmethod
|
||||
def _fit_to_screen(window: Any, min_width: int = 820, min_height: int = 560) -> None:
|
||||
@@ -286,8 +338,13 @@ class SetupWindow:
|
||||
|
||||
header = tk.Frame(self.root, bg=COLOR_BG)
|
||||
header.pack(fill="x", padx=20, pady=(16, 6))
|
||||
tk.Label(header, text=title, font=("Microsoft YaHei UI", 15, "bold"),
|
||||
bg=COLOR_BG).pack(anchor="w")
|
||||
title_row = tk.Frame(header, bg=COLOR_BG)
|
||||
title_row.pack(fill="x", anchor="w")
|
||||
tk.Label(title_row, text=title, font=("Microsoft YaHei UI", 15, "bold"),
|
||||
bg=COLOR_BG).pack(side="left")
|
||||
tk.Label(title_row, text=f"v{protocol.APP_VERSION} · {plat.platform_label()}",
|
||||
font=("Microsoft YaHei UI", 9), fg=COLOR_MUTED,
|
||||
bg=COLOR_BG).pack(side="left", padx=(10, 0), pady=(4, 0))
|
||||
tk.Label(
|
||||
header,
|
||||
text="首次使用需要准备以下组件。缺失的项目点右侧按钮即可自动安装;"
|
||||
@@ -369,6 +426,20 @@ class SetupWindow:
|
||||
command=self.refresh_all)
|
||||
self.recheck_button.pack(side="right")
|
||||
|
||||
self.open_logs_button = ttk.Button(footer, text="打开日志目录", width=13,
|
||||
command=self._open_logs_dir)
|
||||
self.open_logs_button.pack(side="right", padx=(0, 8))
|
||||
|
||||
def _open_logs_dir(self) -> None:
|
||||
"""打开 %LOCALAPPDATA%\\iOSLocationTrack\\logs —— 装驱动失败时让用户能自己取证。"""
|
||||
try:
|
||||
target = plat.data_dir() / "logs"
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
if not plat.open_in_file_manager(target):
|
||||
self.append_log(f"日志目录:{target}")
|
||||
except Exception as exc:
|
||||
self.append_log(f"无法打开日志目录:{exc}")
|
||||
|
||||
# ---------------- 日志 ----------------
|
||||
def append_log(self, text: str) -> None:
|
||||
self.log.configure(state="normal")
|
||||
@@ -439,10 +510,21 @@ class SetupWindow:
|
||||
except Exception as exc:
|
||||
ok, detail = False, f"安装异常:{exc}"
|
||||
self.events.put(("install", (item.key, ok, detail)))
|
||||
if item.key == "permission" and ok:
|
||||
# 提权重启已发起:关掉本窗口,本进程退出,由新实例接管
|
||||
self.events.put(("close", None))
|
||||
return
|
||||
self.events.put(("installs_done", None))
|
||||
|
||||
# ---------------- UI 线程泵 ----------------
|
||||
def _pump(self) -> None:
|
||||
# 窗口一旦销毁(点「进入程序」/关闭窗口/提权重启),就不要再碰控件:
|
||||
# 否则队列里排着的 after 回调会在 Tk 里报 invalid command name ..._pump。
|
||||
try:
|
||||
if not self.root.winfo_exists():
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
while True:
|
||||
kind, payload = self.events.get_nowait()
|
||||
@@ -450,6 +532,9 @@ class SetupWindow:
|
||||
key, ok, detail = payload
|
||||
self.state[key] = {"ok": ok, "detail": detail, "busy": False}
|
||||
self._render(key)
|
||||
# 每完成一项就刷新底部:否则必需项都绿了,
|
||||
# 还要等可选项(网络探测可能 3×6s / PnP 45s)跑完按钮才亮。
|
||||
self._update_footer()
|
||||
elif kind == "checks_done":
|
||||
self._update_footer()
|
||||
elif kind == "busy":
|
||||
@@ -471,10 +556,16 @@ class SetupWindow:
|
||||
self.refresh_all()
|
||||
elif kind == "log":
|
||||
self.append_log(payload)
|
||||
elif kind == "close":
|
||||
# 提权重启:关掉本窗口。main() 看到 proceeded=False 会正常退出,
|
||||
# 由刚拉起的提权实例继续跑(它自己会再弹一次本窗口,那时已是管理员)。
|
||||
self.proceeded = False
|
||||
self._destroy()
|
||||
return
|
||||
except queue.Empty:
|
||||
pass
|
||||
if not self.proceeded:
|
||||
self.root.after(80, self._pump)
|
||||
self._schedule_pump()
|
||||
|
||||
# ---------------- 渲染 ----------------
|
||||
def _render(self, key: str) -> None:
|
||||
@@ -521,9 +612,20 @@ class SetupWindow:
|
||||
self.state[key]["detail"] = detail
|
||||
self._render(key)
|
||||
|
||||
def _needs_item(self, item: EnvItem) -> bool:
|
||||
"""判断某一项此刻是否真的"必需"。
|
||||
|
||||
「管理员权限」只在**需要安装 Apple 驱动**时才是必需的:驱动已就绪时,
|
||||
建隧道(userspace 免管理员)和下发定位都不需要提权,不该因为它把
|
||||
「进入程序」锁死 —— 否则源码模式 / 非管理员启动就永远进不去。
|
||||
"""
|
||||
if item.key == "permission":
|
||||
driver = self.state.get("apple_driver") or {}
|
||||
return driver.get("ok") is not True
|
||||
return item.required
|
||||
|
||||
def _update_footer(self) -> None:
|
||||
required = [i for i in self.items if i.required]
|
||||
self.state.get("__x__")
|
||||
required = [i for i in self.items if self._needs_item(i)]
|
||||
pending = [i for i in required if self.state[i.key].get("ok") is not True]
|
||||
optional_bad = [i for i in self.items
|
||||
if not i.required and self.state[i.key].get("ok") is False]
|
||||
@@ -554,17 +656,11 @@ class SetupWindow:
|
||||
# ---------------- 结束 ----------------
|
||||
def _on_confirm(self) -> None:
|
||||
self.proceeded = True
|
||||
try:
|
||||
self.root.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
self._destroy()
|
||||
|
||||
def _on_close(self) -> None:
|
||||
self.proceeded = False
|
||||
try:
|
||||
self.root.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
self._destroy()
|
||||
|
||||
|
||||
def run_setup_window(title: str = "环境准备", auto_install_missing: bool = False) -> bool | None:
|
||||
|
||||
+48
-9
@@ -137,7 +137,23 @@ class TunnelManager:
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
with self._lock:
|
||||
return bool(self.host and self.port) and self.state == "connected"
|
||||
if not (bool(self.host and self.port) and self.state == "connected"):
|
||||
return False
|
||||
# 用户态隧道死了之后 pymobiledevice3 会自己 aclose() 并把自己的 rsd 置空,
|
||||
# 但我们这边还留着旧引用、host/port 也还在 —— 不主动判断的话会一直把
|
||||
# 已经死掉的 RSD 注入给 provider,表现为"界面显示已连接但怎么都不生效"。
|
||||
tunnel = self._userspace_tunnel
|
||||
if tunnel is not None:
|
||||
try:
|
||||
if getattr(tunnel, "rsd", None) is None:
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
"""隧道是否仍然健康(供 UI/上层做"是否该重连"的判断)。"""
|
||||
return self.ready
|
||||
|
||||
def address(self) -> tuple[str, int] | None:
|
||||
with self._lock:
|
||||
@@ -392,11 +408,25 @@ class TunnelManager:
|
||||
return self.snapshot()
|
||||
|
||||
def stop(self) -> None:
|
||||
# ---- Windows:释放进程内用户态隧道 ----
|
||||
# 先把状态和地址**在锁内一次性清干净**,再慢慢释放资源。
|
||||
# 之前是"先清 rsd、最后才清 host/port/state",中间那段时间 ready 仍为 True
|
||||
# 而 rsd_object() 已经是 None,并发的 provider_for 会退回 (host, port) 去建
|
||||
# 内核 TCP 连接 → Windows 上直接 OSError [WinError 1231]。
|
||||
with self._lock:
|
||||
tunnel = self._userspace_tunnel
|
||||
self._userspace_tunnel = None
|
||||
self._rsd = None
|
||||
process = self._process
|
||||
self._process = None
|
||||
self.host = None
|
||||
self.port = None
|
||||
self.mode = None
|
||||
self._needs_root = False
|
||||
if self.state != "idle":
|
||||
self.state = "starting" # 任何非 connected 状态都会让 ready 变为 False
|
||||
self.message = "正在释放隧道…"
|
||||
|
||||
# ---- Windows:释放进程内用户态隧道 ----
|
||||
if tunnel is not None:
|
||||
try:
|
||||
if self._runner is not None:
|
||||
@@ -407,21 +437,30 @@ class TunnelManager:
|
||||
self._emit("warn", f"释放用户态隧道时出错(可忽略):{exc}")
|
||||
|
||||
# ---- macOS:结束子进程 ----
|
||||
with self._lock:
|
||||
process = self._process
|
||||
self._process = None
|
||||
if process is not None and process.poll() is None:
|
||||
plat.terminate_process_tree(process)
|
||||
|
||||
with self._lock:
|
||||
self.host = None
|
||||
self.port = None
|
||||
self.mode = None
|
||||
if self.state in ("connected", "starting", "error", "unsupported"):
|
||||
if self.state == "starting":
|
||||
self.state = "idle"
|
||||
self.message = _default_message("idle")
|
||||
_write_pidfile([])
|
||||
|
||||
def invalidate(self, reason: str = "隧道已失效") -> None:
|
||||
"""把隧道标记为失效(DVT 会话建立失败 / 数据通道报错时调用)。
|
||||
|
||||
只改状态、不释放底层资源:让下一次 ``ensure()`` 走"重建"分支,
|
||||
避免拿着一个已经死掉的 RSD 反复重试。真正释放由 ``stop()`` 负责。
|
||||
"""
|
||||
with self._lock:
|
||||
if self.state in ("connected", "error"):
|
||||
self.state = "error"
|
||||
self.message = reason
|
||||
self.host = None
|
||||
self.port = None
|
||||
self._rsd = None
|
||||
self._emit("warn", f"[tunnel] {reason},下次连接会重建隧道")
|
||||
|
||||
|
||||
async def _close_userspace(tunnel: Any) -> None:
|
||||
"""释放 userspace 隧道。
|
||||
|
||||
@@ -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 输出可见性。
|
||||
@@ -1,6 +1,6 @@
|
||||
# Windows Guide — iOS Virtual Location & Route Simulator
|
||||
|
||||
[**中文**](README-WINDOWS.md) | [English](README-WINDOWS.en.md)
|
||||
[中文](README-WINDOWS.md) | [**English**](README-WINDOWS.en.md)
|
||||
|
||||
> This document is for Windows users and maintainers.
|
||||
> macOS instructions live in [README.md](../README.md); the porting rationale and
|
||||
@@ -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:**
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
**前置条件:**
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
)
|
||||
]),
|
||||
|
||||
@@ -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 中有非法项(类型/范围不合法),已自动回退为默认值")
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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 首次上传时踩过)。
|
||||
|
||||
Reference in new issue
Block a user