This commit is contained in:
eric
2026-03-29 02:33:37 -05:00
parent 16dc9abbbb
commit ef87c43b33
17 changed files with 1197 additions and 407 deletions

View File

@@ -7,7 +7,7 @@ import shutil
import tempfile
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Optional
from typing import Any, Optional
from urllib.parse import quote
@@ -94,6 +94,24 @@ def _parse_device_line(line: str) -> Optional[dict]:
}
def device_transport_hint(serial: str) -> str:
"""USB 板子 / Redroid·无线 adb / 模拟器 的粗分类,供前端策略与展示。"""
s = (serial or "").strip()
if not s:
return "unknown"
if s.startswith("emulator-"):
return "emulator"
if ":" in s:
return "tcp"
return "usb"
def enrich_device_row(row: dict) -> dict[str, Any]:
out = dict(row)
out["transport"] = device_transport_hint(str(row.get("serial", "")))
return out
async def list_android_devices() -> list[dict]:
if not adb_available():
return []
@@ -104,10 +122,79 @@ async def list_android_devices() -> list[dict]:
for raw in out.splitlines():
parsed = _parse_device_line(raw)
if parsed:
devices.append(parsed)
devices.append(enrich_device_row(parsed))
return devices
async def adb_connect_address(address: str) -> dict[str, Any]:
"""adb connect host[:port],用于 Redroid / 无线调试 / 局域网设备。"""
if not adb_available():
raise RuntimeError("adb is not installed")
addr = (address or "").strip()
if not addr or len(addr) > 200:
raise ValueError("Invalid address")
if any(ch in addr for ch in ";|&$\n\r`"):
raise ValueError("Invalid characters in address")
code, out, err = await _adb_text(None, "connect", addr, timeout=30.0)
text = (out or err or "").strip()
tl = text.lower()
ok = code == 0 and (
"connected" in tl or "already" in tl
) and "failed" not in tl and "unable to connect" not in tl
return {"ok": ok, "code": code, "message": text}
async def adb_disconnect_address(address: str = "") -> dict[str, Any]:
"""adb disconnect [serial];空则断开全部 TCP 会话。"""
if not adb_available():
raise RuntimeError("adb is not installed")
addr = (address or "").strip()
if addr and len(addr) > 200:
raise ValueError("Invalid address")
if addr and any(ch in addr for ch in ";|&$\n\r`"):
raise ValueError("Invalid characters in address")
if addr:
code, out, err = await _adb_text(None, "disconnect", addr, timeout=20.0)
else:
code, out, err = await _adb_text(None, "disconnect", timeout=20.0)
text = (out or err or "").strip()
return {"ok": code == 0, "code": code, "message": text}
async def adb_pair_code(host: str, port: int, pairing_code: str) -> dict[str, Any]:
"""无线配对Android 11+):向 `adb pair host:port` 标准输入写入配对码。"""
if not adb_available():
raise RuntimeError("adb is not installed")
h = (host or "").strip()
pc = (pairing_code or "").strip()
if not h or not pc or len(pc) > 16:
raise ValueError("Invalid host or pairing code")
if any(ch in h for ch in ";|&$\n\r`"):
raise ValueError("Invalid host")
pp = max(1, min(int(port), 65535))
target = f"{h}:{pp}"
adb_bin = shutil.which("adb") or "adb"
proc = await asyncio.create_subprocess_exec(
adb_bin,
"pair",
target,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate((pc + "\n").encode("utf-8")), timeout=50.0)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
raise RuntimeError("adb pair timed out") from None
text = ((stdout or b"") + b"\n" + (stderr or b"")).decode("utf-8", "replace").strip()
rc = int(proc.returncode or 0)
tl = text.lower()
ok = rc == 0 and ("successfully paired" in tl or "pairing successful" in tl or "paired with" in tl)
return {"ok": ok, "code": rc, "message": text}
async def _shell(serial: str, command: str, timeout: float = 20.0) -> str:
code, out, err = await _adb_text(serial, "shell", command, timeout=timeout)
if code != 0:
@@ -123,6 +210,14 @@ async def _get_display_size(serial: str) -> tuple[int, int]:
return int(match.group(1)), int(match.group(2))
async def android_display_metrics(serial: str) -> dict[str, int]:
"""逻辑分辨率wm size与截图 PNG 像素可能略有差异;触控映射优先用截图 natural 尺寸。"""
if not serial:
raise ValueError("Missing Android device serial")
w, h = await _get_display_size(serial)
return {"width": w, "height": h}
async def android_device_overview(serial: str) -> dict:
if not serial:
raise ValueError("Missing Android device serial")
@@ -142,20 +237,35 @@ async def android_device_overview(serial: str) -> dict:
results = dict(await asyncio.gather(*(grab(key, cmd) for key, cmd in props.items())))
battery = await _shell(
serial,
"dumpsys battery | grep -E 'level|status|powered' | sed 's/^[[:space:]]*//'",
timeout=10.0,
)
focus = await _shell(
serial,
"(dumpsys window windows | grep -E 'mCurrentFocus' | tail -n 1) || "
"(dumpsys activity activities | grep -E 'mResumedActivity' | tail -n 1)",
timeout=10.0,
)
resolution = await _shell(serial, "wm size | tail -n 1", timeout=10.0)
rotation = await _shell(serial, "settings get system user_rotation", timeout=10.0)
screen_on = await _shell(serial, "dumpsys power | grep -E 'Display Power|mHoldingDisplaySuspendBlocker'", timeout=10.0)
async def bat() -> str:
return await _shell(
serial,
"dumpsys battery | grep -E 'level|status|powered' | sed 's/^[[:space:]]*//'",
timeout=12.0,
)
async def foc() -> str:
return await _shell(
serial,
"(dumpsys window windows | grep -E 'mCurrentFocus' | tail -n 1) || "
"(dumpsys activity activities | grep -E 'mResumedActivity' | tail -n 1)",
timeout=12.0,
)
async def res() -> str:
return await _shell(serial, "wm size | tail -n 1", timeout=8.0)
async def rot() -> str:
return await _shell(serial, "settings get system user_rotation", timeout=8.0)
async def pwr() -> str:
return await _shell(
serial,
"dumpsys power | grep -E 'Display Power|mHoldingDisplaySuspendBlocker'",
timeout=10.0,
)
battery, focus, resolution, rotation, screen_on = await asyncio.gather(bat(), foc(), res(), rot(), pwr())
return {
"serial": serial,
@@ -199,8 +309,22 @@ async def android_packages(serial: str, query: str = "", limit: int = 60) -> lis
if not serial:
raise ValueError("Missing Android device serial")
output = await _shell(serial, "pm list packages", timeout=25.0)
needle = query.strip().lower()
needle_raw = query.strip()
needle = needle_raw.lower()
lim = max(1, min(limit, 200))
output = ""
if needle_raw and all(c.isalnum() or c in "._-" for c in needle_raw):
try:
safe = needle_raw.replace("'", "'\\''")
output = await _shell(
serial,
f"pm list packages | grep -Fi -- '{safe}' | head -n {lim + 40}",
timeout=22.0,
)
except RuntimeError:
output = ""
if not output.strip():
output = await _shell(serial, "pm list packages", timeout=28.0)
results: list[dict] = []
for raw in output.splitlines():
package = raw.strip()
@@ -307,10 +431,10 @@ async def android_logcat_recent(serial: str, lines: int = 200) -> str:
return await _shell(serial, f"logcat -d -t {n}", timeout=60.0)
async def android_screenshot(serial: str) -> bytes:
async def android_screenshot(serial: str, *, exec_timeout: float = 14.0) -> bytes:
if not serial:
raise ValueError("Missing Android device serial")
code, data, err = await _adb_binary(serial, "exec-out", "screencap", "-p", timeout=20.0)
code, data, err = await _adb_binary(serial, "exec-out", "screencap", "-p", timeout=exec_timeout)
if code == 0 and data:
normalized = data.replace(b"\r\n", b"\n")
if _is_png(data) or normalized.startswith(b"\x89PNG"):
@@ -403,7 +527,7 @@ async def android_action(action: str, serial: str, payload: Optional[dict] = Non
if action == "double_tap":
x = int(payload.get("x", 0))
y = int(payload.get("y", 0))
await _shell(serial, f"input tap {x} {y} && sleep 0.08 && input tap {x} {y}")
await _shell(serial, f"input tap {x} {y} && sleep 0.05 && input tap {x} {y}")
return {"message": f"Double tapped at {x}, {y}"}
if action == "long_press":

View File

@@ -95,12 +95,6 @@ SERVICE_SPECS: tuple[ServiceSpec, ...] = (
"browser",
"可选Docker 内嵌 Chromium 桌面,适合远程开 YouTube StudioENABLE_NEKO=1 时安装",
),
ServiceSpec(
"neko_firefox",
"Neko Firefox (第二桌面)",
"browser",
"可选:第二套 Neko 桌面Firefoxarm64/x86 多架构ENABLE_NEKO_FF=1口令与 Chromium 相同",
),
ServiceSpec(
"android_gateway",
"Android Gateway",
@@ -583,8 +577,6 @@ async def stack_stream_probes() -> dict[str, Any]:
srs_p = int(env.get("SRS_HTTP_PORT", "8080") or 8080)
srs_api_p = int(env.get("SRS_API_PORT", "1985") or 1985)
neko_p = int(env.get("NEKO_HTTP_PORT", "9200") or 9200)
neko_ff_on = env.get("ENABLE_NEKO_FF", "0") == "1"
neko_ff_p = int(env.get("NEKO_FF_HTTP_PORT", "9201") or 9201)
loop = asyncio.get_event_loop()
def run_srs() -> dict[str, Any]:
@@ -609,18 +601,6 @@ async def stack_stream_probes() -> dict[str, Any]:
http = _probe_http_get(f"http://127.0.0.1:{neko_p}/", timeout=2.5)
return {"port": neko_p, "tcp": tcp, "http": http}
def run_neko_ff() -> dict[str, Any]:
tcp = _probe_tcp_port("127.0.0.1", neko_ff_p)
http = _probe_http_get(f"http://127.0.0.1:{neko_ff_p}/", timeout=2.5)
return {"port": neko_ff_p, "tcp": tcp, "http": http}
if neko_ff_on:
srs_d, neko_d, neko_ff_d = await asyncio.gather(
loop.run_in_executor(None, run_srs),
loop.run_in_executor(None, run_neko),
loop.run_in_executor(None, run_neko_ff),
)
return {"srs": srs_d, "neko": neko_d, "neko_firefox": neko_ff_d}
srs_d, neko_d = await asyncio.gather(
loop.run_in_executor(None, run_srs),
loop.run_in_executor(None, run_neko),
@@ -642,8 +622,6 @@ def stack_summary() -> dict:
android_panel = env.get("ANDROID_PANEL_PORT", "5000")
chrome_dbg = env.get("BROWSER_REMOTE_DEBUG_PORT", "9222")
neko_port = env.get("NEKO_HTTP_PORT", "9200")
neko_ff_port = env.get("NEKO_FF_HTTP_PORT", "9201")
neko_ff_on = env.get("ENABLE_NEKO_FF", "0") == "1"
ql: dict[str, str] = {
"control_plane": f"http://{mdns_host}:{web_port}",
"webtty": f"http://{mdns_host}:{webtty}",
@@ -655,8 +633,6 @@ def stack_summary() -> dict:
"chromium_remote_debug": f"http://{mdns_host}:{chrome_dbg}",
"neko_browser": f"http://{mdns_host}:{neko_port}",
}
if neko_ff_on:
ql["neko_firefox_browser"] = f"http://{mdns_host}:{neko_ff_port}"
return {
"hostname": hostname,
"mdns_host": mdns_host,
@@ -679,7 +655,6 @@ def stack_summary() -> dict:
" WebRTC 需 NEKO_WEBRTC_NAT1TO1 为板子局域网 IP并放行 UDP 端口段。"
),
},
"neko_firefox_enabled": neko_ff_on,
"quick_links": ql,
}

59
src/hub_kv_store.py Normal file
View File

@@ -0,0 +1,59 @@
"""Hub 轻量状态SQLite多频道 Pro 等可随控制台落盘,浏览器仅作缓存。"""
from __future__ import annotations
import json
import sqlite3
import time
from pathlib import Path
from typing import Any
from src.control_plane import CONFIG_DIR
_DB_NAME = "hub_state.sqlite"
_KEY_YOUTUBE_PRO = "youtube_pro_channels_v1"
def _db_path() -> Path:
return CONFIG_DIR / _DB_NAME
def _connect() -> sqlite3.Connection:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(_db_path()), timeout=12)
conn.execute(
"CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT NOT NULL, ts REAL NOT NULL)"
)
conn.commit()
return conn
def get_youtube_pro_channels() -> list[dict[str, Any]]:
path = _db_path()
if not path.is_file():
return []
try:
with _connect() as c:
row = c.execute("SELECT v FROM kv WHERE k = ?", (_KEY_YOUTUBE_PRO,)).fetchone()
except sqlite3.Error:
return []
if not row:
return []
try:
data = json.loads(row[0])
except json.JSONDecodeError:
return []
return data if isinstance(data, list) else []
def set_youtube_pro_channels(channels: list[dict[str, Any]]) -> None:
raw = json.dumps(channels, ensure_ascii=False)
if len(raw) > 600_000:
raise ValueError("channels payload too large")
ts = time.time()
with _connect() as c:
c.execute(
"INSERT INTO kv(k, v, ts) VALUES(?,?,?) "
"ON CONFLICT(k) DO UPDATE SET v = excluded.v, ts = excluded.ts",
(_KEY_YOUTUBE_PRO, raw, ts),
)
c.commit()

View File

@@ -5,8 +5,8 @@ import json
from pathlib import Path
from typing import Any, Optional
from fastapi import APIRouter
from pydantic import BaseModel
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field, field_validator
from src.android_control import adb_available, list_android_devices
from src.control_plane import (
@@ -46,6 +46,24 @@ class BusinessDouyinPatch(BaseModel):
sync_youtube_ini: Optional[bool] = None
class YoutubeProChannelsBody(BaseModel):
channels: list[dict[str, Any]] = Field(default_factory=list)
@field_validator("channels")
@classmethod
def _validate_channels(cls, v: list[Any]) -> list[Any]:
if len(v) > 64:
raise ValueError("at most 64 channels")
for item in v:
if not isinstance(item, dict):
raise ValueError("each channel must be an object")
if not str(item.get("id", "")).strip():
raise ValueError("channel.id required")
if not str(item.get("name", "")).strip():
raise ValueError("channel.name required")
return v
@router.get("/dashboard")
async def hub_dashboard() -> dict[str, Any]:
snap = system_snapshot()
@@ -119,6 +137,24 @@ async def hub_config_read() -> dict[str, Any]:
return load_hub_config_snapshot()
@router.get("/youtube_pro_channels")
async def hub_youtube_pro_channels_read() -> dict[str, Any]:
from src.hub_kv_store import get_youtube_pro_channels
return {"channels": get_youtube_pro_channels()}
@router.post("/youtube_pro_channels")
async def hub_youtube_pro_channels_write(body: YoutubeProChannelsBody) -> dict[str, str]:
from src.hub_kv_store import set_youtube_pro_channels
try:
set_youtube_pro_channels(body.channels)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"message": "ok"}
@router.get("/services/declared")
async def hub_services_declared() -> dict[str, Any]:
return {"services": services_config_list()}