feat: release 1.4.0 with isolated upgrades and device discovery
This commit is contained in:
1 parent
a6a3ee2eb0
commit
df66897ff4
41 files changed
+1564
-126
No files matched your search
@@ -134,6 +134,9 @@ class Settings(BaseSettings):
|
||||
ADB_HOST: str | None = Field(default=DEFAULT_ADB_HOST)
|
||||
ADB_PORT: int | None = Field(default=DEFAULT_ADB_PORT)
|
||||
ADB_DEVICE_SERIAL: str | None = None
|
||||
ARTEMIS_ADB_AUTO_CONNECT: bool = True
|
||||
ARTEMIS_ADB_DISCOVERY_INTERVAL_SECONDS: float = Field(default=10, ge=1, le=300)
|
||||
ARTEMIS_ADB_DISCOVERY_TIMEOUT_SECONDS: float = Field(default=5, gt=0, le=30)
|
||||
# UI hierarchy backend: "auto" = Accessibility Helper with UIAutomator2
|
||||
# fallback, "helper" = helper only, "uiautomator" = UIAutomator2 only.
|
||||
ARTEMIS_HIERARCHY_BACKEND: str = Field(default="auto")
|
||||
|
||||
@@ -21,6 +21,7 @@ import time
|
||||
from typing import Any
|
||||
|
||||
from artemis.core.diagnostics.adb_server_connection import adb_server_connection
|
||||
from artemis.runtime.adb_discovery import adb_device_discovery
|
||||
from artemis.core.diagnostics.probes.adb_probe import AdbDeviceProbe
|
||||
from artemis.core.diagnostics.probes.base import BaseProbe
|
||||
from artemis.core.diagnostics.probes.credentials_probe import (
|
||||
@@ -353,7 +354,12 @@ class ReadinessEngine:
|
||||
}
|
||||
|
||||
logger.info("[ReadinessEngine] Restarting ADB server...")
|
||||
return await asyncio.to_thread(_restart_sync)
|
||||
result = await asyncio.to_thread(_restart_sync)
|
||||
if result.get("success"):
|
||||
result["automatic_connections"] = await asyncio.to_thread(
|
||||
adb_device_discovery.refresh, adb_path, force=True
|
||||
)
|
||||
return result
|
||||
|
||||
async def launch_emulator(self, avd_name: str) -> dict[str, Any]:
|
||||
"""Launch an Android emulator by AVD name in the background and track its lifecycle."""
|
||||
@@ -417,8 +423,10 @@ class ReadinessEngine:
|
||||
)
|
||||
output = (res.stdout + "\n" + res.stderr).strip()
|
||||
success = "connected to" in output.lower() and "failed" not in output.lower()
|
||||
remembered = adb_device_discovery.remember(target) if success else False
|
||||
return {
|
||||
"success": success,
|
||||
"remembered": remembered,
|
||||
"target": target,
|
||||
"output": output,
|
||||
"message": f"Connected to {target}" if success else output,
|
||||
|
||||
@@ -33,6 +33,7 @@ from artemis.core.diagnostics.schema import (
|
||||
)
|
||||
from artemis.platform import OSType, platform
|
||||
from artemis.toolchain import toolchain
|
||||
from artemis.runtime.adb_discovery import adb_device_discovery
|
||||
from artemis.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -477,6 +478,7 @@ class AdbDeviceProbe(BaseProbe):
|
||||
async def _parse_adb_devices(self, adb_path: str) -> list[DeviceInfo]:
|
||||
"""Execute `adb devices -l` and extract structured device metadata."""
|
||||
devices: list[DeviceInfo] = []
|
||||
await asyncio.to_thread(adb_device_discovery.refresh, adb_path)
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
adb_path,
|
||||
@@ -668,6 +670,7 @@ class AdbDeviceProbe(BaseProbe):
|
||||
"installed": True,
|
||||
"adb_path": adb_path,
|
||||
"adb_version": adb_ver,
|
||||
"automatic_connections": adb_device_discovery.results.copy(),
|
||||
"adb_keys": key_status.to_dict(),
|
||||
"emulator_path": emulator_path,
|
||||
"is_emulator_in_path": is_emu_in_path,
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Bounded TCP device discovery shared by the console, CLI and MCP.
|
||||
|
||||
USB and Android SDK emulators are discovered by ADB itself. Third-party
|
||||
emulators expose TCP adbd listeners; only listeners owned by known emulator
|
||||
processes are probed. Explicitly connected TCP devices are remembered separately.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
import psutil
|
||||
|
||||
from artemis.config import settings
|
||||
from artemis.config.paths import get_app_dir
|
||||
from artemis.runtime.adb_endpoint import AdbSession, current_adb_endpoint
|
||||
from artemis.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _canonical_target(target: str) -> str:
|
||||
host, separator, port = target.rpartition(":")
|
||||
if separator and host.lower() in {"localhost", "127.0.0.1"}:
|
||||
return f"127.0.0.1:{port}"
|
||||
return target
|
||||
|
||||
|
||||
def emulator_listeners() -> list[list[str]]:
|
||||
"""Return listener groups, one per running emulator (never scan a network)."""
|
||||
owners = set()
|
||||
for process in psutil.process_iter(["pid", "name", "exe"]):
|
||||
info = process.info
|
||||
name = str(info.get("name") or "").lower().removesuffix(".exe")
|
||||
path = str(info.get("exe") or "").lower()
|
||||
known = name in {
|
||||
"mumunxdevice",
|
||||
"nemuheadless",
|
||||
"mumuheadless",
|
||||
"ldvboxheadless",
|
||||
"noxvmhandle",
|
||||
"memuheadless",
|
||||
"hd-player",
|
||||
}
|
||||
vendor_vm = name == "vboxheadless" and any(
|
||||
vendor in path for vendor in ("mumu", "nemu", "ldplayer", "leidian", "nox", "memu")
|
||||
)
|
||||
if known or vendor_vm:
|
||||
owners.add(info["pid"])
|
||||
if not owners:
|
||||
return []
|
||||
groups: dict[int, set[int]] = {}
|
||||
for connection in psutil.net_connections(kind="tcp"):
|
||||
address = connection.laddr
|
||||
pid = connection.pid
|
||||
if (
|
||||
pid is not None
|
||||
and pid in owners
|
||||
and address
|
||||
and connection.status == psutil.CONN_LISTEN
|
||||
and address.ip in {"127.0.0.1", "0.0.0.0"}
|
||||
):
|
||||
groups.setdefault(pid, set()).add(address.port)
|
||||
# Prefer current MuMu ports over its compatibility aliases (same adbd).
|
||||
return [
|
||||
[f"127.0.0.1:{port}" for port in sorted(ports, key=lambda p: (p in {5555, 7555}, p))]
|
||||
for ports in groups.values()
|
||||
]
|
||||
|
||||
|
||||
def is_adbd(target: str, timeout: float) -> bool:
|
||||
"""Confirm an ADB CNXN/AUTH header before giving a local port to adb connect."""
|
||||
host, port = target.rsplit(":", 1)
|
||||
payload = b"host::\x00"
|
||||
command = int.from_bytes(b"CNXN", "little")
|
||||
packet = struct.pack(
|
||||
"<6I", command, 0x01000000, 4096, len(payload), sum(payload), command ^ 0xFFFFFFFF
|
||||
)
|
||||
try:
|
||||
with socket.create_connection((host, int(port)), timeout=timeout) as stream:
|
||||
stream.sendall(packet + payload)
|
||||
header = b""
|
||||
deadline = time.monotonic() + timeout
|
||||
while len(header) < 24:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return False
|
||||
stream.settimeout(remaining)
|
||||
chunk = stream.recv(24 - len(header))
|
||||
if not chunk:
|
||||
return False
|
||||
header += chunk
|
||||
kind, _, _, length, _, magic = struct.unpack("<6I", header)
|
||||
return (
|
||||
kind.to_bytes(4, "little") in {b"CNXN", b"AUTH"}
|
||||
and magic == kind ^ 0xFFFFFFFF
|
||||
and length <= 1048576
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
class AdbDeviceDiscovery:
|
||||
"""Coalesce discovery and reconnect only missing transports, with a total budget."""
|
||||
|
||||
def __init__(self):
|
||||
self._gate = threading.Lock()
|
||||
self._last_scan = float("-inf")
|
||||
self.results: list[dict[str, object]] = []
|
||||
self._cursor = 0
|
||||
|
||||
@staticmethod
|
||||
def _directory() -> Path:
|
||||
return get_app_dir() / "adb-devices"
|
||||
|
||||
def remember(self, target: str) -> bool:
|
||||
"""Save an explicitly connected device, without credentials or shared-list races."""
|
||||
directory = self._directory()
|
||||
temporary = None
|
||||
try:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
destination = directory / (hashlib.sha256(target.encode()).hexdigest() + ".json")
|
||||
fd, temporary = tempfile.mkstemp(dir=directory, prefix=".device-")
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
||||
json.dump({"target": target}, stream)
|
||||
os.replace(temporary, destination)
|
||||
return True
|
||||
except OSError as exc:
|
||||
logger.warning(f"Cannot remember ADB device {target}: {exc}")
|
||||
return False
|
||||
finally:
|
||||
if temporary:
|
||||
Path(temporary).unlink(missing_ok=True)
|
||||
|
||||
def _remembered(self) -> list[str]:
|
||||
targets = []
|
||||
for path in self._directory().glob("*.json"):
|
||||
try:
|
||||
target = json.loads(path.read_text(encoding="utf-8"))["target"]
|
||||
host, port = target.rsplit(":", 1)
|
||||
if host and 0 < int(port) < 65536:
|
||||
targets.append(target)
|
||||
except (OSError, ValueError, KeyError, TypeError, AttributeError) as exc:
|
||||
logger.warning(f"Invalid remembered ADB device {path.name}: {exc}")
|
||||
return targets
|
||||
|
||||
def refresh(self, adb_path: str, *, force: bool = False) -> list[dict[str, object]]:
|
||||
if not settings.ARTEMIS_ADB_AUTO_CONNECT or not current_adb_endpoint().is_local_default:
|
||||
self.results = []
|
||||
return []
|
||||
if not self._gate.acquire(blocking=False):
|
||||
return self.results.copy()
|
||||
try:
|
||||
if (
|
||||
not force
|
||||
and time.monotonic() - self._last_scan
|
||||
< settings.ARTEMIS_ADB_DISCOVERY_INTERVAL_SECONDS
|
||||
):
|
||||
return self.results.copy()
|
||||
self._last_scan = time.monotonic()
|
||||
self.results = []
|
||||
self._refresh(adb_path)
|
||||
except (OSError, psutil.Error, ValueError, subprocess.SubprocessError) as exc:
|
||||
logger.warning(f"ADB automatic discovery unavailable: {exc}")
|
||||
self.results.append({"success": False, "message": str(exc)})
|
||||
finally:
|
||||
self._gate.release()
|
||||
return self.results.copy()
|
||||
|
||||
def _refresh(self, adb_path: str) -> None:
|
||||
session = AdbSession(current_adb_endpoint(), adb_path=adb_path)
|
||||
deadline = time.monotonic() + settings.ARTEMIS_ADB_DISCOVERY_TIMEOUT_SECONDS
|
||||
devices = session.run(
|
||||
["devices"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=max(deadline - time.monotonic(), 0.01),
|
||||
)
|
||||
if devices.returncode:
|
||||
return
|
||||
ready = {
|
||||
_canonical_target(parts[0])
|
||||
for line in devices.stdout.splitlines()
|
||||
if len(parts := line.split()) >= 2 and parts[1] in {"device", "unauthorized"}
|
||||
}
|
||||
try:
|
||||
groups = emulator_listeners()
|
||||
except (OSError, psutil.Error) as exc:
|
||||
# Missing process-inspection permissions must not stop reconnection
|
||||
# of explicitly configured phones or make device enumeration fail.
|
||||
groups = []
|
||||
logger.warning(f"Cannot inspect local emulator listeners: {exc}")
|
||||
self.results.append({"success": False, "message": str(exc)})
|
||||
remembered = self._remembered()
|
||||
targets = [(group, True) for group in groups] + [([target], False) for target in remembered]
|
||||
# Resume at a different group if a slow/offline peer exhausts the budget.
|
||||
# Large multi-instance installations must not starve later devices.
|
||||
count = len(targets)
|
||||
start = self._cursor % count if count else 0
|
||||
for offset in range(count):
|
||||
index = (start + offset) % count
|
||||
candidates, probe = targets[index]
|
||||
self._cursor = (index + 1) % count
|
||||
if any(_canonical_target(target) in ready for target in candidates):
|
||||
continue
|
||||
for target in candidates:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return
|
||||
if probe and not is_adbd(target, min(remaining, 0.3)):
|
||||
continue
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return
|
||||
try:
|
||||
result = session.run(
|
||||
["connect", target],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=min(remaining, 2),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.results.append(
|
||||
{"target": target, "success": False, "message": "ADB 连接超时"}
|
||||
)
|
||||
continue
|
||||
output = (result.stdout + result.stderr).strip()
|
||||
success = (
|
||||
result.returncode == 0
|
||||
and "connected to" in output.lower()
|
||||
and "failed" not in output.lower()
|
||||
)
|
||||
state = self._wait_for_transport(session, target, deadline) if success else None
|
||||
pending = success and state is None
|
||||
if success and state is None:
|
||||
success = False
|
||||
output = f"{output};已发起连接,等待设备就绪,下次刷新会重试"
|
||||
self.results.append(
|
||||
{
|
||||
"target": target,
|
||||
"success": success,
|
||||
"state": state,
|
||||
"pending": pending,
|
||||
"message": output,
|
||||
}
|
||||
)
|
||||
if success:
|
||||
logger.info(f"ADB 自动连接:{target} — {output}")
|
||||
ready.add(_canonical_target(target))
|
||||
break
|
||||
if pending:
|
||||
logger.info(f"ADB 设备连接中:{target} — {output}")
|
||||
break # Do not create a second alias while the first transport is settling.
|
||||
logger.warning(f"ADB 自动连接失败:{target} — {output}")
|
||||
|
||||
@staticmethod
|
||||
def _wait_for_transport(session: AdbSession, target: str, deadline: float) -> str | None:
|
||||
"""adb connect can return before its transport appears in adb devices."""
|
||||
while (remaining := deadline - time.monotonic()) > 0:
|
||||
try:
|
||||
result = session.run(["devices"], capture_output=True, text=True, timeout=remaining)
|
||||
except subprocess.TimeoutExpired:
|
||||
return None
|
||||
for line in result.stdout.splitlines():
|
||||
parts = line.split()
|
||||
if (
|
||||
len(parts) >= 2
|
||||
and _canonical_target(parts[0]) == _canonical_target(target)
|
||||
and parts[1] in {"device", "unauthorized"}
|
||||
):
|
||||
return parts[1]
|
||||
time.sleep(min(0.1, max(deadline - time.monotonic(), 0)))
|
||||
return None
|
||||
|
||||
|
||||
adb_device_discovery = AdbDeviceDiscovery()
|
||||
@@ -26,6 +26,7 @@ import time
|
||||
from typing import Any
|
||||
|
||||
from artemis.runtime.adb_endpoint import AdbSession, current_adb_endpoint
|
||||
from artemis.runtime.adb_discovery import adb_device_discovery
|
||||
from artemis.runtime.device_lock import DeviceExecutionLock
|
||||
from artemis.toolchain import toolchain
|
||||
from artemis.utils.logger import get_logger
|
||||
@@ -108,6 +109,7 @@ class DevicePool:
|
||||
adb = self._resolve_adb()
|
||||
if not adb:
|
||||
return None
|
||||
adb_device_discovery.refresh(adb)
|
||||
try:
|
||||
res = subprocess.run(
|
||||
[adb, "devices", "-l"],
|
||||
@@ -132,6 +134,7 @@ class DevicePool:
|
||||
adb = self._resolve_adb()
|
||||
if not adb:
|
||||
return None
|
||||
await asyncio.to_thread(adb_device_discovery.refresh, adb)
|
||||
proc = None
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
|
||||
Reference in new issue
Block a user