From ef87c43b33fb9538e276aa12c08c8ea2772cb829 Mon Sep 17 00:00:00 2001 From: eric Date: Sun, 29 Mar 2026 02:33:37 -0500 Subject: [PATCH] 's' --- .gitignore | 1 + config/system-stack.env.example | 7 - .../config/defaults/services/neko.json | 6 - scripts/linux/lib/common.sh | 12 - scripts/linux/service_ctl.sh | 70 -- services/neko/docker-compose.firefox.yml | 29 - src/android_control.py | 166 +++- src/control_plane.py | 25 - src/hub_kv_store.py | 59 ++ src/hub_routes.py | 40 +- tools/deploy_live_paramiko.py | 5 +- .../live-product/LiveAndroidStreamConsole.tsx | 789 ++++++++++++++++++ .../live-product/LiveAndroidWorkstation.tsx | 184 +--- .../live-product/LiveControlApp.tsx | 61 +- .../live-product/LiveYoutubeUnmannedView.tsx | 75 +- web-console/lib/youtubeProChannels.ts | 18 + web.py | 57 +- 17 files changed, 1197 insertions(+), 407 deletions(-) delete mode 100644 services/neko/docker-compose.firefox.yml create mode 100644 src/hub_kv_store.py create mode 100644 web-console/components/live-product/LiveAndroidStreamConsole.tsx diff --git a/.gitignore b/.gitignore index 1a4f59e..73b5482 100644 --- a/.gitignore +++ b/.gitignore @@ -184,3 +184,4 @@ config/runtime.env config/system-stack.env config/hardware.env config/hardware-profile.json +config/hub_state.sqlite diff --git a/config/system-stack.env.example b/config/system-stack.env.example index a51bca9..65287b1 100644 --- a/config/system-stack.env.example +++ b/config/system-stack.env.example @@ -63,13 +63,6 @@ NEKO_HTTP_PORT=9200 # Neko 浏览器登录:用户名为 live / admin,口令与全站策略一致(默认 12345678) NEKO_USER_PASS=12345678 NEKO_ADMIN_PASS=12345678 -# 第二套 Neko 桌面:Firefox(Google 无官方 ARM Chrome;此为多架构 Firefox)。与 Chromium 共用上面口令。 -ENABLE_NEKO_FF=1 -NEKO_FF_HTTP_PORT=9201 -NEKO_FF_WEBRTC_EPR=52200-52300 -NEKO_FF_WEBRTC_UDP_RANGE=52200-52300 -NEKO_FF_DESKTOP_SCREEN=1920x1080@30 -NEKO_FF_PROFILE_HOST_DIR= # 持久化 Chromium 配置(油猴、YouTube 登录、脚本);可从参考机 rsync 整个目录覆盖以迁移 NEKO_PROFILE_HOST_DIR= NEKO_SHM_SIZE=2gb diff --git a/live-platform/config/defaults/services/neko.json b/live-platform/config/defaults/services/neko.json index 2eba25c..5a9b153 100644 --- a/live-platform/config/defaults/services/neko.json +++ b/live-platform/config/defaults/services/neko.json @@ -9,12 +9,6 @@ "NEKO_HTTP_PORT": "9200", "NEKO_USER_PASS": "12345678", "NEKO_ADMIN_PASS": "12345678", - "ENABLE_NEKO_FF": "1", - "NEKO_FF_HTTP_PORT": "9201", - "NEKO_FF_WEBRTC_EPR": "52200-52300", - "NEKO_FF_WEBRTC_UDP_RANGE": "52200-52300", - "NEKO_FF_DESKTOP_SCREEN": "1920x1080@30", - "NEKO_FF_PROFILE_HOST_DIR": "", "NEKO_PROFILE_HOST_DIR": "", "NEKO_SHM_SIZE": "2gb", "NEKO_DESKTOP_SCREEN": "1920x1080@30", diff --git a/scripts/linux/lib/common.sh b/scripts/linux/lib/common.sh index 64237ec..ce29e39 100644 --- a/scripts/linux/lib/common.sh +++ b/scripts/linux/lib/common.sh @@ -511,18 +511,6 @@ install_neko_browser() { fi export NEKO_PLUGINS_ENABLED="${NEKO_PLUGINS_ENABLED:-true}" compose_in_service_dir "$neko_dir/docker-compose.yml" up -d - if [ "${ENABLE_NEKO_FF:-0}" = "1" ] && [ -f "$neko_dir/docker-compose.firefox.yml" ]; then - log "Installing Neko Firefox (second desktop, m1k1o/neko)" - local ff_prof="${NEKO_FF_PROFILE_HOST_DIR:-$neko_dir/data/firefox-profile}" - mkdir -p "$ff_prof" - chmod 777 "$ff_prof" 2>/dev/null || true - export NEKO_FF_HTTP_PORT="${NEKO_FF_HTTP_PORT:-9201}" - export NEKO_FF_DESKTOP_SCREEN="${NEKO_FF_DESKTOP_SCREEN:-1920x1080@30}" - export NEKO_FF_WEBRTC_EPR="${NEKO_FF_WEBRTC_EPR:-52200-52300}" - export NEKO_FF_WEBRTC_UDP_RANGE="${NEKO_FF_WEBRTC_UDP_RANGE:-52200-52300}" - export NEKO_FF_PROFILE_HOST_DIR="$ff_prof" - compose_in_service_dir "$neko_dir/docker-compose.firefox.yml" up -d - fi } configure_adb_udev() { diff --git a/scripts/linux/service_ctl.sh b/scripts/linux/service_ctl.sh index 2e5360a..08c0127 100644 --- a/scripts/linux/service_ctl.sh +++ b/scripts/linux/service_ctl.sh @@ -12,7 +12,6 @@ SRS_COMPOSE="$ROOT_DIR/services/srs/docker-compose.yml" HOMEPAGE_COMPOSE="$ROOT_DIR/services/homepage/docker-compose.yml" FILEBROWSER_COMPOSE="$ROOT_DIR/services/filebrowser/docker-compose.yml" NEKO_COMPOSE="$ROOT_DIR/services/neko/docker-compose.yml" -NEKO_FF_COMPOSE="$ROOT_DIR/services/neko/docker-compose.firefox.yml" BROWSER_LAUNCHER="$ROOT_DIR/scripts/linux/launch_browser.sh" [ -f "$STACK_ENV" ] && set -a && . "$STACK_ENV" && set +a @@ -555,75 +554,6 @@ service_neko_stop() { kv message "Neko stack stopped" } -service_neko_firefox_status() { - if [ "${ENABLE_NEKO_FF:-0}" != "1" ]; then - kv available 0 - kv running 0 - kv status unavailable - kv detail "第二桌面未启用:在 config/system-stack.env 设置 ENABLE_NEKO_FF=1(Firefox 与 Chromium 错开端口,均为 arm64 多架构镜像)" - return 0 - fi - if [ ! -f "$NEKO_FF_COMPOSE" ]; then - kv available 0 - kv running 0 - kv status unavailable - kv detail "docker-compose.firefox.yml missing" - return 0 - fi - if ! have docker; then - kv available 0 - kv running 0 - kv status unavailable - kv detail "docker not installed" - return 0 - fi - local state - state="$(docker_container_status live-neko-firefox || true)" - kv available 1 - kv running "$([[ "$state" == running* ]] && echo 1 || echo 0)" - kv status "$([[ "$state" == running* ]] && echo online || echo stopped)" - kv host "${HOSTNAME_ALIAS}.local" - kv port "${NEKO_FF_HTTP_PORT:-9201}" - kv url "http://${HOSTNAME_ALIAS}.local:${NEKO_FF_HTTP_PORT:-9201}" - kv detail "Neko Firefox(第二桌面)可选:Firefox arm64/x86 多架构;ENABLE_NEKO_FF=1。登录策略与 Chromium 相同:昵称任意,密码须为 NEKO_USER_PASS/NEKO_ADMIN_PASS(勿用 live/live)。" -} - -service_neko_firefox_start() { - if [ "${ENABLE_NEKO_FF:-0}" != "1" ]; then - kv message "请先在 config/system-stack.env 设置 ENABLE_NEKO_FF=1" - exit 1 - fi - if [ ! -f "$NEKO_FF_COMPOSE" ]; then - kv message "Neko Firefox compose missing" - exit 1 - fi - local prof="${NEKO_FF_PROFILE_HOST_DIR:-$ROOT_DIR/services/neko/data/firefox-profile}" - mkdir -p "$prof" - chmod 777 "$prof" 2>/dev/null || true - export NEKO_FF_HTTP_PORT="${NEKO_FF_HTTP_PORT:-9201}" - export NEKO_FF_DESKTOP_SCREEN="${NEKO_FF_DESKTOP_SCREEN:-1920x1080@30}" - export NEKO_FF_WEBRTC_EPR="${NEKO_FF_WEBRTC_EPR:-52200-52300}" - export NEKO_FF_WEBRTC_UDP_RANGE="${NEKO_FF_WEBRTC_UDP_RANGE:-52200-52300}" - export NEKO_USER_PASS="${NEKO_USER_PASS:-12345678}" - export NEKO_ADMIN_PASS="${NEKO_ADMIN_PASS:-12345678}" - export NEKO_FF_PROFILE_HOST_DIR="$prof" - export NEKO_WEBRTC_ICELITE="${NEKO_WEBRTC_ICELITE:-1}" - if [ -z "${NEKO_WEBRTC_NAT1TO1:-}" ]; then - export NEKO_WEBRTC_NAT1TO1="$(hostname -I 2>/dev/null | awk '{print $1}')" - fi - compose_in_service_dir "$NEKO_FF_COMPOSE" up -d - kv message "Neko Firefox started" -} - -service_neko_firefox_stop() { - if [ ! -f "$NEKO_FF_COMPOSE" ]; then - kv message "Neko Firefox compose missing" - return 0 - fi - compose_in_service_dir "$NEKO_FF_COMPOSE" down - kv message "Neko Firefox stopped" -} - service_android_gateway_status() { if ! have nmcli; then kv available 0 diff --git a/services/neko/docker-compose.firefox.yml b/services/neko/docker-compose.firefox.yml deleted file mode 100644 index f417f9e..0000000 --- a/services/neko/docker-compose.firefox.yml +++ /dev/null @@ -1,29 +0,0 @@ -# 第二套 Neko 桌面(Firefox,多架构含 arm64)。与 Chromium 实例错开端口与 UDP 段。 -# 启用:ENABLE_NEKO_FF=1 后 service_ctl neko_firefox start -version: "3.3" -services: - neko-firefox: - image: ghcr.io/m1k1o/neko/firefox:latest - container_name: live-neko-firefox - restart: unless-stopped - shm_size: "1gb" - cap_add: - - SYS_ADMIN - environment: - DISPLAY: ":99.0" - NEKO_DESKTOP_SCREEN: "${NEKO_FF_DESKTOP_SCREEN}" - NEKO_SERVER_BIND: ":8080" - NEKO_PASSWORD: "${NEKO_USER_PASS}" - NEKO_PASSWORD_ADMIN: "${NEKO_ADMIN_PASS}" - NEKO_MEMBER_PROVIDER: "multiuser" - NEKO_MEMBER_MULTIUSER_USER_PASSWORD: "${NEKO_USER_PASS}" - NEKO_MEMBER_MULTIUSER_ADMIN_PASSWORD: "${NEKO_ADMIN_PASS}" - NEKO_WEBRTC_EPR: "${NEKO_FF_WEBRTC_EPR}" - NEKO_WEBRTC_ICELITE: "${NEKO_WEBRTC_ICELITE}" - NEKO_WEBRTC_NAT1TO1: "${NEKO_WEBRTC_NAT1TO1}" - NEKO_PLUGINS_ENABLED: "false" - ports: - - "${NEKO_FF_HTTP_PORT}:8080" - - "${NEKO_FF_WEBRTC_UDP_RANGE}:52000-52100/udp" - volumes: - - "${NEKO_FF_PROFILE_HOST_DIR}:/home/neko/.mozilla" diff --git a/src/android_control.py b/src/android_control.py index da6e3b2..bed3b12 100644 --- a/src/android_control.py +++ b/src/android_control.py @@ -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": diff --git a/src/control_plane.py b/src/control_plane.py index 6f291b9..131ea83 100644 --- a/src/control_plane.py +++ b/src/control_plane.py @@ -95,12 +95,6 @@ SERVICE_SPECS: tuple[ServiceSpec, ...] = ( "browser", "可选:Docker 内嵌 Chromium 桌面,适合远程开 YouTube Studio;ENABLE_NEKO=1 时安装", ), - ServiceSpec( - "neko_firefox", - "Neko Firefox (第二桌面)", - "browser", - "可选:第二套 Neko 桌面(Firefox,arm64/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, } diff --git a/src/hub_kv_store.py b/src/hub_kv_store.py new file mode 100644 index 0000000..d6ef5ad --- /dev/null +++ b/src/hub_kv_store.py @@ -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() diff --git a/src/hub_routes.py b/src/hub_routes.py index 74c0aef..021e1e5 100644 --- a/src/hub_routes.py +++ b/src/hub_routes.py @@ -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()} diff --git a/tools/deploy_live_paramiko.py b/tools/deploy_live_paramiko.py index 7302afe..1654494 100644 --- a/tools/deploy_live_paramiko.py +++ b/tools/deploy_live_paramiko.py @@ -49,6 +49,7 @@ FILES = [ "src/control_plane.py", "src/web_process_backend.py", "src/hub_routes.py", + "src/hub_kv_store.py", "src/initializer.py", "src/live_config/__init__.py", "src/live_config/loader.py", @@ -72,7 +73,6 @@ FILES = [ "services/srs/docker-compose.yml", "services/srs/data/index.html", "services/neko/docker-compose.yml", - "services/neko/docker-compose.firefox.yml", "services/neko/chromium.conf", "services/neko/policies/policies.json", "services/neko/.gitignore", @@ -87,6 +87,7 @@ FILES = [ "web-console/components/OverviewCharts.tsx", "web-console/components/live-product/LiveControlApp.tsx", "web-console/components/live-product/LiveAndroidWorkstation.tsx", + "web-console/components/live-product/LiveAndroidStreamConsole.tsx", "web-console/components/live-product/LiveYoutubeUnmannedView.tsx", "web-console/components/live-product/LiveProcessLiveLogCard.tsx", "web-console/components/live-product/LiveTiktokHdmiView.tsx", @@ -274,7 +275,7 @@ def main() -> int: "backup/__init__.py backup/msg_push.py backup/robust_relay.py " "src/utils.py src/logger.py src/spider.py src/stream.py src/proxy.py " "src/http_clients/async_http.py src/http_clients/sync_http.py " - "src/control_plane.py src/web_process_backend.py src/hub_routes.py " + "src/control_plane.py src/web_process_backend.py src/hub_routes.py src/hub_kv_store.py " "src/async_thread_loop.py src/youtube_ini_sync.py " "src/live_config/__init__.py src/live_config/loader.py " "scripts/hardware_probe.py douyin_youtube_ffplay.py main.py" diff --git a/web-console/components/live-product/LiveAndroidStreamConsole.tsx b/web-console/components/live-product/LiveAndroidStreamConsole.tsx new file mode 100644 index 0000000..cb3d609 --- /dev/null +++ b/web-console/components/live-product/LiveAndroidStreamConsole.tsx @@ -0,0 +1,789 @@ +"use client"; + +import { + Cable, + ExternalLink, + LayoutGrid, + Link2, + MousePointer2, + Pause, + Play, + RefreshCw, + Smartphone, + Sparkles, + Usb, + Wifi, +} from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +type AndroidOverview = { + model?: string; + brand?: string; + android_version?: string; + sdk?: string; + battery?: string; + resolution?: string; +}; + +export type DeviceRow = { + serial: string; + model?: string; + state: string; + transport?: string; +}; +type TFn = (zh: string, en: string) => string; + +const BOOKMARKS_KEY = "live-hub-adb-bookmarks-v1"; + +function clientPointToImagePixel( + img: HTMLImageElement, + clientX: number, + clientY: number, +): { x: number; y: number } | null { + const rect = img.getBoundingClientRect(); + const nw = img.naturalWidth; + const nh = img.naturalHeight; + if (!nw || !nh) return null; + const elAR = rect.width / rect.height; + const imAR = nw / nh; + let drawW: number; + let drawH: number; + let offX: number; + let offY: number; + if (imAR > elAR) { + drawW = rect.width; + drawH = rect.width / imAR; + offX = 0; + offY = (rect.height - drawH) / 2; + } else { + drawH = rect.height; + drawW = rect.height * imAR; + offX = (rect.width - drawW) / 2; + offY = 0; + } + const lx = clientX - rect.left - offX; + const ly = clientY - rect.top - offY; + if (lx < 0 || ly < 0 || lx > drawW || ly > drawH) return null; + return { + x: Math.max(0, Math.min(nw - 1, Math.round((lx / drawW) * nw))), + y: Math.max(0, Math.min(nh - 1, Math.round((ly / drawH) * nh))), + }; +} + +const INTERVALS_MS = [0, 280, 450, 700, 1100] as const; + +function transportBadge(tr: string | undefined) { + switch (tr) { + case "usb": + return { Icon: Usb, zh: "USB", en: "USB", cls: "bg-amber-500/20 text-amber-100 ring-amber-500/35" }; + case "tcp": + return { Icon: Wifi, zh: "网络", en: "TCP", cls: "bg-cyan-500/20 text-cyan-100 ring-cyan-500/35" }; + case "emulator": + return { Icon: Cable, zh: "模拟器", en: "Emu", cls: "bg-fuchsia-500/20 text-fuchsia-100 ring-fuchsia-500/35" }; + default: + return { Icon: Smartphone, zh: "?", en: "?", cls: "bg-zinc-600/30 text-zinc-400" }; + } +} + +type Props = { + t: TFn; + apiBase: string; + fetchJson: (path: string, init?: RequestInit) => Promise; + serial: string; + devices: DeviceRow[]; + setSerial: (s: string) => void; + adbAvailable: boolean; + lock: boolean; + overview: AndroidOverview | null; + onAndroidAction: (action: string, payload?: Record) => Promise; + notify: (msg: string) => void; + onRefreshDevices: () => void; +}; + +const SCRCPY_URL = "https://github.com/Genymobile/scrcpy"; + +export function LiveAndroidStreamConsole({ + t, + apiBase, + fetchJson, + serial, + devices, + setSerial, + adbAvailable, + lock, + overview, + onAndroidAction, + notify, + onRefreshDevices, +}: Props) { + const [frameTs, setFrameTs] = useState(0); + const [liveMs, setLiveMs] = useState<(typeof INTERVALS_MS)[number]>(450); + const [imgBusy, setImgBusy] = useState(false); + const [imgErr, setImgErr] = useState(null); + const [metrics, setMetrics] = useState<{ width: number; height: number } | null>(null); + const [tapMode, setTapMode] = useState(true); + const [connectAddr, setConnectAddr] = useState(""); + const [pairHost, setPairHost] = useState(""); + const [pairPort, setPairPort] = useState("37777"); + const [pairCode, setPairCode] = useState(""); + const [connBusy, setConnBusy] = useState(false); + const [bookmarks, setBookmarks] = useState([]); + const lastTapRef = useRef<{ t: number; x: number; y: number } | null>(null); + const imgRef = useRef(null); + const mirrorFocusRef = useRef(null); + const shotFailRef = useRef(0); + + const activeTransport = devices.find((d) => d.serial === serial)?.transport; + const useFastShot = liveMs > 0; + + const shotUrl = + apiBase && serial + ? `${apiBase}/android/screenshot?serial=${encodeURIComponent(serial)}&ts=${frameTs}${useFastShot ? "&fast=1" : ""}` + : ""; + + const bumpFrame = useCallback(() => { + setFrameTs((n) => n + 1); + }, []); + + useEffect(() => { + try { + const raw = window.localStorage.getItem(BOOKMARKS_KEY); + if (raw) { + const v = JSON.parse(raw) as unknown; + if (Array.isArray(v)) setBookmarks(v.map(String).filter(Boolean).slice(0, 24)); + } + } catch { + /* ignore */ + } + }, []); + + const saveBookmark = (addr: string) => { + const a = addr.trim(); + if (!a) return; + const next = [a, ...bookmarks.filter((x) => x !== a)].slice(0, 16); + setBookmarks(next); + try { + window.localStorage.setItem(BOOKMARKS_KEY, JSON.stringify(next)); + } catch { + /* ignore */ + } + notify(t("已保存快捷地址", "Saved quick address")); + }; + + useEffect(() => { + if (!serial || !adbAvailable) { + setMetrics(null); + return; + } + let cancelled = false; + void (async () => { + try { + const res = await fetch(`${apiBase}/android/display_metrics?serial=${encodeURIComponent(serial)}`); + const data = (await res.json()) as { width?: number; height?: number }; + if (!res.ok || cancelled) return; + if (typeof data.width === "number" && typeof data.height === "number") { + setMetrics({ width: data.width, height: data.height }); + } + } catch { + /* ignore */ + } + })(); + return () => { + cancelled = true; + }; + }, [apiBase, adbAvailable, serial]); + + useEffect(() => { + if (!adbAvailable || !serial || liveMs <= 0) return; + const id = window.setInterval(() => setFrameTs((n) => n + 1), liveMs); + return () => clearInterval(id); + }, [adbAvailable, liveMs, serial]); + + useEffect(() => { + if (serial && adbAvailable) { + shotFailRef.current = 0; + setFrameTs((n) => n + 1); + } + }, [serial, adbAvailable]); + + const runConnect = async () => { + const addr = connectAddr.trim(); + if (!addr) { + notify(t("填写 host:port", "Enter host:port")); + return; + } + setConnBusy(true); + try { + const r = await fetchJson<{ ok?: boolean; message?: string }>("/android/connect", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ address: addr }), + }); + notify(r.message || (r.ok ? "OK" : t("连接结果见日志", "See message"))); + onRefreshDevices(); + bumpFrame(); + } catch (e) { + notify((e as Error).message); + } finally { + setConnBusy(false); + } + }; + + const runDisconnect = async (addr: string) => { + setConnBusy(true); + try { + const r = await fetchJson<{ ok?: boolean; message?: string }>("/android/disconnect", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ address: addr }), + }); + notify(r.message || "OK"); + onRefreshDevices(); + } catch (e) { + notify((e as Error).message); + } finally { + setConnBusy(false); + } + }; + + const runPair = async () => { + const h = pairHost.trim(); + const p = parseInt(pairPort, 10) || 37777; + const c = pairCode.trim(); + if (!h || !c) { + notify(t("填写配对 IP 与码", "Host + code required")); + return; + } + setConnBusy(true); + try { + const r = await fetchJson<{ ok?: boolean; message?: string }>("/android/pair", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ host: h, port: p, code: c }), + }); + notify(r.message || (r.ok ? t("配对成功后再点连接", "Paired — now Connect") : "pair")); + } catch (e) { + notify((e as Error).message); + } finally { + setConnBusy(false); + } + }; + + const onImageClick = async (e: React.MouseEvent) => { + if (!tapMode || lock || !adbAvailable || !serial) return; + const img = imgRef.current; + if (!img) return; + const p = clientPointToImagePixel(img, e.clientX, e.clientY); + if (!p) return; + + const now = Date.now(); + const prev = lastTapRef.current; + if (prev && now - prev.t < 320 && Math.abs(prev.x - p.x) < 28 && Math.abs(prev.y - p.y) < 28) { + lastTapRef.current = null; + await onAndroidAction("double_tap", { x: p.x, y: p.y }); + notify(t("双击", "Double tap")); + } else { + lastTapRef.current = { t: now, x: p.x, y: p.y }; + await onAndroidAction("tap", { x: p.x, y: p.y }); + } + bumpFrame(); + }; + + const onImageContextMenu = async (e: React.MouseEvent) => { + e.preventDefault(); + if (lock || !adbAvailable || !serial) return; + const img = imgRef.current; + if (!img) return; + const p = clientPointToImagePixel(img, e.clientX, e.clientY); + if (!p) return; + await onAndroidAction("long_press", { x: p.x, y: p.y, duration: 720 }); + notify(t("长按", "Long press")); + bumpFrame(); + }; + + const onMirrorKeyDown = (e: React.KeyboardEvent) => { + if (!serial || lock) return; + const tag = (e.target as HTMLElement).tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return; + const k = e.key.toLowerCase(); + const map: Record?]> = { + h: ["key", { keycode: "3" }], + b: ["key", { keycode: "4" }], + r: ["key", { keycode: "187" }], + w: ["wake", undefined], + m: ["key", { keycode: "82" }], + }; + const hit = map[k]; + if (!hit) return; + e.preventDefault(); + void (async () => { + await onAndroidAction(hit[0], hit[1]); + bumpFrame(); + })(); + }; + + const onImgError = () => { + setImgBusy(false); + shotFailRef.current += 1; + if (shotFailRef.current <= 3) { + setImgErr(null); + window.setTimeout(() => bumpFrame(), 380 + shotFailRef.current * 200); + } else { + setImgErr(t("截图连续失败:检查 USB 授权 / 无线是否断开 / Redroid 端口", "Screenshot failed repeatedly")); + } + }; + + const onImgLoad = () => { + setImgBusy(false); + shotFailRef.current = 0; + setImgErr(null); + }; + + if (!adbAvailable) { + return ( +
+ {t("宿主机未安装 adb,无法使用 Live 控制台。", "adb is required for the Live console.")} +
+ ); + } + + const tb = transportBadge(activeTransport); + const HintIcon = tb.Icon; + + return ( +
+
+
+ +

{t("链路管理", "Link manager")}

+ + {t("USB / Redroid :5555 / 无线调试(先配对再连接)", "USB · Redroid :5555 · wireless (pair then connect)")} + +
+
+
+

adb connect

+
+ setConnectAddr(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && void runConnect()} + /> + + +
+ {bookmarks.length ? ( +
+ {bookmarks.map((b) => ( + + ))} +
+ ) : null} +
+ + + +
+
+
+

adb pair(无线调试)

+
+ setPairHost(e.target.value)} + /> + setPairPort(e.target.value)} + /> + setPairCode(e.target.value)} + /> + +
+

