feat(windows): 新增 Windows 移植(免管理员隧道 + 自动装 Apple 驱动 + 环境自检)

按 docs/WINDOWS_PORT.md 完成 Windows 移植,并修正该文档中若干与实测不符之处。
macOS 构建链完整保留,单一代码库双平台构建。

核心:iOS 17+ RSD 隧道在 Windows 上换了实现方式
- macOS 借 Apple remoted(--native,内核可路由),Windows 改走 UserspaceRsdTunnel
  (纯 Python PyTCP 用户态协议栈)
- 实测:不需要管理员权限、不需要 Wintun 驱动(pytun_pmd3 自带 wintun.dll)、
  建立耗时约 0.3 秒
- 关键坑:用户态隧道的设备地址**只在本进程可达**。拿 (host, port) 另建 RSD 会得到
  OSError [WinError 1231] 网络位置不可达。必须把 aopen() 返回的 rsd 对象本身注入
  DVT 层(RealLocationProvider 早有 rsd= 注入口),因此 Windows 不能沿用
  「隧道助手子进程 + 解析 HOST PORT」那套架构
- 修正 WINDOWS_PORT.md 的错误:类名是 UserspaceRsdTunnel 不是 UserspaceTunnel;
  remote start-tunnel 没有 --userspace;Wintun 不是必需项

新增
- core/platform.py      平台抽象层(数据目录/浏览器探测/进程管理/提权/信号)
- core/apple_driver.py  Apple Mobile Device Support 检测与自动安装
                        (服务启动 → 独立 AMDS MSI → 完整 iTunes 包 → winget 逐级降级)
                        + 开发者镜像下载
- core/setup_window.py  启动时的「环境准备」窗口:逐项列出依赖,缺失项右侧可点安装,
                        必需项全部就绪后「进入程序」才可点击
- scripts/run.ps1, build.ps1, installer/iosloc.iss, installer/version_info.txt
- tools/make_ico.py(由已有 PNG 图标集生成多尺寸 .ico)
- tools/probe_tunnel_win.py(隧道可行性诊断)
- tests/win_e2e.py(7 项)、tests/win_app_e2e.py(9 项)真机验收
- docs/README-WINDOWS.md
- .gitattributes(避免 Windows 写出 CRLF 的 .sh 把 macOS 构建搞坏)

修改
- core/tunnel.py            Windows 走进程内 userspace 隧道;macOS 路径不变
- core/server.py            TunnelManager 注入 runner;provider_for 注入 rsd 对象;
                            资源目录与数据目录分离;日志落盘
- core/location_provider.py 平台化错误提示;新增保活(隧道静默断开会让设备
                            偷偷恢复真实定位,停止播放时每 15s 重发)
- main.py                   「环境准备」窗口、Apple 驱动后台检查、
                            --check-driver / --install-driver、跨平台浏览器与信号
- Location.spec             平台分支;Windows 用 .ico + uac_admin=True(启动即请求管理员)
                            + version_info.txt;单一 spec 不分叉
- tests/drift_sim.py / offline_tiles.py  去掉硬编码 /tmp,改用 tempfile.gettempdir()

打包
- exe 清单写入 requireAdministrator(用户要求:启动时直接请求管理员权限)
- 绿色版 dist\iOSLocationTrack\iOSLocationTrack.exe
- 安装包 dist\installer\iOSLocationTrack-1.0.0-setup.exe(Inno Setup)

实测(Windows 11 x64 / Python 3.12.14 / pymobiledevice3 11.31.0 / iPhone 11 iOS 26.6.1)
- 隧道→镜像→DVT→下发定位→恢复真实定位 全链路跑通,连接 0.8s
- selftest 83/83、drift_sim 40/40、offline_tiles 13/13
- win_e2e 7/7、win_app_e2e 9/9
- 打包版 exe:发现设备 → 0.3s 隧道 → DVT → 两次下发 → 恢复真实定位 → 干净退出
This commit is contained in:
jbjf6666 committed 2026-10-16 01:41:59 +08:00
1 parent 85e9a53750
commit 4d55e2415f
26 files changed
+4515 -306

