's'
This commit is contained in:
197
src/scrcpy_stream.py
Normal file
197
src/scrcpy_stream.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""在宿主上拉起官方 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
|
||||
|
||||
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_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 = 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)
|
||||
Reference in New Issue
Block a user