Compare commits

5 Commits
Author SHA1 Message Date
jbjf6666 e6880efdc6 feat: Windows v1.1.1 — 修复与改进(DSH 工作区归档) 2026-10-29 15:37:17 +08:00
jbjf6666 4a5d676c7b docs: 新增《画轨迹 · 折线绘制 · 开始虚拟轨迹》详细教程
TUTORIAL-DRAW-AND-PLAY.txt,中英双语纯文本,1305 行 / 64 KB。

内容按真实界面元素写(对照 web/index.html 与 web/js/app.js 的实际按钮 / 交互):
- A 开始之前:三件必须做完的事(连接设备 / 挂载镜像 / 手机在手边)
- B 界面总览:四块区域 + 三个最关键按钮
- C 地图操作:平移缩放全览、切换底图、快速定位到坐标
- D 三种造轨迹方式
    D1 折线绘制(点击加点、双击或 Esc 结束、至少 2 点)
    D2 途经点 + OSRM 路线规划(拖拽排序、双击改坐标、改完要重新加载)
    D3 导入 GPX
- E 编辑:撤销 / 删除 / 移动 / 排序
- F 出行模式(驾车/骑行/步行/飞行)与播放倍速(0.1x-50x)的区别
- G 真实感参数逐个讲解:漂移 / 速度浮动 / 节奏抖动 / 位移推算速度 / 粗差跳点
- H 开始播放:前置检查清单 + 逐步现象 + 失败提示对照
- I 播放中控制:暂停 / 继续 / 拖动跳转 / 停止 的区别
- J 结束与恢复真实定位(三种方式 + 恢复不了的例外)
- K 保存复用(localStorage 与 GPX 的区别)
- L 快捷键总表
- M 常见问题(含错误提示原文对照)
- N 参数速查表

编码:UTF-8 with BOM + CRLF(记事本友好),.gitattributes 锁定 eol=crlf。
README.md / README.en.md 加入口,Location.spec 随 Windows 包一起装到安装目录。
2026-10-16 12:49:34 +08:00
jbjf6666 bbf8a4fac4 docs: 使用方法.txt 重命名为 USAGE.txt
文件名改成英文,文件内容逐字节不变(23981 字节,UTF-8 with BOM,505 个 CRLF)。
git 识别为 rename,历史保留。

同步更新引用:
- .gitattributes 的 eol=crlf 规则(USAGE.txt 仍锁定 CRLF,记事本友好)
- README.md / README.en.md 的入口链接
- Location.spec 的打包 datas(随 Windows 包装到安装目录)

文档内的标题「iOS 虚拟定位轨迹工具 — 使用方法」保持中文不动。
2026-10-16 12:42:34 +08:00
jbjf6666 94c152ba00 docs: 新增中英双语《使用方法.txt》
- 纯文本、记事本友好:UTF-8 with BOM + CRLF(老版记事本也能正确显示中文)
- 中文在前(9 节:是什么/安装/首次启动/连设备/使用/功能/数据位置/常见问题/已知限制),
  英文在后,共 505 行
- 覆盖 Windows 与 macOS 两个平台,含 11 条常见问题排查
- README.md / README.en.md 顶部加入口
- .gitattributes 为该文件单独锁定 CRLF(覆盖通用 *.txt eol=lf)
- Location.spec:随 Windows 包一起装到安装目录
2026-10-16 02:18:44 +08:00
jbjf6666 7aed7a05c7 docs: 英文版 Windows 文档的语言切换里加粗 English(与 README.en.md 一致) 2026-10-16 01:53:00 +08:00
22 changed files with 2623 additions and 99 deletions

No files matched your search

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