+ {t( + "手机上「无线调试」会显示配对端口与 6 位码;配对成功后用上方 connect 连接 adb 显示的 IP:5555。", + "Use the pairing port + 6-digit code from Wireless debugging; then connect to the listed adb IP:5555.", + )} +

+
+
+
+ +
+
+
+
+
+
+ +
+
+

+ {t("Live Hub · ADB 云控台", "Live Hub · ADB farm console")} +

+

+ {t( + "统一适配 USB 板子、Redroid 容器与无线 adb:截图轮询 + 输入注入。极致低延迟请用桌面", + "USB boards, Redroid, wireless adb: polled screencap + input. For lowest latency use desktop ", + )} + + scrcpy + + {t("。", ".")} +

+
+
+ + scrcpy + + +
+ + {devices.length > 0 ? ( +
+

+ + {t("设备矩阵", "Device matrix")} +

+
+ {devices.map((d) => { + const on = d.state === "device"; + const active = serial === d.serial; + const badge = transportBadge(d.transport); + const BIcon = badge.Icon; + return ( + + ); + })} +
+
+ ) : null} + +
+
+
+ + {t("刷新间隔", "Interval")} + + {INTERVALS_MS.map((ms) => ( + + ))} + + + {activeTransport === "tcp" + ? t("建议 ≥450ms", "Suggest ≥450ms") + : t("可试 280ms", "Try 280ms")} + + + + +
+

