diff --git a/config/system-stack.env.example b/config/system-stack.env.example index 37e047c..9fc6c60 100644 --- a/config/system-stack.env.example +++ b/config/system-stack.env.example @@ -55,12 +55,23 @@ DISABLE_IPV6=1 # ANDROID_PANEL_GIT_URL=https://github.com/baixin1228/web-scrcpy.git # 可选:Docker Neko 浏览器桌面(m1k1o/neko),与 SRS 8080 错开端口 +# 策略/启动参数对齐 x86 参考机:油猴强制安装、扩展白名单、用户数据持久化目录见 NEKO_PROFILE_HOST_DIR +# WebRTC 画面:NEKO_WEBRTC_NAT1TO1 必须为本机在局域网中的 IP(如 192.168.x.x),与参考机填网关 IP 同理 ENABLE_NEKO=0 NEKO_HTTP_PORT=9200 NEKO_USER_PASS=live NEKO_ADMIN_PASS=admin +# 持久化 Chromium 配置(油猴、YouTube 登录、脚本);可从参考机 rsync 整个目录覆盖以迁移 +NEKO_PROFILE_HOST_DIR= +NEKO_SHM_SIZE=2gb +NEKO_DESKTOP_SCREEN=1920x1080@30 +NEKO_WEBRTC_EPR=52000-52100 +NEKO_WEBRTC_UDP_RANGE=52000-52100 +NEKO_WEBRTC_ICELITE=1 +NEKO_WEBRTC_NAT1TO1= +NEKO_PLUGINS_ENABLED=true -# Android gateway mode +# Android gateway mode(第二网卡接安卓板时配置下行口,否则控制台显示「不可用」属正常) ANDROID_GATEWAY_NAME=live-android-gateway ANDROID_UPLINK_IF=eth0 ANDROID_DOWNLINK_IF= diff --git a/live-platform/config/defaults/services/neko.json b/live-platform/config/defaults/services/neko.json index 8e4d219..a7fd048 100644 --- a/live-platform/config/defaults/services/neko.json +++ b/live-platform/config/defaults/services/neko.json @@ -8,6 +8,14 @@ "ENABLE_NEKO": "0", "NEKO_HTTP_PORT": "9200", "NEKO_USER_PASS": "live", - "NEKO_ADMIN_PASS": "admin" + "NEKO_ADMIN_PASS": "admin", + "NEKO_PROFILE_HOST_DIR": "", + "NEKO_SHM_SIZE": "2gb", + "NEKO_DESKTOP_SCREEN": "1920x1080@30", + "NEKO_WEBRTC_EPR": "52000-52100", + "NEKO_WEBRTC_UDP_RANGE": "52000-52100", + "NEKO_WEBRTC_ICELITE": "1", + "NEKO_WEBRTC_NAT1TO1": "", + "NEKO_PLUGINS_ENABLED": "true" } } diff --git a/scripts/linux/lib/common.sh b/scripts/linux/lib/common.sh index e973cb0..eca34e1 100644 --- a/scripts/linux/lib/common.sh +++ b/scripts/linux/lib/common.sh @@ -417,8 +417,28 @@ install_neko_browser() { set -a [ -f "$STACK_ENV" ] && . "$STACK_ENV" set +a + local neko_dir="$ROOT_DIR/services/neko" + local neko_prof="${NEKO_PROFILE_HOST_DIR:-$neko_dir/data/chromium-profile}" + mkdir -p "$neko_prof" + chmod 777 "$neko_prof" 2>/dev/null || true export NEKO_HTTP_PORT="${NEKO_HTTP_PORT:-9200}" - compose_run "$ROOT_DIR/services/neko/docker-compose.yml" up -d + export NEKO_USER_PASS="${NEKO_USER_PASS:-live}" + export NEKO_ADMIN_PASS="${NEKO_ADMIN_PASS:-admin}" + export NEKO_PROFILE_HOST_DIR="$neko_prof" + export NEKO_DESKTOP_SCREEN="${NEKO_DESKTOP_SCREEN:-1920x1080@30}" + export NEKO_WEBRTC_EPR="${NEKO_WEBRTC_EPR:-52000-52100}" + export NEKO_WEBRTC_UDP_RANGE="${NEKO_WEBRTC_UDP_RANGE:-52000-52100}" + 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}')" + else + 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 + ) } configure_adb_udev() { diff --git a/scripts/linux/service_ctl.sh b/scripts/linux/service_ctl.sh index 3756e5c..b838c75 100644 --- a/scripts/linux/service_ctl.sh +++ b/scripts/linux/service_ctl.sh @@ -462,6 +462,13 @@ service_chromium_stop() { } service_neko_status() { + if [ "${ENABLE_NEKO:-0}" != "1" ]; then + kv available 0 + kv running 0 + kv status unavailable + kv detail "在 config/system-stack.env 设置 ENABLE_NEKO=1 后执行安装脚本或在此 Start 拉取容器" + return 0 + fi if [ ! -f "$NEKO_COMPOSE" ]; then kv available 0 kv running 0 @@ -488,19 +495,43 @@ service_neko_status() { } service_neko_start() { + if [ "${ENABLE_NEKO:-0}" != "1" ]; then + kv message "请先在 config/system-stack.env 设置 ENABLE_NEKO=1" + exit 1 + fi if [ ! -f "$NEKO_COMPOSE" ]; then 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 + export NEKO_HTTP_PORT="${NEKO_HTTP_PORT:-9200}" + export NEKO_USER_PASS="${NEKO_USER_PASS:-live}" + export NEKO_ADMIN_PASS="${NEKO_ADMIN_PASS:-admin}" + export NEKO_PROFILE_HOST_DIR="$prof" + export NEKO_DESKTOP_SCREEN="${NEKO_DESKTOP_SCREEN:-1920x1080@30}" + export NEKO_WEBRTC_EPR="${NEKO_WEBRTC_EPR:-52000-52100}" + export NEKO_WEBRTC_UDP_RANGE="${NEKO_WEBRTC_UDP_RANGE:-52000-52100}" + 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 + export NEKO_PLUGINS_ENABLED="${NEKO_PLUGINS_ENABLED:-true}" cmd="$(compose_bin)" || { kv message "docker compose unavailable" return 0 } - if [ "$cmd" = "docker compose" ]; then - docker compose -f "$NEKO_COMPOSE" up -d - else - docker-compose -f "$NEKO_COMPOSE" up -d - fi + ( + 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 + ) kv message "Neko stack started" } @@ -509,15 +540,20 @@ 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 } - if [ "$cmd" = "docker compose" ]; then - docker compose -f "$NEKO_COMPOSE" down - else - docker-compose -f "$NEKO_COMPOSE" down - fi + ( + 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 + ) kv message "Neko stack stopped" } @@ -526,7 +562,7 @@ service_android_gateway_status() { kv available 0 kv running 0 kv status unavailable - kv detail "Set ANDROID_DOWNLINK_IF in system-stack.env to enable gateway mode" + kv detail "透明网关需第二块网卡:在 system-stack.env 设置 ANDROID_DOWNLINK_IF(如 eth1),并安装 NetworkManager(nmcli)" return 0 fi if [ ! -d "/sys/class/net/$ANDROID_DOWNLINK_IF" ]; then diff --git a/services/neko/.gitignore b/services/neko/.gitignore new file mode 100644 index 0000000..8fce603 --- /dev/null +++ b/services/neko/.gitignore @@ -0,0 +1 @@ +data/ diff --git a/services/neko/chromium.conf b/services/neko/chromium.conf new file mode 100644 index 0000000..e65142e --- /dev/null +++ b/services/neko/chromium.conf @@ -0,0 +1,36 @@ +[program:chromium] +environment=HOME="/home/%(ENV_USER)s",USER="%(ENV_USER)s",DISPLAY="%(ENV_DISPLAY)s" +command=/usr/bin/chromium + --window-position=0,0 + --display=%(ENV_DISPLAY)s + --user-data-dir=/home/neko/.config/chromium + --password-store=basic + --no-first-run + --start-maximized + --force-dark-mode + --disable-file-system + --disable-gpu + --disable-software-rasterizer + --disable-dev-shm-usage + --disable-background-timer-throttling + --disable-backgrounding-occluded-windows + --disable-renderer-backgrounding +stopsignal=INT +autorestart=true +priority=800 +user=%(ENV_USER)s +stdout_logfile=/var/log/neko/chromium.log +stdout_logfile_maxbytes=100MB +stdout_logfile_backups=10 +redirect_stderr=true + +[program:openbox] +environment=HOME="/home/%(ENV_USER)s",USER="%(ENV_USER)s",DISPLAY="%(ENV_DISPLAY)s" +command=/usr/bin/openbox --config-file /etc/neko/openbox.xml +autorestart=true +priority=300 +user=%(ENV_USER)s +stdout_logfile=/var/log/neko/openbox.log +stdout_logfile_maxbytes=100MB +stdout_logfile_backups=10 +redirect_stderr=true diff --git a/services/neko/docker-compose.yml b/services/neko/docker-compose.yml index f2ab2c9..7440100 100644 --- a/services/neko/docker-compose.yml +++ b/services/neko/docker-compose.yml @@ -1,15 +1,32 @@ -# 可选:m1k1o/neko 浏览器桌面(ARM64/x86)。需在 system-stack.env 设 ENABLE_NEKO=1 并由安装脚本拉起。 -# 默认映射宿主机 NEKO_HTTP_PORT(默认 9200)→ 容器 8080,避免与 SRS 8080 冲突。 +# 变量由 system-stack.env 或启动前 export(勿使用 ${VAR:-default},旧版 docker-compose 会报错) +# WebRTC:NEKO_WEBRTC_NAT1TO1 填板子局域网 IP +version: "3.8" services: neko: image: ghcr.io/m1k1o/neko/chromium:latest container_name: live-neko-chromium restart: unless-stopped shm_size: "2gb" + cap_add: + - SYS_ADMIN environment: - NEKO_PASSWORD: "${NEKO_USER_PASS:-live}" - NEKO_PASSWORD_ADMIN: "${NEKO_ADMIN_PASS:-admin}" - NEKO_BIND: "0.0.0.0:8080" + DISPLAY: ":99.0" + NEKO_DESKTOP_SCREEN: "${NEKO_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_WEBRTC_EPR}" + NEKO_WEBRTC_ICELITE: "${NEKO_WEBRTC_ICELITE}" + NEKO_WEBRTC_NAT1TO1: "${NEKO_WEBRTC_NAT1TO1}" + NEKO_PLUGINS_ENABLED: "${NEKO_PLUGINS_ENABLED}" + NEKO_PLUGINS_DIR: "/etc/neko/plugins/" ports: - - "${NEKO_HTTP_PORT:-9200}:8080" - - "52000-52100:52000-52100/udp" + - "${NEKO_HTTP_PORT}:8080" + - "${NEKO_WEBRTC_UDP_RANGE}:52000-52100/udp" + volumes: + - "${NEKO_PROFILE_HOST_DIR}:/home/neko/.config/chromium" + - "./policies:/etc/chromium/policies/managed:ro" + - "./chromium.conf:/etc/neko/supervisord/chromium.conf:ro" diff --git a/services/neko/policies/policies.json b/services/neko/policies/policies.json new file mode 100644 index 0000000..d57588b --- /dev/null +++ b/services/neko/policies/policies.json @@ -0,0 +1,18 @@ +{ + "ExtensionInstallForcelist": [ + "dhdgffkkebhmkfjojejmpbldmpobfkfo;https://clients2.google.com/service/update2/crx" + ], + "ExtensionInstallAllowlist": ["*"], + "ExtensionInstallBlocklist": [], + "DownloadRestrictions": 0, + "AllowFileSelectionDialogs": true, + "URLAllowlist": ["file:///home/neko/Downloads/*"], + "DefaultCookiesSetting": 1, + "RestoreOnStartup": 1, + "AutoplayAllowed": true, + "DefaultNotificationsSetting": 3, + "VideoCaptureAllowed": true, + "AudioCaptureAllowed": true, + "DeveloperToolsAvailability": 2, + "DefaultGeolocationSetting": 3 +} diff --git a/src/control_plane.py b/src/control_plane.py index b31d126..320f82a 100644 --- a/src/control_plane.py +++ b/src/control_plane.py @@ -377,10 +377,10 @@ async def service_action(service_id: str, action: str) -> dict: if not SERVICE_CTL.is_file(): return _default_service_status(spec, "Service controller script is missing") - command = ["bash", str(SERVICE_CTL), service_id, action] - if shutil.which("sudo"): - command = ["sudo", "-n", "bash", str(SERVICE_CTL), service_id, action] - code, output = await _run_command(command, timeout=45.0) + cmd = ["bash", str(SERVICE_CTL), service_id, action] + code, output = await _run_command(cmd, timeout=45.0) + if code != 0 and shutil.which("sudo"): + code, output = await _run_command(["sudo", "-n", *cmd], timeout=45.0) info = _parse_kv_output(output) message = info.get("message", output) if code != 0: diff --git a/tools/deploy_live_paramiko.py b/tools/deploy_live_paramiko.py index c12712d..84fa9d3 100644 --- a/tools/deploy_live_paramiko.py +++ b/tools/deploy_live_paramiko.py @@ -3,6 +3,7 @@ """通过 Paramiko(SFTP + exec)部署到 ARM 板,无交互 SSH。日志写入 .deploy_logs/。""" from __future__ import annotations +import io import os import posixpath import sys @@ -35,7 +36,13 @@ FILES = [ "douyin_youtube_ffplay.py", "scripts/linux/install_hub.sh", "scripts/linux/reinstall_live_edge.sh", + "scripts/linux/service_ctl.sh", "scripts/linux/lib/common.sh", + "services/srs/docker-compose.yml", + "services/neko/docker-compose.yml", + "services/neko/chromium.conf", + "services/neko/policies/policies.json", + "services/neko/.gitignore", "live-platform/tools/render_caddyfile.py", "live-platform/tools/render_stack_env.py", "web-console/package.json", @@ -105,11 +112,19 @@ def mkdir_p(sftp: paramiko.SFTPClient, remote_dir: str) -> None: pass +def _shell_bytes_lf(data: bytes) -> bytes: + """Windows 工作区常见 CRLF;上传到 Linux 后 bash 会把 pipefail\\r 当成非法选项。""" + return data.replace(b"\r\n", b"\n").replace(b"\r", b"\n") + + def sftp_put(sftp: paramiko.SFTPClient, local: Path, remote: str) -> None: remote = remote.replace("\\", "/") parent = posixpath.dirname(remote) mkdir_p(sftp, parent) - sftp.put(str(local), remote) + if local.suffix.lower() == ".sh": + sftp.putfo(io.BytesIO(_shell_bytes_lf(local.read_bytes())), remote) + else: + sftp.put(str(local), remote) def connect() -> tuple[paramiko.SSHClient, paramiko.SFTPClient]: @@ -273,12 +288,19 @@ def main() -> int: f"echo '{PASS}' | sudo -S bash {REMOTE_BASE}/scripts/linux/reinstall_live_edge.sh", timeout=180, ) + # Caddy 刚 enable --now 时 bind :80 可能晚于立即 curl(曾出现 http_code=000) + time.sleep(8) run_logged( client, log_path, "curl_port80_after_edge", - "curl -sS -m 12 -o /dev/null -w 'http_code=%{http_code}\\n' http://127.0.0.1/ 2>&1 || echo curl_80_fail", - timeout=20, + "for i in 1 2 3 4 5; do " + "code=$(curl -sS -m 8 -o /dev/null -w '%{http_code}' http://127.0.0.1/ 2>/dev/null || echo 000); " + "echo \"try$i http_code=$code\"; " + "[ \"$code\" = 200 ] && exit 0; " + "sleep 3; " + "done; exit 1", + timeout=60, ) run_logged( diff --git a/web-console/components/live-product/LiveControlApp.tsx b/web-console/components/live-product/LiveControlApp.tsx index c78c3eb..33b8b8f 100644 --- a/web-console/components/live-product/LiveControlApp.tsx +++ b/web-console/components/live-product/LiveControlApp.tsx @@ -54,6 +54,7 @@ type HubDashboard = { running: boolean; status: string; detail?: string; + url?: string; }>; android?: { adb_available: boolean; devices: Array<{ serial: string; model?: string; state: string }> }; live?: { @@ -108,6 +109,8 @@ export default function LiveControlApp() { const [apkPath, setApkPath] = useState("/sdcard/app.apk"); const [androidSerial, setAndroidSerial] = useState(""); const [hubCfgText, setHubCfgText] = useState(null); + const [androidShotUrl, setAndroidShotUrl] = useState(null); + const [androidShotErr, setAndroidShotErr] = useState(null); const t = useCallback((zh: string, en: string) => tr(lang, zh, en), [lang]); @@ -155,6 +158,59 @@ export default function LiveControlApp() { return () => clearInterval(id); }, [refreshDashboard]); + useEffect(() => { + if (view !== "android" || !androidSerial || !dash?.android?.adb_available) { + setAndroidShotErr(null); + setAndroidShotUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return null; + }); + return; + } + let cancelled = false; + const tick = async () => { + try { + const res = await fetch( + `${base}/android/screenshot?serial=${encodeURIComponent(androidSerial)}`, + { cache: "no-store" }, + ); + const ct = res.headers.get("content-type") || ""; + if (!res.ok) { + let msg = `HTTP ${res.status}`; + if (ct.includes("json")) { + try { + const j = (await res.json()) as { error?: string }; + if (j.error) msg = j.error; + } catch { + /* ignore */ + } + } + if (!cancelled) setAndroidShotErr(msg); + return; + } + const blob = await res.blob(); + if (cancelled) return; + setAndroidShotErr(null); + setAndroidShotUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return URL.createObjectURL(blob); + }); + } catch (e) { + if (!cancelled) setAndroidShotErr((e as Error).message); + } + }; + void tick(); + const tid = window.setInterval(() => void tick(), 2500); + return () => { + cancelled = true; + window.clearInterval(tid); + setAndroidShotUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return null; + }); + }; + }, [view, androidSerial, base, dash?.android?.adb_available]); + useEffect(() => { void (async () => { try { @@ -224,12 +280,16 @@ export default function LiveControlApp() { const runService = async (service: string, action: "start" | "stop" | "restart") => { setBusy(`svc:${service}`); try { - const data = await fetchJson<{ message?: string }>("/service_action", { + const data = await fetchJson<{ message?: string; status?: string; detail?: string }>("/service_action", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ service, action }), }); - notify(data.message || "OK"); + if (data.status === "error") { + notify(data.detail || data.message || t("操作失败", "Action failed")); + } else { + notify(data.message || "OK"); + } await refreshDashboard(); } catch (e) { notify((e as Error).message); @@ -570,10 +630,24 @@ export default function LiveControlApp() { {s.running ? t("运行中", "Running") : s.status} -
+ {s.detail ? ( +

{s.detail}

+ ) : null} +
+ {s.url ? ( + + {t("打开面板", "Open panel")} + + ) : null}
+
+

