s'
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
1
services/neko/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
data/
|
||||
36
services/neko/chromium.conf
Normal file
36
services/neko/chromium.conf
Normal 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
|
||||
@@ -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"
|
||||
|
||||
18
services/neko/policies/policies.json
Normal file
18
services/neko/policies/policies.json
Normal 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
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user