This commit is contained in:
eric
2026-03-28 14:16:42 -05:00
parent 8053c4cb6d
commit 92ecc14e43
11 changed files with 325 additions and 45 deletions

View File

@@ -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=

View File

@@ -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"
}
}

View File

@@ -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() {

View File

@@ -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

1
services/neko/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
data/

View File

@@ -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

View File

@@ -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 会报错)
# WebRTCNEKO_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"

View File

@@ -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
}

View File

@@ -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:

View File

@@ -3,6 +3,7 @@
"""通过 ParamikoSFTP + 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(

View File

@@ -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<string | null>(null);
const [androidShotUrl, setAndroidShotUrl] = useState<string | null>(null);
const [androidShotErr, setAndroidShotErr] = useState<string | null>(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}
</span>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{s.detail ? (
<p className="mt-2 text-[11px] leading-snug text-amber-200/85 line-clamp-4">{s.detail}</p>
) : null}
<div className="mt-4 flex flex-wrap items-center gap-2">
{s.url ? (
<a
href={normalizeBrowserReachableHttpUrl(s.url)}
target="_blank"
rel="noreferrer"
className="rounded-lg bg-violet-500/15 px-3 py-1.5 text-xs font-medium text-violet-200 ring-1 ring-violet-500/25"
>
{t("打开面板", "Open panel")}
</a>
) : null}
<button
type="button"
disabled={!!busy || !s.available}
title={!s.available ? s.detail || t("当前不可操作", "Unavailable") : undefined}
onClick={() => void runService(s.service_id, "start")}
className="rounded-lg bg-emerald-500/20 px-3 py-1.5 text-xs font-medium text-emerald-200 ring-1 ring-emerald-500/30 disabled:opacity-30"
>
@@ -582,6 +656,7 @@ export default function LiveControlApp() {
<button
type="button"
disabled={!!busy || !s.available}
title={!s.available ? s.detail || t("当前不可操作", "Unavailable") : undefined}
onClick={() => void runService(s.service_id, "restart")}
className="rounded-lg bg-amber-500/15 px-3 py-1.5 text-xs font-medium text-amber-200 ring-1 ring-amber-500/25 disabled:opacity-30"
>
@@ -590,6 +665,7 @@ export default function LiveControlApp() {
<button
type="button"
disabled={!!busy || !s.available}
title={!s.available ? s.detail || t("当前不可操作", "Unavailable") : undefined}
onClick={() => void runService(s.service_id, "stop")}
className="rounded-lg bg-rose-500/15 px-3 py-1.5 text-xs font-medium text-rose-200 ring-1 ring-rose-500/25 disabled:opacity-30"
>
@@ -753,6 +829,31 @@ export default function LiveControlApp() {
pm install
</button>
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("实时画面ADB 截图)", "Live screen (ADB)")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t(
"约每 2.5 秒刷新一次;无需 ws-scrcpy。点击下方「新窗口」可尝试流式画面。",
"~2.5s refresh via ADB; no ws-scrcpy required. Open ws-scrcpy in a new tab for streaming.",
)}
</p>
{!dash?.android?.adb_available ? (
<p className="mt-3 text-sm text-amber-200/90">{t("ADB 未就绪,无法截图。", "ADB unavailable.")}</p>
) : null}
{androidShotErr && dash?.android?.adb_available ? (
<p className="mt-3 text-sm text-rose-300">{androidShotErr}</p>
) : null}
{androidShotUrl ? (
<img
alt="Android live"
className="mt-4 max-h-[min(60vh,520px)] w-full rounded-xl border border-white/10 bg-black object-contain"
src={androidShotUrl}
/>
) : null}
{dash?.android?.adb_available && !androidShotUrl && !androidShotErr ? (
<p className="mt-6 text-center text-sm text-zinc-500">{t("加载截图…", "Loading screenshot…")}</p>
) : null}
</div>
{scrcpyUrl ? (
<div className="overflow-hidden rounded-2xl border border-white/[0.08] bg-black/40">
<div className="flex items-center justify-between border-b border-white/5 px-4 py-3">
@@ -808,21 +909,31 @@ export default function LiveControlApp() {
["SRS", ql.srs_http],
["Neko", ql.neko_browser],
["WebTTY", ql.webtty],
].map(([name, href]) =>
href ? (
<a
key={name}
href={href}
target="_blank"
rel="noreferrer"
className="flex items-center justify-between rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm hover:border-violet-500/30"
>
{name}
<ArrowUpRight className="h-4 w-4 text-zinc-600" />
</a>
) : null,
)}
].map(([name, href]) => (
<a
key={name}
href={href || "#"}
target="_blank"
rel="noreferrer"
className={`flex items-center justify-between rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm hover:border-violet-500/30 ${href ? "" : "pointer-events-none opacity-40"}`}
onClick={(e) => {
if (!href) {
e.preventDefault();
notify(t("仪表盘未返回该链接", "Hub did not return this link"));
}
}}
>
{name}
<ArrowUpRight className="h-4 w-4 text-zinc-600" />
</a>
))}
</div>
<p className="text-center text-xs text-zinc-500">
{t(
"若新标签页一直转圈:请先到「服务」里 Start 对应容器(如 SRS / Neko并确认防火墙未拦端口。",
"If the new tab spins: start the stack under Services (e.g. SRS/Neko) and check the firewall.",
)}
</p>
</div>
) : null}