"""在宿主上拉起官方 scrcpy-server(raw_stream),经 ADB forward 读 H.264,再推到 WebSocket。 与桌面端 [escrcpy](https://github.com/viarotel-org/escrcpy) / [scrcpy](https://github.com/Genymobile/scrcpy) 使用同一套 scrcpy-server;版本号须与 jar 内 BuildConfig 完全一致(见官方 doc/develop.md)。 """ from __future__ import annotations import asyncio import json import os import random import re import shutil import socket from pathlib import Path from typing import Any from src.android_control import _adb_prefix, _adb_text, _run_cmd, adb_available from src.scrcpy_vendor import ensure_scrcpy_jar BASE_DIR = Path(__file__).resolve().parent.parent REMOTE_JAR = "/data/local/tmp/scrcpy-web-hub.jar" _VENDOR_JAR = BASE_DIR / "vendor" / "scrcpy-server.jar" _VERSION_FILE = BASE_DIR / "vendor" / "scrcpy-server.version" def default_server_version() -> str: v = (os.environ.get("SCRCPY_SERVER_VERSION") or "").strip() if v: return v try: if _VERSION_FILE.is_file(): line = _VERSION_FILE.read_text(encoding="utf-8").splitlines()[0].strip() if line: return line except OSError: pass return "3.3.3" def resolve_scrcpy_server_jar() -> Path | None: env = (os.environ.get("SCRCPY_SERVER_JAR") or "").strip() if env: p = Path(env).expanduser() if p.is_file(): return p.resolve() if _VENDOR_JAR.is_file(): return _VENDOR_JAR.resolve() exe = shutil.which("scrcpy") if exe: parent = Path(exe).resolve().parent for name in ("scrcpy-server", "scrcpy-server.jar"): cand = parent / name if cand.is_file(): return cand.resolve() return None def scrcpy_stream_info() -> dict[str, Any]: jar = resolve_scrcpy_server_jar() ver = default_server_version() return { "adb_ok": adb_available(), "server_jar": jar is not None, "server_path": str(jar) if jar else None, "server_version": ver, "remote_jar": REMOTE_JAR, "hint_zh": ( "服务启动时会尝试从 Genymobile 官方 Release 拉取与 vendor/scrcpy-server.version 匹配的 scrcpy-server;" "离线环境可预置 vendor/scrcpy-server.jar 或设置 SCRCPY_SERVER_JAR。" if not jar else "scrcpy-server 已就绪。" ), "hint_en": ( "On startup the hub tries to download the matching scrcpy-server from Genymobile releases; " "for offline use place vendor/scrcpy-server.jar or set SCRCPY_SERVER_JAR." if not jar else "scrcpy-server is ready." ), } _SERIAL_SAFE = re.compile(r"^[A-Za-z0-9._:\-]+$") def _validate_serial(serial: str) -> str: s = (serial or "").strip() if not s or len(s) > 256 or not _SERIAL_SAFE.match(s): raise ValueError("Invalid device serial") return s def _pick_local_port() -> int: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: s.bind(("127.0.0.1", 0)) _host, port = s.getsockname() return int(port) finally: s.close() async def _adb_forward_add(serial: str, local_port: int, socket_suffix: str) -> None: spec = f"tcp:{local_port}" abstract = f"localabstract:scrcpy_{socket_suffix}" code, _out, err = await _adb_text(serial, "forward", spec, abstract, timeout=25.0) if code != 0: raise RuntimeError(err or f"adb forward {spec} {abstract} failed") async def _adb_forward_remove(local_port: int) -> None: spec = f"tcp:{local_port}" await _adb_text(None, "forward", "--remove", spec, timeout=15.0) async def _push_server(serial: str, jar: Path) -> None: code, _out, err = await _run_cmd( _adb_prefix(serial) + ["push", str(jar), REMOTE_JAR], timeout=120.0, ) if code != 0: raise RuntimeError(err or "adb push scrcpy-server failed") async def _start_server_process(serial: str, version: str, scid_hex: str, max_size: int) -> asyncio.subprocess.Process: """在设备上后台启动 app_process;tunnel_forward 模式下等待宿主机连入 forward 端口。""" # 与 DesktopConnection.getSocketName 一致:scrcpy_%08x args_line = ( f"{version} tunnel_forward=true audio=false control=false cleanup=false raw_stream=true " f"max_size={max_size} scid={scid_hex}" ) # 使用 sh -c 与后台 &,避免阻塞 adb shell inner = ( f"CLASSPATH={REMOTE_JAR} /system/bin/app_process / com.genymobile.scrcpy.Server {args_line} " ">/dev/null 2>&1 &" ) proc = await asyncio.create_subprocess_exec( *_adb_prefix(serial), "shell", "sh", "-c", inner, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE, ) _stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15.0) rc = int(proc.returncode or 0) if rc != 0: msg = (stderr or b"").decode("utf-8", "replace").strip() raise RuntimeError(msg or f"app_process start failed (code {rc})") return proc async def open_scrcpy_h264_stream( serial: str, *, max_size: int = 1920, connect_attempts: int = 22, connect_delay: float = 0.28, ) -> tuple[int, str, asyncio.StreamReader, asyncio.StreamWriter]: """建立 forward + 启动 server,并返回 (local_port, scid_hex, reader, writer)。""" _validate_serial(serial) if not adb_available(): raise RuntimeError("adb is not installed") jar = resolve_scrcpy_server_jar() if not jar: ensure_scrcpy_jar(BASE_DIR) jar = resolve_scrcpy_server_jar() if not jar: raise RuntimeError( "scrcpy-server 不可用:自动拉取失败或处于离线;请检查网络后重启服务,或预置 vendor/scrcpy-server.jar", ) version = default_server_version() scid = random.randint(0x1000_0000, 0x7FFF_FFFF) scid_hex = f"{scid:08x}" port = _pick_local_port() await _push_server(serial, jar) await _adb_forward_add(serial, port, scid_hex) await _start_server_process(serial, version, scid_hex, max(0, min(int(max_size), 8192))) last_err: str | None = None for attempt in range(connect_attempts): await asyncio.sleep(connect_delay) try: reader, writer = await asyncio.wait_for( asyncio.open_connection("127.0.0.1", port), timeout=4.0, ) return port, scid_hex, reader, writer except (OSError, asyncio.TimeoutError) as exc: last_err = str(exc) continue await _adb_forward_remove(port) raise RuntimeError(last_err or "Could not connect to scrcpy forward port") async def cleanup_scrcpy_session(local_port: int, writer: asyncio.StreamWriter | None) -> None: if writer is not None and not writer.is_closing(): writer.close() try: await writer.wait_closed() except OSError: pass await _adb_forward_remove(local_port)