+ {t( + "快捷键(镜像区聚焦时):H Home · B Back · R 多任务 · W 唤醒 · M 菜单", + "Hotkeys (mirror focused): H Home · B Back · R Recents · W Wake · M Menu", + )} +

+ +
+ {!serial ? ( +

{t("请选择或连接设备", "Pick or connect a device")}

+ ) : ( +
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + device { + setImgBusy(true); + }} + onLoad={onImgLoad} + onError={onImgError} + onClick={onImageClick} + onContextMenu={onImageContextMenu} + /> +
+ {liveMs > 0 ? ( +
+ + LIVE +
+ ) : ( +
+ + {t("单帧", "Still")} +
+ )} +
+ )} + {imgErr ?

{imgErr}

: null} +

+ {t("左键单击 / 双击 · 右键长按 · 坐标对齐 PNG 像素", "Left tap/double · right long-press · PNG pixel coords")} +

+
+
+ +
+
+

+ {t("遥测", "Telemetry")} +

+ {overview ? ( +
+
+
device
+
+ {overview.brand} {overview.model} +
+
+
+
Android
+
+ {overview.android_version} (API {overview.sdk}) +
+
+
+
wm size
+
+ {metrics ? `${metrics.width}×${metrics.height}` : overview.resolution || "—"} +
+
+
{overview.battery}
+
+ ) : ( +

{t("在上方点「刷新设备信息」", "Use Refresh device info above")}

+ )} +
+ +
+

