210 lines
7.0 KiB
Python
210 lines
7.0 KiB
Python
"""在宿主上拉起官方 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)
|