Files
ios-location-track/core/platform.py
T
jbjf6666 4d55e2415f 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 → 两次下发 → 恢复真实定位 → 干净退出
2026-10-16 01:41:59 +08:00

549 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""跨平台适配层(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"