+ {t("导航坞", "Nav dock")} +

+
+ {( + [ + ["3", "Home"], + ["4", "Back"], + ["187", "Recents"], + ] as const + ).map(([code, label]) => ( + + ))} +
+
+ + +
+
+ +
+

+ {t("手势", "Gestures")} +

+
+ {(["up", "down", "left", "right"] as const).map((dir) => ( + + ))} +
+
+
+
+
+
+
+ ); +} diff --git a/web-console/components/live-product/LiveAndroidWorkstation.tsx b/web-console/components/live-product/LiveAndroidWorkstation.tsx index c213e6a..dbe9390 100644 --- a/web-console/components/live-product/LiveAndroidWorkstation.tsx +++ b/web-console/components/live-product/LiveAndroidWorkstation.tsx @@ -1,9 +1,11 @@ "use client"; -import { Copy, ExternalLink, Layers, Loader2, Package, RefreshCw, Terminal } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { Copy, Layers, Loader2, Package, Terminal } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; -type DeviceRow = { serial: string; model?: string; state: string }; +import { LiveAndroidStreamConsole } from "@/components/live-product/LiveAndroidStreamConsole"; + +type DeviceRow = { serial: string; model?: string; state: string; transport?: string }; type TFn = (zh: string, en: string) => string; type AndroidOverview = { @@ -42,33 +44,35 @@ type Props = { t: TFn; notify: (msg: string) => void; fetchJson: (path: string, init?: RequestInit) => Promise; + apiBase: string; /** Parent busy (e.g. adb actions); combined with local pending for UI lock. */ busy: string | null; androidSerial: string; setAndroidSerial: (s: string) => void; devices: DeviceRow[]; adbAvailable: boolean; - scrcpyUrl: string; apkPath: string; setApkPath: (v: string) => void; onAndroidAction: (action: string, payload?: Record) => Promise; onCopy: (text: string) => void; + onRefreshDevices: () => void; }; export function LiveAndroidWorkstation({ t, notify, fetchJson, + apiBase, busy, androidSerial, setAndroidSerial, devices, adbAvailable, - scrcpyUrl, apkPath, setApkPath, onAndroidAction, onCopy, + onRefreshDevices, }: Props) { const [overview, setOverview] = useState(null); const [overviewErr, setOverviewErr] = useState(null); @@ -83,30 +87,11 @@ export function LiveAndroidWorkstation({ const [uiErr, setUiErr] = useState(null); const [openUrl, setOpenUrl] = useState("https://"); const [adbInputText, setAdbInputText] = useState(""); - const [mirrorMode, setMirrorMode] = useState<"host" | "webusb">("host"); - const [mirrorReloadKey, setMirrorReloadKey] = useState(0); const [logcatLive, setLogcatLive] = useState(false); const [pending, setPending] = useState(null); - const PANDA_WEB_SCRCPY = "https://pandatestgrid.github.io/panda-web-scrcpy/"; - const LINK_SCRCPY = "https://github.com/Genymobile/scrcpy"; - const LINK_WS_SCRCPY = "https://github.com/NetrisTV/ws-scrcpy"; - const LINK_PANDA_GH = "https://github.com/PandaTestGrid/panda-web-scrcpy"; - const lock = !!(busy || pending); - const hostMirrorSrc = useMemo(() => { - if (!scrcpyUrl) return ""; - try { - const u = new URL(scrcpyUrl); - u.searchParams.set("_lh", String(mirrorReloadKey)); - return u.toString(); - } catch { - const sep = scrcpyUrl.includes("?") ? "&" : "?"; - return `${scrcpyUrl}${sep}_lh=${mirrorReloadKey}`; - } - }, [scrcpyUrl, mirrorReloadKey]); - const loadOverview = useCallback(async () => { if (!androidSerial || !adbAvailable) { setOverview(null); @@ -281,7 +266,14 @@ export function LiveAndroidWorkstation({ : "border-white/10 bg-black/30 text-zinc-400" }`} > -
{d.model || d.serial}
+
+ {d.model || d.serial} + {d.transport ? ( + + {d.transport} + + ) : null} +
{d.serial}
))} @@ -296,134 +288,20 @@ export function LiveAndroidWorkstation({ ) : null}
-
-
- - - -
- {mirrorMode === "host" ? ( -
-
-

- {t( - "板载 ws-scrcpy 同时只允许一个浏览器会话:多标签/总览与安卓页各嵌一个 iframe 会互相抢连接。请只保留一个投屏页,或反复点「重载投屏」。已打补丁的安装会踢掉旧连接以便嵌入。黑屏请先 Wake(会连发唤醒+滑动+Home)。", - "Only one ws-scrcpy browser session: multiple embedded iframes fight for the stream—keep one tab or use Reload. Patched installs replace the old session. Black screen: try Wake (wake+swipe+home), then Reload or New tab.", - )} -

-
- {adbAvailable && androidSerial ? ( - - ) : null} - {scrcpyUrl ? ( - <> - - - {t("新窗口打开", "New tab")} - - - ) : null} -
-
- {scrcpyUrl ? ( -