From d74f3046a3dd2aad21686c1731f017647d4108d5 Mon Sep 17 00:00:00 2001 From: eric Date: Sat, 28 Mar 2026 15:08:59 -0500 Subject: [PATCH] 's' --- scripts/linux/lib/common.sh | 33 +- scripts/linux/service_ctl.sh | 67 +- services/neko/docker-compose.yml | 2 +- src/android_control.py | 8 + src/control_plane.py | 63 +- src/hub_routes.py | 4 +- tools/deploy_live_paramiko.py | 13 +- .../live-product/LiveAndroidWorkstation.tsx | 597 +++++++++++++++++ .../live-product/LiveControlApp.tsx | 329 ++++----- .../live-product/LiveTiktokHdmiView.tsx | 203 ++++++ .../live-product/LiveYoutubeUnmannedView.tsx | 626 ++++++++++++++++++ web-console/lib/youtubeConfigUtils.ts | 105 +++ web-console/lib/youtubeProChannels.ts | 71 ++ web.py | 13 + 14 files changed, 1889 insertions(+), 245 deletions(-) create mode 100644 web-console/components/live-product/LiveAndroidWorkstation.tsx create mode 100644 web-console/components/live-product/LiveTiktokHdmiView.tsx create mode 100644 web-console/components/live-product/LiveYoutubeUnmannedView.tsx create mode 100644 web-console/lib/youtubeConfigUtils.ts create mode 100644 web-console/lib/youtubeProChannels.ts diff --git a/scripts/linux/lib/common.sh b/scripts/linux/lib/common.sh index eca34e1..4017d92 100644 --- a/scripts/linux/lib/common.sh +++ b/scripts/linux/lib/common.sh @@ -59,6 +59,22 @@ compose_run() { fi } +compose_in_service_dir() { + local compose_file="$1" + shift + local d base + d="$(cd "$(dirname "$compose_file")" && pwd)" + base="$(basename "$compose_file")" + ( + cd "$d" || exit 1 + if docker compose version >/dev/null 2>&1; then + docker compose -f "$base" "$@" + else + docker-compose -f "$base" "$@" + fi + ) +} + as_live() { runuser -u live -- bash -lc "cd '$ROOT_DIR' && $*" } @@ -435,10 +451,7 @@ install_neko_browser() { export NEKO_WEBRTC_NAT1TO1 fi export NEKO_PLUGINS_ENABLED="${NEKO_PLUGINS_ENABLED:-true}" - ( - cd "$neko_dir" || exit 1 - compose_run "$neko_dir/docker-compose.yml" up -d - ) + compose_in_service_dir "$neko_dir/docker-compose.yml" up -d } configure_adb_udev() { @@ -642,7 +655,7 @@ prepare_homepage() { fi log "Preparing Homepage" render_homepage_services - compose_run "$ROOT_DIR/services/homepage/docker-compose.yml" up -d + compose_in_service_dir "$ROOT_DIR/services/homepage/docker-compose.yml" up -d } prepare_filebrowser() { @@ -660,7 +673,7 @@ prepare_filebrowser() { rm -f "$fb_db" touch "$fb_db" chmod -R 0777 "$ROOT_DIR/services/filebrowser/database" "$ROOT_DIR/services/filebrowser/config" - compose_run "$ROOT_DIR/services/filebrowser/docker-compose.yml" down >/dev/null 2>&1 || true + compose_in_service_dir "$ROOT_DIR/services/filebrowser/docker-compose.yml" down >/dev/null 2>&1 || true docker run --rm \ -v "$fb_db_dir:/database" \ @@ -736,7 +749,7 @@ PY rm -f "$fb_cfg_dir/users.backup.json" rm -f "$fb_tmp_json" - compose_run "$ROOT_DIR/services/filebrowser/docker-compose.yml" up -d + compose_in_service_dir "$ROOT_DIR/services/filebrowser/docker-compose.yml" up -d } prepare_srs() { @@ -745,7 +758,7 @@ prepare_srs() { fi log "Starting SRS" mkdir -p "$ROOT_DIR/services/srs/data" - compose_run "$ROOT_DIR/services/srs/docker-compose.yml" up -d + compose_in_service_dir "$ROOT_DIR/services/srs/docker-compose.yml" up -d } run_hardware_probe() { @@ -775,9 +788,9 @@ install_live_edge_proxy() { if [ -f "$ROOT_DIR/config/system-stack.env" ] && grep -q '^HOMEPAGE_PORT=80' "$ROOT_DIR/config/system-stack.env"; then sed -i 's/^HOMEPAGE_PORT=80$/HOMEPAGE_PORT=3080/' "$ROOT_DIR/config/system-stack.env" || true fi - compose_run "$ROOT_DIR/services/homepage/docker-compose.yml" down >/dev/null 2>&1 || true + compose_in_service_dir "$ROOT_DIR/services/homepage/docker-compose.yml" down >/dev/null 2>&1 || true render_homepage_services - compose_run "$ROOT_DIR/services/homepage/docker-compose.yml" up -d || true + compose_in_service_dir "$ROOT_DIR/services/homepage/docker-compose.yml" up -d || true fi fi diff --git a/scripts/linux/service_ctl.sh b/scripts/linux/service_ctl.sh index b838c75..f1c503d 100644 --- a/scripts/linux/service_ctl.sh +++ b/scripts/linux/service_ctl.sh @@ -88,6 +88,24 @@ compose_run() { fi } +# docker-compose v1 将 ./卷 解析为当前工作目录,必须在 compose 文件所在目录执行(与 Neko 一致) +compose_in_service_dir() { + local compose_file="$1" + shift + local d base + d="$(cd "$(dirname "$compose_file")" && pwd)" + base="$(basename "$compose_file")" + ( + cd "$d" || exit 1 + cmd="$(compose_bin)" || exit 1 + if [ "$cmd" = "docker compose" ]; then + docker compose -f "$base" "$@" + else + docker-compose -f "$base" "$@" + fi + ) +} + docker_container_status() { local name="$1" if ! have docker; then @@ -227,12 +245,12 @@ service_srs_status() { } service_srs_start() { - compose_run "$SRS_COMPOSE" up -d + compose_in_service_dir "$SRS_COMPOSE" up -d kv message "SRS started" } service_srs_stop() { - compose_run "$SRS_COMPOSE" down + compose_in_service_dir "$SRS_COMPOSE" down kv message "SRS stopped" } @@ -310,12 +328,12 @@ service_filebrowser_status() { } service_filebrowser_start() { - compose_run "$FILEBROWSER_COMPOSE" up -d + compose_in_service_dir "$FILEBROWSER_COMPOSE" up -d kv message "File Browser started" } service_filebrowser_stop() { - compose_run "$FILEBROWSER_COMPOSE" down + compose_in_service_dir "$FILEBROWSER_COMPOSE" down kv message "File Browser stopped" } @@ -339,12 +357,12 @@ service_homepage_status() { } service_homepage_start() { - compose_run "$HOMEPAGE_COMPOSE" up -d + compose_in_service_dir "$HOMEPAGE_COMPOSE" up -d kv message "Homepage started" } service_homepage_stop() { - compose_run "$HOMEPAGE_COMPOSE" down + compose_in_service_dir "$HOMEPAGE_COMPOSE" down kv message "Homepage stopped" } @@ -451,7 +469,12 @@ service_chromium_status() { } service_chromium_start() { - nohup "$BROWSER_LAUNCHER" >/tmp/live-browser.log 2>&1 & + if [ -z "${DISPLAY:-}" ] && command -v xvfb-run >/dev/null 2>&1; then + nohup xvfb-run -a "$BROWSER_LAUNCHER" >/tmp/live-browser.log 2>&1 & + else + [ -z "${DISPLAY:-}" ] && export DISPLAY=:0 + nohup "$BROWSER_LAUNCHER" >/tmp/live-browser.log 2>&1 & + fi disown || true kv message "Chromium launched" } @@ -503,8 +526,6 @@ service_neko_start() { kv message "Neko compose missing" return 0 fi - local neko_dir - neko_dir="$(cd "$(dirname "$NEKO_COMPOSE")" && pwd)" local prof="${NEKO_PROFILE_HOST_DIR:-$ROOT_DIR/services/neko/data/chromium-profile}" mkdir -p "$prof" chmod 777 "$prof" 2>/dev/null || true @@ -520,18 +541,7 @@ service_neko_start() { export NEKO_WEBRTC_NAT1TO1="$(hostname -I 2>/dev/null | awk '{print $1}')" fi export NEKO_PLUGINS_ENABLED="${NEKO_PLUGINS_ENABLED:-true}" - cmd="$(compose_bin)" || { - kv message "docker compose unavailable" - return 0 - } - ( - cd "$neko_dir" || exit 1 - if [ "$cmd" = "docker compose" ]; then - docker compose -f docker-compose.yml up -d - else - docker-compose -f docker-compose.yml up -d - fi - ) + compose_in_service_dir "$NEKO_COMPOSE" up -d kv message "Neko stack started" } @@ -540,20 +550,7 @@ service_neko_stop() { kv message "Neko compose missing" return 0 fi - local neko_dir - neko_dir="$(cd "$(dirname "$NEKO_COMPOSE")" && pwd)" - cmd="$(compose_bin)" || { - kv message "docker compose unavailable" - return 0 - } - ( - cd "$neko_dir" || exit 1 - if [ "$cmd" = "docker compose" ]; then - docker compose -f docker-compose.yml down - else - docker-compose -f docker-compose.yml down - fi - ) + compose_in_service_dir "$NEKO_COMPOSE" down kv message "Neko stack stopped" } diff --git a/services/neko/docker-compose.yml b/services/neko/docker-compose.yml index 7440100..d80e799 100644 --- a/services/neko/docker-compose.yml +++ b/services/neko/docker-compose.yml @@ -1,6 +1,6 @@ # 变量由 system-stack.env 或启动前 export(勿使用 ${VAR:-default},旧版 docker-compose 会报错) # WebRTC:NEKO_WEBRTC_NAT1TO1 填板子局域网 IP -version: "3.8" +version: "3.3" services: neko: image: ghcr.io/m1k1o/neko/chromium:latest diff --git a/src/android_control.py b/src/android_control.py index 1c14d11..4b291cd 100644 --- a/src/android_control.py +++ b/src/android_control.py @@ -299,6 +299,14 @@ def _is_png(data: bytes) -> bool: return bool(data and len(data) > 8 and data.startswith(b"\x89PNG\r\n\x1a\n")) +async def android_logcat_recent(serial: str, lines: int = 200) -> str: + """Dump recent logcat lines (logcat -d -t N), similar to device-console tooling.""" + if not serial: + raise ValueError("Missing Android device serial") + n = max(1, min(int(lines), 3000)) + return await _shell(serial, f"logcat -d -t {n}", timeout=60.0) + + async def android_screenshot(serial: str) -> bytes: if not serial: raise ValueError("Missing Android device serial") diff --git a/src/control_plane.py b/src/control_plane.py index 320f82a..a3f65b0 100644 --- a/src/control_plane.py +++ b/src/control_plane.py @@ -8,9 +8,11 @@ import shutil import socket import subprocess import sys +import time from dataclasses import dataclass from pathlib import Path -from typing import Iterable +from typing import Any, Iterable +from urllib.error import URLError from urllib.request import urlopen BASE_DIR = Path(__file__).resolve().parent.parent @@ -454,6 +456,65 @@ def load_stack_env() -> dict[str, str]: return values +def _probe_tcp_port(host: str, port: int, timeout: float = 2.0) -> dict[str, Any]: + t0 = time.monotonic() + try: + with socket.create_connection((host, int(port)), timeout=timeout): + ms = (time.monotonic() - t0) * 1000 + return {"ok": True, "reachable": True, "latency_ms": round(ms, 2), "error": ""} + except OSError as exc: + ms = (time.monotonic() - t0) * 1000 + return {"ok": False, "reachable": False, "latency_ms": round(ms, 2), "error": str(exc)} + + +def _probe_http_get(url: str, timeout: float = 2.5) -> dict[str, Any]: + t0 = time.monotonic() + try: + with urlopen(url, timeout=timeout) as resp: + code = int(resp.getcode() or 0) + ms = (time.monotonic() - t0) * 1000 + return { + "ok": True, + "http_code": code, + "latency_ms": round(ms, 2), + "reachable": True, + "error": "", + } + except (URLError, OSError, ValueError) as exc: + ms = (time.monotonic() - t0) * 1000 + return { + "ok": False, + "http_code": 0, + "latency_ms": round(ms, 2), + "reachable": False, + "error": str(exc), + } + + +async def stack_stream_probes() -> dict[str, Any]: + """本机 SRS / Neko HTTP 端口探测(供 live.local 验收)。""" + env = load_stack_env() + srs_p = int(env.get("SRS_HTTP_PORT", "8080") or 8080) + neko_p = int(env.get("NEKO_HTTP_PORT", "9200") or 9200) + loop = asyncio.get_event_loop() + + def run_srs() -> dict[str, Any]: + tcp = _probe_tcp_port("127.0.0.1", srs_p) + http = _probe_http_get(f"http://127.0.0.1:{srs_p}/", timeout=2.5) + return {"port": srs_p, "tcp": tcp, "http": http} + + def run_neko() -> dict[str, Any]: + tcp = _probe_tcp_port("127.0.0.1", neko_p) + http = _probe_http_get(f"http://127.0.0.1:{neko_p}/", timeout=2.5) + return {"port": neko_p, "tcp": tcp, "http": http} + + srs_d, neko_d = await asyncio.gather( + loop.run_in_executor(None, run_srs), + loop.run_in_executor(None, run_neko), + ) + return {"srs": srs_d, "neko": neko_d} + + def stack_summary() -> dict: env = load_stack_env() hostname = env.get("HOSTNAME_ALIAS") or env.get("HOSTNAME") or socket.gethostname() diff --git a/src/hub_routes.py b/src/hub_routes.py index 5a7866a..ff7b303 100644 --- a/src/hub_routes.py +++ b/src/hub_routes.py @@ -8,7 +8,7 @@ from fastapi import APIRouter from pydantic import BaseModel from src.android_control import adb_available, list_android_devices -from src.control_plane import query_services, stack_summary, system_snapshot +from src.control_plane import query_services, stack_stream_probes, stack_summary, system_snapshot from src.live_config import business_config, load_hub_config_snapshot, services_config_list from src.web_process_backend import WebProcessBackend @@ -38,6 +38,7 @@ async def hub_dashboard() -> dict[str, Any]: else: devices = [] stack = stack_summary() + probes = await stack_stream_probes() live_status: dict[str, Any] = { "processes": [], @@ -68,6 +69,7 @@ async def hub_dashboard() -> dict[str, Any]: return { "snapshot": snap, "stack": stack, + "probes": probes, "services": services, "android": { "adb_available": adb_available(), diff --git a/tools/deploy_live_paramiko.py b/tools/deploy_live_paramiko.py index 84fa9d3..57c3927 100644 --- a/tools/deploy_live_paramiko.py +++ b/tools/deploy_live_paramiko.py @@ -53,7 +53,12 @@ FILES = [ "web-console/middleware.ts", "web-console/components/OverviewCharts.tsx", "web-console/components/live-product/LiveControlApp.tsx", + "web-console/components/live-product/LiveAndroidWorkstation.tsx", + "web-console/components/live-product/LiveYoutubeUnmannedView.tsx", + "web-console/components/live-product/LiveTiktokHdmiView.tsx", "web-console/lib/panelUrls.ts", + "web-console/lib/youtubeProChannels.ts", + "web-console/lib/youtubeConfigUtils.ts", "web-console/components/console/LiveConsoleWorkspace.tsx", ] @@ -224,12 +229,18 @@ def main() -> int: _safe_print("远程 py_compile 失败,见日志") return 3 + _log_append( + log_path, + "\n[提示] web_console_build 在远端执行 npm + next build,ARM 上常需 5~20 分钟;" + "未完成前日志不会出现 exit=,属正常现象。\n", + ) + _safe_print("远端正在构建 web-console(请耐心等待,ARM 可能较久)…") build_cmd = ( f"cd {REMOTE_BASE}/web-console && " "(npm ci 2>/dev/null || npm install) && " "(command -v pnpm >/dev/null 2>&1 && pnpm run build || npm run build)" ) - code_b, _ = run_logged(client, log_path, "web_console_build", build_cmd, timeout=900) + code_b, _ = run_logged(client, log_path, "web_console_build", build_cmd, timeout=2400) if code_b != 0: _safe_print("[警告] web-console build 非 0(可能未装 Node/pnpm),完整输出见日志") diff --git a/web-console/components/live-product/LiveAndroidWorkstation.tsx b/web-console/components/live-product/LiveAndroidWorkstation.tsx new file mode 100644 index 0000000..0b9f4b6 --- /dev/null +++ b/web-console/components/live-product/LiveAndroidWorkstation.tsx @@ -0,0 +1,597 @@ +"use client"; + +import { ArrowUpRight, Layers, Loader2, Package, Terminal } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; + +type DeviceRow = { serial: string; model?: string; state: string }; +type TFn = (zh: string, en: string) => string; + +type AndroidOverview = { + serial?: string; + model?: string; + brand?: string; + android_version?: string; + sdk?: string; + battery?: string; + focus?: string; + resolution?: string; + rotation?: string; + screen_state?: string; +}; + +type PkgRow = { package: string; label?: string }; +type UiNode = { + label: string; + text?: string; + content_desc?: string; + resource_id?: string; + center_x: number; + center_y: number; + clickable?: boolean; + enabled?: boolean; +}; + +type Props = { + t: TFn; + notify: (msg: string) => void; + fetchJson: (path: string, init?: RequestInit) => Promise; + /** 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; + androidShotUrl: string | null; + androidShotErr: string | null; + apkPath: string; + setApkPath: (v: string) => void; + onAndroidAction: (action: string, payload?: Record) => Promise; +}; + +export function LiveAndroidWorkstation({ + t, + notify, + fetchJson, + busy, + androidSerial, + setAndroidSerial, + devices, + adbAvailable, + scrcpyUrl, + androidShotUrl, + androidShotErr, + apkPath, + setApkPath, + onAndroidAction, +}: Props) { + const [overview, setOverview] = useState(null); + const [overviewErr, setOverviewErr] = useState(null); + const [pkgQuery, setPkgQuery] = useState(""); + const [pkgList, setPkgList] = useState([]); + const [shellCmd, setShellCmd] = useState("getprop ro.product.model"); + const [shellOut, setShellOut] = useState(""); + const [logcatLines, setLogcatLines] = useState("300"); + const [logcatText, setLogcatText] = useState(""); + const [logcatErr, setLogcatErr] = useState(null); + const [uiNodes, setUiNodes] = useState([]); + const [uiErr, setUiErr] = useState(null); + const [openUrl, setOpenUrl] = useState("https://"); + const [pending, setPending] = useState(null); + + const lock = !!(busy || pending); + + const loadOverview = useCallback(async () => { + if (!androidSerial || !adbAvailable) { + setOverview(null); + setOverviewErr(null); + return; + } + setPending("overview"); + setOverviewErr(null); + try { + const o = await fetchJson( + `/android/overview?serial=${encodeURIComponent(androidSerial)}`, + ); + setOverview(o); + } catch (e) { + setOverview(null); + setOverviewErr((e as Error).message); + } finally { + setPending(null); + } + }, [adbAvailable, androidSerial, fetchJson]); + + useEffect(() => { + void loadOverview(); + }, [loadOverview]); + + const searchPackages = async () => { + if (!androidSerial) { + notify(t("请选择设备", "Pick a device")); + return; + } + setPending("packages"); + try { + const q = encodeURIComponent(pkgQuery.trim()); + const data = await fetchJson<{ packages: PkgRow[] }>( + `/android/packages?serial=${encodeURIComponent(androidSerial)}&query=${q}&limit=80`, + ); + setPkgList(data.packages || []); + } catch (e) { + notify((e as Error).message); + setPkgList([]); + } finally { + setPending(null); + } + }; + + const runShell = async () => { + if (!androidSerial) { + notify(t("请选择设备", "Pick a device")); + return; + } + const cmd = shellCmd.trim(); + if (!cmd) return; + setPending("shell"); + try { + const data = await fetchJson<{ output?: string; message?: string }>("/android/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "shell", serial: androidSerial, payload: { command: cmd } }), + }); + setShellOut(data.output ?? data.message ?? ""); + } catch (e) { + setShellOut((e as Error).message); + } finally { + setPending(null); + } + }; + + const refreshLogcat = async () => { + if (!androidSerial) { + notify(t("请选择设备", "Pick a device")); + return; + } + const n = Math.min(3000, Math.max(1, parseInt(logcatLines, 10) || 200)); + setPending("logcat"); + setLogcatErr(null); + try { + const data = await fetchJson<{ text?: string }>( + `/android/logcat?serial=${encodeURIComponent(androidSerial)}&lines=${n}`, + ); + setLogcatText(data.text ?? ""); + } catch (e) { + setLogcatText(""); + setLogcatErr((e as Error).message); + } finally { + setPending(null); + } + }; + + const loadUiNodes = async () => { + if (!androidSerial) { + notify(t("请选择设备", "Pick a device")); + return; + } + setPending("ui"); + setUiErr(null); + try { + const data = await fetchJson<{ nodes: UiNode[] }>( + `/android/ui?serial=${encodeURIComponent(androidSerial)}&limit=100`, + ); + setUiNodes(data.nodes || []); + } catch (e) { + setUiNodes([]); + setUiErr((e as Error).message); + } finally { + setPending(null); + } + }; + + const tapNode = async (x: number, y: number) => { + await onAndroidAction("tap", { x, y }); + }; + + const sendKey = async (keycode: string) => { + await onAndroidAction("key", { keycode }); + }; + + const openUrlGo = async () => { + const u = openUrl.trim(); + if (!u || u === "https://") { + notify(t("填写 URL", "Enter URL")); + return; + } + await onAndroidAction("open_url", { url: u }); + }; + + return ( +
+
+
+

{t("设备与工作台", "Device workstation")}

+ {!adbAvailable ? ( + {t("宿主机未检测到 adb", "adb not found on host")} + ) : ( + + )} +
+
+ {devices.map((d) => ( + + ))} +
+
+ + {adbAvailable && androidSerial ? ( +
+
+

+ {t("设备信息", "Device info")} +

+ {overviewErr ?

{overviewErr}

: null} + {overview ? ( +
+
+
model
{" "} +
+ {overview.brand} {overview.model} +
+
+
+
Android
{" "} +
+ {overview.android_version} (sdk {overview.sdk}) +
+
+
+
display
{" "} +
{overview.resolution}
+
+
+
rotation
{" "} +
{overview.rotation}
+
+
+ {overview.battery} +
+
{overview.focus}
+
+ ) : !overviewErr ? ( +

{t("加载中…", "Loading…")}

+ ) : null} +
+ +
+

+ {t("快捷控制", "Quick controls")} +

+

+ {t("按键与常用应用;深层调试用下方 Shell / Logcat。", "Keys & apps; use Shell / Logcat for deep debug.")} +

+
+ + + + + +
+
+ setOpenUrl(e.target.value)} + placeholder="https://" + /> + +
+
+
+ ) : null} + + {adbAvailable && androidSerial ? ( +
+

+ + {t("包名检索与启动", "Packages")} +

+

+ {t("过滤 pm list packages;行内可启动或强停。", "Filter pm list packages; launch or force-stop.")} +

+
+ setPkgQuery(e.target.value)} + placeholder="tiktok / musically / …" + /> + +
+
    + {pkgList.map((p) => ( +
  • + {p.package} + + + + +
  • + ))} + {!pkgList.length ? ( +
  • {t("暂无结果,先搜索", "No results")}
  • + ) : null} +
+
+ ) : null} + + {adbAvailable && androidSerial ? ( +
+

+ + {t("交互式 Shell(只读安全子集由你输入决定)", "ADB shell")} +

+

+ {t("等同 adb shell;勿执行不可逆命令。", "Same as adb shell; avoid destructive commands.")} +

+