{t("实时画面(ADB 截图)", "Live screen (ADB)")}

+

+ {t( + "约每 2.5 秒刷新一次;无需 ws-scrcpy。点击下方「新窗口」可尝试流式画面。", + "~2.5s refresh via ADB; no ws-scrcpy required. Open ws-scrcpy in a new tab for streaming.", + )} +

+ {!dash?.android?.adb_available ? ( +

{t("ADB 未就绪,无法截图。", "ADB unavailable.")}

+ ) : null} + {androidShotErr && dash?.android?.adb_available ? ( +

{androidShotErr}

+ ) : null} + {androidShotUrl ? ( + Android live + ) : null} + {dash?.android?.adb_available && !androidShotUrl && !androidShotErr ? ( +

{t("加载截图…", "Loading screenshot…")}

+ ) : null} +
{scrcpyUrl ? (
@@ -808,21 +909,31 @@ export default function LiveControlApp() { ["SRS", ql.srs_http], ["Neko", ql.neko_browser], ["WebTTY", ql.webtty], - ].map(([name, href]) => - href ? ( - - {name} - - - ) : null, - )} + ].map(([name, href]) => ( + { + if (!href) { + e.preventDefault(); + notify(t("仪表盘未返回该链接", "Hub did not return this link")); + } + }} + > + {name} + + + ))}
+

+ {t( + "若新标签页一直转圈:请先到「服务」里 Start 对应容器(如 SRS / Neko),并确认防火墙未拦端口。", + "If the new tab spins: start the stack under Services (e.g. SRS/Neko) and check the firewall.", + )} +

) : null}