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()}
+ />
+ void runConnect()}
+ className="rounded-lg bg-sky-500/25 px-3 py-2 text-xs font-semibold text-sky-100 ring-1 ring-sky-400/40"
+ >
+ {connBusy ? "…" : "Connect"}
+
+ saveBookmark(connectAddr)}
+ className="rounded-lg border border-white/10 px-3 py-2 text-xs text-zinc-400 hover:text-zinc-200"
+ >
+ {t("存书签", "Pin")}
+
+
+ {bookmarks.length ? (
+
+ {bookmarks.map((b) => (
+ setConnectAddr(b)}
+ >
+ {b}
+
+ ))}
+
+ ) : null}
+
+ void runDisconnect(serial)}
+ className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-2.5 py-1.5 text-[10px] text-rose-200"
+ >
+ {t("断开当前 TCP", "Disconnect current")}
+
+ void runDisconnect("")}
+ className="rounded-lg border border-white/10 px-2.5 py-1.5 text-[10px] text-zinc-500 hover:text-zinc-300"
+ >
+ {t("断开全部 TCP", "Disconnect all TCP")}
+
+ {
+ onRefreshDevices();
+ bumpFrame();
+ notify(t("已刷新设备列表", "Devices refreshed"));
+ }}
+ className="inline-flex items-center gap-1 rounded-lg border border-white/10 px-2.5 py-1.5 text-[10px] text-zinc-400"
+ >
+
+ ADB list
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {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 (
+
setSerial(d.serial)}
+ className={`flex min-w-[9rem] flex-col rounded-xl border px-3 py-2 text-left transition ${
+ active
+ ? "border-emerald-500/50 bg-emerald-500/15 ring-1 ring-emerald-400/30"
+ : "border-white/10 bg-black/30 hover:border-white/20"
+ }`}
+ >
+
+
+ {d.model || d.serial.slice(0, 10)}
+
+ {d.serial}
+
+
+
+ {t(badge.zh, badge.en)}
+
+
+ {d.state}
+
+
+
+ );
+ })}
+
+
+ ) : null}
+
+
+
+
+
+ {t("刷新间隔", "Interval")}
+
+ {INTERVALS_MS.map((ms) => (
+ setLiveMs(ms)}
+ className={`rounded-lg px-2.5 py-1 text-[10px] font-semibold transition ${
+ liveMs === ms
+ ? "bg-violet-500/25 text-violet-100 ring-1 ring-violet-400/35"
+ : "bg-black/30 text-zinc-500 hover:text-zinc-300"
+ }`}
+ >
+ {ms === 0 ? t("手动", "Manual") : `${ms}ms`}
+
+ ))}
+
+
+ {activeTransport === "tcp"
+ ? t("建议 ≥450ms", "Suggest ≥450ms")
+ : t("可试 280ms", "Try 280ms")}
+
+ void bumpFrame()}
+ className="ml-auto inline-flex items-center gap-1 rounded-lg border border-white/10 bg-white/[0.05] px-2.5 py-1 text-[10px] text-zinc-300 hover:bg-white/[0.08]"
+ >
+
+ {t("下一帧", "Next frame")}
+
+ void onAndroidAction("wake")}
+ className="rounded-lg bg-amber-500/20 px-2.5 py-1 text-[10px] font-semibold text-amber-100 ring-1 ring-amber-500/30"
+ >
+ Wake
+
+
+ setTapMode(e.target.checked)}
+ />
+
+ {t("点按穿透", "Tap-through")}
+
+
+
+ {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 */}
+
{
+ setImgBusy(true);
+ }}
+ onLoad={onImgLoad}
+ onError={onImgError}
+ onClick={onImageClick}
+ onContextMenu={onImageContextMenu}
+ />
+
+ {liveMs > 0 ? (
+
+ ) : (
+
+ )}
+
+ )}
+ {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]) => (
+ {
+ void onAndroidAction("key", { keycode: code });
+ bumpFrame();
+ }}
+ className="rounded-xl border border-white/10 bg-white/[0.06] py-3 text-xs font-semibold text-zinc-200 hover:bg-white/[0.1]"
+ >
+ {label}
+
+ ))}
+
+
+ {
+ void onAndroidAction("key", { keycode: "24" });
+ bumpFrame();
+ }}
+ className="rounded-xl border border-white/10 bg-black/30 py-2 text-[11px] text-zinc-300"
+ >
+ Vol+
+
+ {
+ void onAndroidAction("key", { keycode: "25" });
+ bumpFrame();
+ }}
+ className="rounded-xl border border-white/10 bg-black/30 py-2 text-[11px] text-zinc-300"
+ >
+ Vol−
+
+
+
+
+
+
+ {t("手势", "Gestures")}
+
+
+ {(["up", "down", "left", "right"] as const).map((dir) => (
+ {
+ void onAndroidAction("swipe", { direction: dir });
+ bumpFrame();
+ }}
+ className="rounded-lg border border-white/10 bg-black/30 py-2 font-mono text-[10px] uppercase text-zinc-300"
+ >
+ {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}
-
-
-
setMirrorMode("host")}
- className={`rounded-lg px-3 py-2 text-[11px] font-semibold transition ${
- mirrorMode === "host"
- ? "bg-violet-500/25 text-violet-100 ring-1 ring-violet-400/40"
- : "text-zinc-500 hover:bg-white/5 hover:text-zinc-300"
- }`}
- >
- {t("宿主投屏 (板子 ws-scrcpy)", "Host ws-scrcpy")}
-
-
setMirrorMode("webusb")}
- className={`rounded-lg px-3 py-2 text-[11px] font-semibold transition ${
- mirrorMode === "webusb"
- ? "bg-cyan-500/20 text-cyan-100 ring-1 ring-cyan-400/35"
- : "text-zinc-500 hover:bg-white/5 hover:text-zinc-300"
- }`}
- >
- WebUSB · Panda
-
-
-
- {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 ? (
-
void onAndroidAction("wake")}
- className="rounded-lg bg-amber-500/20 px-3 py-1.5 text-[11px] text-amber-100 ring-1 ring-amber-500/30"
- >
- Wake
-
- ) : null}
- {scrcpyUrl ? (
- <>
-
setMirrorReloadKey((k) => k + 1)}
- className="inline-flex items-center gap-1 rounded-lg border border-white/15 bg-black/30 px-3 py-1.5 text-[11px] text-zinc-200"
- >
-
- {t("重载投屏", "Reload mirror")}
-
-
- {t("新窗口打开", "New tab")}
-
- >
- ) : null}
-
-
- {scrcpyUrl ? (
-
- ) : (
-
{t("未配置 ws-scrcpy 地址", "ws-scrcpy URL missing")}
- )}
-
- ) : (
-
-
- {t(
- "Panda / WebUSB:手机必须插在您正在打开本页的电脑 USB 上(与板载 ADB 无关)。与官方 scrcpy 协议生态同源,适合本地调试。仓库:",
- "Panda WebUSB: phone plugs into the PC running this browser. Same scrcpy-protocol family. Repo: ",
- )}
-
- PandaTestGrid/panda-web-scrcpy
-
-
-
-
- )}
-
+
{adbAvailable && androidSerial ? (
diff --git a/web-console/components/live-product/LiveControlApp.tsx b/web-console/components/live-product/LiveControlApp.tsx
index 5886a0a..b6e2e7e 100644
--- a/web-console/components/live-product/LiveControlApp.tsx
+++ b/web-console/components/live-product/LiveControlApp.tsx
@@ -81,7 +81,6 @@ type HubDashboard = {
admin_password?: string;
note_zh?: string;
};
- neko_firefox_enabled?: boolean;
};
services?: Array<{
service_id: string;
@@ -93,7 +92,10 @@ type HubDashboard = {
detail?: string;
url?: string;
}>;
- android?: { adb_available: boolean; devices: Array<{ serial: string; model?: string; state: string }> };
+ android?: {
+ adb_available: boolean;
+ devices: Array<{ serial: string; model?: string; state: string; transport?: string }>;
+ };
live?: {
backend_mode?: string;
processes?: Array<{
@@ -131,11 +133,6 @@ type HubDashboard = {
tcp?: { ok?: boolean; reachable?: boolean; latency_ms?: number; error?: string };
http?: { ok?: boolean; http_code?: number; reachable?: boolean; latency_ms?: number; error?: string };
};
- neko_firefox?: {
- port?: number;
- tcp?: { ok?: boolean; reachable?: boolean; latency_ms?: number; error?: string };
- http?: { ok?: boolean; http_code?: number; reachable?: boolean; latency_ms?: number; error?: string };
- };
};
};
@@ -314,11 +311,6 @@ export default function LiveControlApp() {
return o;
}, [dash?.stack?.quick_links]);
- const scrcpyUrl = useMemo(
- () => (ql.ws_scrcpy ? normalizeBrowserReachableHttpUrl(ql.ws_scrcpy) : ""),
- [ql.ws_scrcpy],
- );
-
const runService = async (service: string, action: "start" | "stop" | "restart") => {
setBusy(`svc:${service}`);
try {
@@ -827,7 +819,7 @@ export default function LiveControlApp() {
Neko {t("登录", "sign-in")}
{dash.stack.neko_login.note_zh ||
- t("Chromium :9200 与 Firefox :9201(若启用)共用下列账号策略。", "Chromium :9200 and Firefox :9201 share the same policy.")}
+ t("Neko Chromium :9200 使用下列账号策略。", "Neko Chromium :9200 uses the policy below.")}
{t(
@@ -1050,38 +1042,6 @@ export default function LiveControlApp() {
);
})()
) : null}
- {dash.probes.neko_firefox ? (
- (() => {
- const p = dash.probes?.neko_firefox;
- const tcpOk = p?.tcp?.reachable;
- const http = p?.http;
- return (
-
-
Neko Firefox
-
- {t(
- "第二桌面(ENABLE_NEKO_FF=1);口令与 Chromium 相同。",
- "Second desktop (ENABLE_NEKO_FF=1); same passwords as Chromium.",
- )}
-
-
- :{p?.port ?? "—"} · TCP {tcpOk ? "OK" : "—"} · HTTP {http?.http_code ?? "—"}
-
-
- {t("延迟", "RTT")}{" "}
- {http?.latency_ms != null
- ? `${http.latency_ms} ms`
- : p?.tcp?.latency_ms != null
- ? `${p.tcp.latency_ms} ms`
- : "—"}
-
- {!http?.reachable && http?.error ? (
-
{http.error}
- ) : null}
-
- );
- })()
- ) : null}
) : null}
@@ -1132,14 +1092,6 @@ export default function LiveControlApp() {
)}
) : null}
- {s.service_id === "neko_firefox" ? (
-
- {t(
- "第二套 Neko 桌面(Firefox);ENABLE_NEKO_FF=1。登录:昵称任意,密码 = NEKO_USER_PASS / NEKO_ADMIN_PASS(与 Chromium 相同,默认 12345678)。",
- "Second Neko desktop (Firefox). Login: any display name; password = NEKO_USER_PASS / NEKO_ADMIN_PASS (same as Chromium).",
- )}
-
- ) : null}
{s.url ? (
androidAction(action, payload ?? {})}
onCopy={copyText}
+ onRefreshDevices={() => void refreshDashboard()}
/>
) : null}
diff --git a/web-console/components/live-product/LiveYoutubeUnmannedView.tsx b/web-console/components/live-product/LiveYoutubeUnmannedView.tsx
index 9e8caa6..cd3422c 100644
--- a/web-console/components/live-product/LiveYoutubeUnmannedView.tsx
+++ b/web-console/components/live-product/LiveYoutubeUnmannedView.tsx
@@ -19,6 +19,8 @@ import {
defaultPm2NameForChannel,
loadYoutubeProChannels,
newChannelId,
+ parseYoutubeProChannelList,
+ pushYoutubeProChannelsRemote,
saveYoutubeProChannels,
type YoutubeProChannel,
} from "@/lib/youtubeProChannels";
@@ -203,6 +205,7 @@ export function LiveYoutubeUnmannedView({
setChannels((prev) => {
const next = prev.map((x) => (x.id === activeCh ? { ...x, urlLines: c } : x));
saveYoutubeProChannels(next);
+ void pushYoutubeProChannelsRemote(fetchJson, next).catch(() => {});
return next;
});
}
@@ -238,6 +241,7 @@ export function LiveYoutubeUnmannedView({
setChannels((prev) => {
const next = prev.map((x) => (x.id === activeCh ? { ...x, urlLines: c } : x));
saveYoutubeProChannels(next);
+ void pushYoutubeProChannelsRemote(fetchJson, next).catch(() => {});
return next;
});
}
@@ -260,35 +264,57 @@ export function LiveYoutubeUnmannedView({
}, []);
useEffect(() => {
- const list = loadYoutubeProChannels();
- const initial =
- list.length > 0
- ? list
- : (() => {
- const id = newChannelId();
- return [
- {
- id,
- name: "线路 1",
- streamKey: "",
- urlLines: "",
- pm2Name: defaultPm2NameForChannel(id),
- notes: "",
- },
- ];
- })();
- setChannels(initial);
- if (!list.length) saveYoutubeProChannels(initial);
- }, []);
+ let cancelled = false;
+ void (async () => {
+ try {
+ const data = await fetchJson<{ channels?: unknown }>("/hub/youtube_pro_channels");
+ const fromServer = parseYoutubeProChannelList(data.channels);
+ if (fromServer.length && !cancelled) {
+ setChannels(fromServer);
+ saveYoutubeProChannels(fromServer);
+ return;
+ }
+ } catch {
+ /* 使用本机缓存 */
+ }
+ if (cancelled) return;
+ const list = loadYoutubeProChannels();
+ const initial =
+ list.length > 0
+ ? list
+ : (() => {
+ const id = newChannelId();
+ return [
+ {
+ id,
+ name: "线路 1",
+ streamKey: "",
+ urlLines: "",
+ pm2Name: defaultPm2NameForChannel(id),
+ notes: "",
+ },
+ ];
+ })();
+ setChannels(initial);
+ if (!list.length) saveYoutubeProChannels(initial);
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [fetchJson]);
useEffect(() => {
if (channels.length && !activeCh) setActiveCh(channels[0].id);
}, [channels, activeCh]);
- const persistChannels = useCallback((next: YoutubeProChannel[]) => {
- setChannels(next);
- saveYoutubeProChannels(next);
- }, []);
+ const persistChannels = useCallback(
+ (next: YoutubeProChannel[]) => {
+ setChannels(next);
+ saveYoutubeProChannels(next);
+ void pushYoutubeProChannelsRemote(fetchJson, next).catch(() => {});
+ },
+ [fetchJson],
+ );
const active = channels.find((c) => c.id === activeCh) || channels[0];
@@ -373,6 +399,7 @@ export function LiveYoutubeUnmannedView({
setChannels((prev) => {
const next = prev.map((x) => (x.id === activeCh ? { ...x, urlLines: c } : x));
saveYoutubeProChannels(next);
+ void pushYoutubeProChannelsRemote(fetchJson, next).catch(() => {});
return next;
});
} catch {
diff --git a/web-console/lib/youtubeProChannels.ts b/web-console/lib/youtubeProChannels.ts
index d08bbc0..ad6af27 100644
--- a/web-console/lib/youtubeProChannels.ts
+++ b/web-console/lib/youtubeProChannels.ts
@@ -14,6 +14,13 @@ export type YoutubeProChannel = {
const STORAGE_KEY = "live-hub-youtube-pro-v2";
+export function parseYoutubeProChannelList(raw: unknown): YoutubeProChannel[] {
+ if (!Array.isArray(raw)) return [];
+ return raw
+ .map((x) => migrateRow(x as Record))
+ .filter((c): c is YoutubeProChannel => c !== null);
+}
+
function migrateRow(x: Record): YoutubeProChannel | null {
if (!x || typeof x !== "object") return null;
const id = String(x.id || "").trim();
@@ -62,6 +69,17 @@ export function saveYoutubeProChannels(channels: YoutubeProChannel[]): void {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(channels));
}
+export async function pushYoutubeProChannelsRemote(
+ fetchJson: (path: string, init?: RequestInit) => Promise,
+ channels: YoutubeProChannel[],
+): Promise {
+ await fetchJson("/hub/youtube_pro_channels", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ channels }),
+ });
+}
+
export function newChannelId(): string {
return `ch_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
}
diff --git a/web.py b/web.py
index 1e73de1..85a8ba5 100644
--- a/web.py
+++ b/web.py
@@ -16,8 +16,12 @@ from fastapi.staticfiles import StaticFiles
from src.android_control import (
adb_available,
+ adb_connect_address,
+ adb_disconnect_address,
+ adb_pair_code,
android_action,
android_device_overview,
+ android_display_metrics,
android_logcat_recent,
android_packages,
android_screenshot,
@@ -305,6 +309,20 @@ class ConfigRestoreBody(BaseModel):
backup_id: str = Field(..., min_length=1, max_length=320)
+class AndroidConnectBody(BaseModel):
+ address: str = Field(..., min_length=1, max_length=200)
+
+
+class AndroidDisconnectBody(BaseModel):
+ address: str = Field(default="", max_length=200)
+
+
+class AndroidPairBody(BaseModel):
+ host: str = Field(..., min_length=1, max_length=160)
+ port: int = Field(default=37_777, ge=1, le=65535)
+ code: str = Field(..., min_length=4, max_length=16)
+
+
async def read_log_path(path: Optional[str], lines: int) -> str:
if not path:
return "无日志路径\n"
@@ -371,6 +389,30 @@ async def get_android_devices():
}
+@app.post("/android/connect")
+async def post_android_connect(body: AndroidConnectBody):
+ try:
+ return await adb_connect_address(body.address.strip())
+ except (RuntimeError, ValueError) as exc:
+ return JSONResponse({"error": str(exc)}, status_code=400)
+
+
+@app.post("/android/disconnect")
+async def post_android_disconnect(body: AndroidDisconnectBody):
+ try:
+ return await adb_disconnect_address((body.address or "").strip())
+ except (RuntimeError, ValueError) as exc:
+ return JSONResponse({"error": str(exc)}, status_code=400)
+
+
+@app.post("/android/pair")
+async def post_android_pair(body: AndroidPairBody):
+ try:
+ return await adb_pair_code(body.host.strip(), body.port, body.code.strip())
+ except (RuntimeError, ValueError) as exc:
+ return JSONResponse({"error": str(exc)}, status_code=400)
+
+
@app.get("/android/packages")
async def get_android_packages(
serial: str = Query(..., description="Android device serial"),
@@ -415,9 +457,12 @@ async def get_android_logcat(
@app.get("/android/screenshot")
-async def get_android_screenshot(serial: str = Query(..., description="Android device serial")):
+async def get_android_screenshot(
+ serial: str = Query(..., description="Android device serial"),
+ fast: bool = Query(False, description="更短超时,适合 Live 轮询(无线/Redroid 仍可能回落慢路径)"),
+):
try:
- content = await android_screenshot(serial)
+ content = await android_screenshot(serial, exec_timeout=8.5 if fast else 14.0)
except (RuntimeError, ValueError) as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
return Response(
@@ -427,6 +472,14 @@ async def get_android_screenshot(serial: str = Query(..., description="Android d
)
+@app.get("/android/display_metrics")
+async def get_android_display_metrics(serial: str = Query(..., description="Android device serial")):
+ try:
+ return await android_display_metrics(serial)
+ except (RuntimeError, ValueError) as exc:
+ return JSONResponse({"error": str(exc)}, status_code=400)
+
+
@app.post("/android/action")
async def post_android_action(request: Request):
try: