This commit is contained in:
eric
2026-03-29 02:57:08 -05:00
parent ef87c43b33
commit e3ac026710
15 changed files with 799 additions and 222 deletions

197
src/scrcpy_stream.py Normal file
View File

@@ -0,0 +1,197 @@
"""在宿主上拉起官方 scrcpy-serverraw_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
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": "将官方 scrcpy 发行包中的 scrcpy-server或 scrcpy-server.jar放到项目 vendor/scrcpy-server.jar"
"或设置环境变量 SCRCPY_SERVER_JAR版本字符串须与该 jar 完全一致(默认可配 vendor/scrcpy-server.version 或 SCRCPY_SERVER_VERSION",
"hint_en": "Place scrcpy-server from the official scrcpy release in vendor/scrcpy-server.jar, "
"or set SCRCPY_SERVER_JAR; the version string must match the jar (vendor/scrcpy-server.version or SCRCPY_SERVER_VERSION).",
}
_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_processtunnel_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 = 12,
connect_delay: float = 0.22,
) -> 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:
raise RuntimeError(
"scrcpy-server not found; set SCRCPY_SERVER_JAR or add 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)