No files matched your search

+548
View File
@@ -0,0 +1,548 @@
"""跨平台适配层(macOS / Windows / Linux)。
见 docs/WINDOWS_PORT.md 第 9 节:平台相关代码集中到本模块,其余模块只依赖这里,
从而保持**单一代码库**,不给 Windows 分叉出第二份源码。
约定:
- 所有公开函数在任何平台上都必须可用(不存在的能力返回 None / 空列表,不抛异常)。
- 需要"管理员/root"的操作只在这里封装,便于在界面上给出对应提示。
"""
from __future__ import annotations
import os
import shutil
import signal
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
IS_WINDOWS = sys.platform == "win32"
IS_MACOS = sys.platform == "darwin"
IS_LINUX = sys.platform.startswith("linux")
#: 数据目录名(Windows: %LOCALAPPDATA%\iOSLocationTrack;macOS: ~/Library/Application Support/iOSLocationTrack)
APP_DIR_NAME = "iOSLocationTrack"
#: Chrome/Edge `--app=` 模式使用的独立用户数据目录
APP_WINDOW_PROFILE = ".ios-location-appwindow"
# --------------------------------------------------------------------------
# 路径
# --------------------------------------------------------------------------
def data_dir() -> Path:
"""打包运行时的可写数据目录(配置、瓦片缓存、隧道 pid 文件都写这里)。"""
if IS_WINDOWS:
base = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA")
root = Path(base) if base else Path.home() / "AppData" / "Local"
target = root / APP_DIR_NAME
elif IS_MACOS:
target = Path.home() / "Library" / "Application Support" / APP_DIR_NAME
else:
base = os.environ.get("XDG_DATA_HOME")
root = Path(base) if base else Path.home() / ".local" / "share"
target = root / APP_DIR_NAME
try:
target.mkdir(parents=True, exist_ok=True)
except OSError:
fallback = Path.home() / f".{APP_DIR_NAME.lower()}"
try:
fallback.mkdir(parents=True, exist_ok=True)
except OSError:
return Path.home()
return fallback
return target
def resolve_roots() -> tuple[Path, Path]:
"""返回 (资源根, 数据根)。
- 源码运行:两者都是项目目录
- 打包后:资源在 PyInstaller 解包目录(只读),数据写到用户可写目录
"""
if getattr(sys, "frozen", False):
resources = Path(getattr(sys, "_MEIPASS", Path(sys.executable).parent))
return resources, data_dir()
root = Path(__file__).resolve().parent.parent
return root, root
def venv_python(root: Path | str | None = None) -> Path | None:
"""返回项目 venv 内的解释器路径(不存在返回 None)。"""
base = Path(root) if root else resolve_roots()[0]
candidates = (
[base / ".venv" / "Scripts" / "python.exe", base / ".venv" / "Scripts" / "python"]
if IS_WINDOWS
else [base / ".venv" / "bin" / "python", base / ".venv" / "bin" / "python3"]
)
for candidate in candidates:
if candidate.exists():
return candidate
return None
def python_for_subprocess(root: Path | str | None = None) -> str:
"""选一个能 `import pymobiledevice3` 的解释器:优先项目 venv,其次当前解释器。"""
found = venv_python(root)
if found is not None:
return str(found)
return sys.executable
def module_command(root: Path | str | None = None) -> list[str]:
"""运行 `pymobiledevice3` CLI 的命令前缀。"""
return [python_for_subprocess(root), "-m", "pymobiledevice3"]
# --------------------------------------------------------------------------
# 界面窗口(Chrome/Edge 的 --app= 模式)
# --------------------------------------------------------------------------
def _windows_browser_candidates() -> list[Path]:
out: list[Path] = []
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
pf86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
local = os.environ.get("LOCALAPPDATA", "")
rel = [
(pf, r"Google\Chrome\Application\chrome.exe"),
(pf86, r"Google\Chrome\Application\chrome.exe"),
(local, r"Google\Chrome\Application\chrome.exe"),
(pf, r"Microsoft\Edge\Application\msedge.exe"),
(pf86, r"Microsoft\Edge\Application\msedge.exe"),
(pf, r"BraveSoftware\Brave-Browser\Application\brave.exe"),
(pf86, r"BraveSoftware\Brave-Browser\Application\brave.exe"),
(local, r"BraveSoftware\Brave-Browser\Application\brave.exe"),
(local, r"Chromium\Application\chrome.exe"),
]
for base, tail in rel:
if base:
out.append(Path(base) / tail)
# 注册表 App Paths 兜底(覆盖非默认安装位置)
try:
import winreg # type: ignore[import-not-found]
for exe in ("chrome.exe", "msedge.exe", "brave.exe"):
for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
for view in (winreg.KEY_WOW64_64KEY, winreg.KEY_WOW64_32KEY):
try:
with winreg.OpenKey(
hive,
rf"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{exe}",
0,
winreg.KEY_READ | view,
) as key:
value, _ = winreg.QueryValueEx(key, None)
if value:
out.append(Path(str(value).strip('"')))
except OSError:
continue
# 卸载信息里找 Chrome 的 InstallLocation
for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
for view in (winreg.KEY_WOW64_64KEY, winreg.KEY_WOW64_32KEY):
try:
with winreg.OpenKey(
hive,
r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Google Chrome",
0,
winreg.KEY_READ | view,
) as key:
loc, _ = winreg.QueryValueEx(key, "InstallLocation")
if loc:
out.append(Path(str(loc)) / "chrome.exe")
except OSError:
continue
except Exception:
pass
return out
def browser_candidates() -> list[Path]:
"""可用于 `--app=` 无地址栏窗口的 Chromium 系浏览器,按优先级排序。"""
if IS_WINDOWS:
return _windows_browser_candidates()
if IS_MACOS:
return [
Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
Path("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"),
Path("/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"),
Path("/Applications/Chromium.app/Contents/MacOS/Chromium"),
]
return [
Path("/usr/bin/google-chrome"),
Path("/usr/bin/chromium"),
Path("/usr/bin/chromium-browser"),
Path("/usr/bin/microsoft-edge"),
Path("/snap/bin/chromium"),
]
def find_app_browser() -> Path | None:
"""返回第一个真实存在的 Chromium 系浏览器。"""
seen: set[str] = set()
for candidate in browser_candidates():
key = str(candidate).lower()
if key in seen:
continue
seen.add(key)
try:
if candidate.is_file():
return candidate
except OSError:
continue
for name in ("chrome", "msedge", "chromium", "brave"):
found = shutil.which(name)
if found:
return Path(found)
return None
def app_window_profile_dir() -> Path:
return Path.home() / APP_WINDOW_PROFILE
# --------------------------------------------------------------------------
# 进程
# --------------------------------------------------------------------------
def spawn_detached_kwargs() -> dict[str, Any]:
"""Popen 关键字:让子进程独立于父进程(父进程被 Ctrl+C 时不连带杀掉隧道)。"""
if IS_WINDOWS:
# start_new_session 在 Windows 上被忽略;用 CREATE_NEW_PROCESS_GROUP +
# DETACHED_PROCESS 达到同样效果,否则子进程会跟着控制台一起收到 CTRL_C_EVENT。
flags = 0
flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
flags |= getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
return {"creationflags": flags}
return {"start_new_session": True}
def _no_window_kwargs() -> dict[str, Any]:
"""辅助进程(不弹黑窗)的 Popen 关键字。"""
if IS_WINDOWS:
return {"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)}
return {}
def process_cmdline(pid: int) -> str | None:
"""返回进程命令行;进程不存在或查询失败返回 None。"""
if pid <= 0:
return None
if IS_WINDOWS:
return _windows_cmdline(pid)
try:
result = subprocess.run(
["ps", "-p", str(pid), "-o", "command="],
capture_output=True,
text=True,
timeout=5,
)
except Exception:
return None
text = (result.stdout or "").strip()
return text or None
def _windows_cmdline(pid: int) -> str | None:
"""Windows 上取进程命令行:优先 PowerShell CIM,其次 wmic。"""
ps = shutil.which("powershell") or shutil.which("pwsh")
if ps:
try:
result = subprocess.run(
[
ps, "-NoProfile", "-NonInteractive", "-Command",
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={int(pid)}').CommandLine",
],
capture_output=True,
text=True,
timeout=15,
**_no_window_kwargs(),
)
text = (result.stdout or "").strip()
if text:
return text
except Exception:
pass
wmic = shutil.which("wmic")
if wmic:
try:
result = subprocess.run(
[wmic, "process", "where", f"processid={int(pid)}", "get", "commandline", "/value"],
capture_output=True,
text=True,
timeout=10,
**_no_window_kwargs(),
)
for line in (result.stdout or "").splitlines():
if line.strip().startswith("CommandLine="):
return line.split("=", 1)[1].strip() or None
except Exception:
pass
return None
def pid_alive(pid: int) -> bool:
if pid <= 0:
return False
if IS_WINDOWS:
# OpenProcess + GetExitCodeProcess;用 tasklist 太慢,这里走 ctypes
try:
import ctypes
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
STILL_ACTIVE = 259
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid))
if not handle:
return False
try:
code = ctypes.c_ulong()
if kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
return code.value == STILL_ACTIVE
return False
finally:
kernel32.CloseHandle(handle)
except Exception:
return False
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
except OSError:
return False
return True
def terminate_process(pid: int, force: bool = False, timeout: float = 3.0) -> bool:
"""结束单个进程(不递归)。返回是否已确认退出。"""
if pid <= 0 or pid == os.getpid():
return False
if IS_WINDOWS:
try:
subprocess.run(
["taskkill", "/PID", str(int(pid)), "/T", "/F"],
capture_output=True,
text=True,
timeout=15,
**_no_window_kwargs(),
)
except Exception:
return False
for _ in range(int(max(1, timeout / 0.2))):
if not pid_alive(pid):
return True
time.sleep(0.2)
return not pid_alive(pid)
sig = signal.SIGKILL if force else signal.SIGTERM
try:
os.kill(pid, sig)
except ProcessLookupError:
return True
except Exception:
return False
deadline = time.time() + timeout
while time.time() < deadline:
if not pid_alive(pid):
return True
time.sleep(0.2)
if not force:
try:
os.kill(pid, signal.SIGKILL)
except Exception:
pass
return not pid_alive(pid)
def terminate_process_tree(process: subprocess.Popen[Any] | None) -> None:
"""结束一个 Popen 及其子进程(隧道助手会派生子进程)。"""
if process is None or process.poll() is not None:
return
if IS_WINDOWS:
try:
subprocess.run(
["taskkill", "/PID", str(int(process.pid)), "/T", "/F"],
capture_output=True,
text=True,
timeout=15,
**_no_window_kwargs(),
)
except Exception:
try:
process.kill()
except Exception:
pass
else:
try:
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
except Exception:
try:
process.terminate()
except Exception:
pass
try:
process.wait(timeout=4)
except Exception:
if not IS_WINDOWS:
try:
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
except Exception:
pass
else:
terminate_process(process.pid, force=True)
def find_pid_by_cmdline(needles: list[str], image_names: list[str] | None = None) -> list[int]:
"""按命令行关键字找进程(Windows 用一次 CIM 批量查询,POSIX 用 pgrep)。
仅用于清理本工具自己遗留的隧道进程。
"""
if IS_WINDOWS:
ps = shutil.which("powershell") or shutil.which("pwsh")
if not ps:
return []
try:
result = subprocess.run(
[
ps, "-NoProfile", "-NonInteractive", "-Command",
"Get-CimInstance Win32_Process | "
"Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress",
],
capture_output=True,
text=True,
timeout=25,
**_no_window_kwargs(),
)
except Exception:
return []
import json
try:
data = json.loads((result.stdout or "").strip() or "[]")
except Exception:
return []
if isinstance(data, dict):
data = [data]
found: list[int] = []
me = os.getpid()
for item in data:
if not isinstance(item, dict):
continue
pid = int(item.get("ProcessId") or 0)
if pid <= 0 or pid == me:
continue
name = str(item.get("Name") or "")
cmd = str(item.get("CommandLine") or "")
if image_names and not any(name.lower() == n.lower() for n in image_names):
continue
if any(needle.lower() in cmd.lower() for needle in needles):
found.append(pid)
return found
pids: list[int] = []
for needle in needles:
try:
result = subprocess.run(
["pgrep", "-f", needle], capture_output=True, text=True, timeout=5
)
except Exception:
continue
for line in (result.stdout or "").split():
try:
pid = int(line)
except ValueError:
continue
if pid != os.getpid() and pid not in pids:
pids.append(pid)
return pids
# --------------------------------------------------------------------------
# 权限
# --------------------------------------------------------------------------
def is_admin() -> bool:
"""当前进程是否具备管理员/root 权限。"""
if IS_WINDOWS:
try:
import ctypes
return bool(ctypes.windll.shell32.IsUserAnAdmin()) # type: ignore[attr-defined]
except Exception:
return False
try:
return os.geteuid() == 0
except AttributeError:
return False
def admin_hint() -> str:
"""界面上展示的"怎么以管理员身份运行"文案。"""
if IS_WINDOWS:
return "请右键程序图标选择「以管理员身份运行」(userspace 隧道需要安装/启用 Wintun 虚拟网卡)"
return "请在终端执行 sudo ./run.sh 以管理员模式启动"
def relaunch_elevated(argv: list[str] | None = None) -> bool:
"""以提权方式重新启动自身。返回是否成功发起(不等待结果)。"""
args = argv if argv is not None else sys.argv
if IS_WINDOWS:
try:
import ctypes
if getattr(sys, "frozen", False):
exe, params = sys.executable, subprocess.list2cmdline(args[1:])
else:
exe = sys.executable
params = subprocess.list2cmdline([str(Path(args[0]).resolve()), *args[1:]])
# SW_SHOWNORMAL = 1
rc = ctypes.windll.shell32.ShellExecuteW( # type: ignore[attr-defined]
None, "runas", exe, params, None, 1
)
return int(rc) > 32
except Exception:
return False
if IS_MACOS:
inner = " ".join(f"'{a}'" for a in args)
script = f'do shell script "{inner}" with administrator privileges'
try:
subprocess.Popen(["osascript", "-e", script])
return True
except Exception:
return False
return False
# --------------------------------------------------------------------------
# 信号
# --------------------------------------------------------------------------
def termination_signals() -> list[int]:
"""可以安全注册的退出信号(Windows 没有 SIGHUP)。"""
names = ["SIGINT", "SIGTERM"]
if not IS_WINDOWS:
names.append("SIGHUP")
out: list[int] = []
for name in names:
value = getattr(signal, name, None)
if isinstance(value, int):
out.append(value)
return out
def open_in_file_manager(path: Path) -> bool:
"""在系统文件管理器里打开一个目录。"""
try:
if IS_WINDOWS:
os.startfile(str(path)) # type: ignore[attr-defined]
elif IS_MACOS:
subprocess.Popen(["open", str(path)])
else:
subprocess.Popen(["xdg-open", str(path)])
return True
except Exception:
return False
def platform_label() -> str:
if IS_WINDOWS:
return "Windows"
if IS_MACOS:
return "macOS"
return "Linux"