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: adb connect adb pair(无线调试)
+ {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(
+ "统一适配 USB 板子、Redroid 容器与无线 adb:截图轮询 + 输入注入。极致低延迟请用桌面",
+ "USB boards, Redroid, wireless adb: polled screencap + input. For lowest latency use desktop ",
+ )}
+
+ scrcpy
+
+ {t("。", ".")}
+
+
+ {t(
+ "快捷键(镜像区聚焦时):H Home · B Back · R 多任务 · W 唤醒 · M 菜单",
+ "Hotkeys (mirror focused): H Home · B Back · R Recents · W Wake · M Menu",
+ )}
+ {t("请选择或连接设备", "Pick or connect a device")} {imgErr}
+ {t("左键单击 / 双击 · 右键长按 · 坐标对齐 PNG 像素", "Left tap/double · right long-press · PNG pixel coords")}
+
+ {t("遥测", "Telemetry")}
+ {t("在上方点「刷新设备信息」", "Use Refresh device info above")}
+ {t("导航坞", "Nav dock")}
+
+ {t("手势", "Gestures")}
+
- {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.",
- )}
- {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
-
-
{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.error}{t("链路管理", "Link manager")}
+
+ {t("USB / Redroid :5555 / 无线调试(先配对再连接)", "USB · Redroid :5555 · wireless (pair then connect)")}
+
+
+ {t("Live Hub · ADB 云控台", "Live Hub · ADB farm console")}
+
+ {
+ setImgBusy(true);
+ }}
+ onLoad={onImgLoad}
+ onError={onImgError}
+ onClick={onImageClick}
+ onContextMenu={onImageContextMenu}
+ />
+
+
+ ) : (
+ Neko {t("登录", "sign-in")}
- {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}