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:
1 parent
85e9a53750
commit
4d55e2415f
26 files changed
+4515
-306
No files matched your search
@@ -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"
|
||||
Reference in new issue
Block a user