#!/usr/bin/env bash set -euo pipefail SERVICE_ID="${1:-}" ACTION="${2:-status}" ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" CONFIG_DIR="$ROOT_DIR/config" STACK_ENV="$CONFIG_DIR/system-stack.env" HW_ENV="$CONFIG_DIR/hardware.env" 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" MITMPROXY_COMPOSE="$ROOT_DIR/services/mitmproxy/docker-compose.yml" VSCODE_SERVER_COMPOSE="$ROOT_DIR/services/vscode-server/docker-compose.yml" UPTIME_KUMA_COMPOSE="$ROOT_DIR/services/uptime-kuma/docker-compose.yml" OPENCLAW_COMPOSE="$ROOT_DIR/services/openclaw/docker-compose.yml" BROWSER_LAUNCHER="$ROOT_DIR/scripts/linux/launch_browser.sh" KMSCUBE_LAUNCHER="$ROOT_DIR/scripts/linux/run_kmscube.sh" [ -f "$STACK_ENV" ] && set -a && . "$STACK_ENV" && set +a [ -f "$HW_ENV" ] && set -a && . "$HW_ENV" && set +a HOSTNAME_ALIAS="${HOSTNAME_ALIAS:-live}" PORT="${PORT:-8001}" WEBTTY_PORT="${WEBTTY_PORT:-7681}" HOMEPAGE_PORT="${HOMEPAGE_PORT:-80}" FILEBROWSER_PORT="${FILEBROWSER_PORT:-8082}" NETDATA_PORT="${NETDATA_PORT:-19999}" SRS_HTTP_PORT="${SRS_HTTP_PORT:-8080}" SRS_RTMP_PORT="${SRS_RTMP_PORT:-1935}" SRS_API_PORT="${SRS_API_PORT:-1985}" SRS_SRT_PORT="${SRS_SRT_PORT:-10080}" SRS_WEBRTC_PORT="${SRS_WEBRTC_PORT:-8088}" SHELLCRASH_DIR="${SHELLCRASH_DIR:-/etc/ShellCrash}" WEBTTY_USER="${WEBTTY_USER:-live}" WEBTTY_PASS="${WEBTTY_PASS:-12345678}" FILEBROWSER_USER="${FILEBROWSER_USER:-live}" FILEBROWSER_PASS="${FILEBROWSER_PASS:-12345678}" WIFI_CONNECTION_NAME="${WIFI_CONNECTION_NAME:-live-wifi}" WIFI_SSID="${WIFI_SSID:-live}" ANDROID_GATEWAY_NAME="${ANDROID_GATEWAY_NAME:-live-android-gateway}" ANDROID_UPLINK_IF="${ANDROID_UPLINK_IF:-eth0}" ANDROID_DOWNLINK_IF="${ANDROID_DOWNLINK_IF:-}" ANDROID_GATEWAY_CIDR="${ANDROID_GATEWAY_CIDR:-192.168.51.1/24}" ANDROID_PANEL_PORT="${ANDROID_PANEL_PORT:-5000}" NEKO_HTTP_PORT="${NEKO_HTTP_PORT:-9200}" NEKO_HTTP_PORT_1="${NEKO_HTTP_PORT_1:-$NEKO_HTTP_PORT}" NEKO_HTTP_PORT_2="${NEKO_HTTP_PORT_2:-9201}" NEKO_HTTP_PORT_3="${NEKO_HTTP_PORT_3:-9202}" NEKO_INSTANCE_COUNT="${NEKO_INSTANCE_COUNT:-2}" NEKO_WEBRTC_TCPMUX="${NEKO_WEBRTC_TCPMUX:-52300}" NEKO_WEBRTC_UDPMUX="${NEKO_WEBRTC_UDPMUX:-52300}" NEKO_WEBRTC_TCPMUX_1="${NEKO_WEBRTC_TCPMUX_1:-$NEKO_WEBRTC_TCPMUX}" NEKO_WEBRTC_TCPMUX_2="${NEKO_WEBRTC_TCPMUX_2:-52301}" NEKO_WEBRTC_TCPMUX_3="${NEKO_WEBRTC_TCPMUX_3:-52302}" NEKO_WEBRTC_UDPMUX_1="${NEKO_WEBRTC_UDPMUX_1:-$NEKO_WEBRTC_UDPMUX}" NEKO_WEBRTC_UDPMUX_2="${NEKO_WEBRTC_UDPMUX_2:-52301}" NEKO_WEBRTC_UDPMUX_3="${NEKO_WEBRTC_UDPMUX_3:-52302}" NEKO_WEBRTC_UDP_RANGE="${NEKO_WEBRTC_UDP_RANGE:-52000-52099}" NEKO_WEBRTC_UDP_RANGE_1="${NEKO_WEBRTC_UDP_RANGE_1:-$NEKO_WEBRTC_UDP_RANGE}" NEKO_WEBRTC_UDP_RANGE_2="${NEKO_WEBRTC_UDP_RANGE_2:-52101-52199}" NEKO_WEBRTC_UDP_RANGE_3="${NEKO_WEBRTC_UDP_RANGE_3:-52201-52299}" NEKO_WEBRTC_EPR="${NEKO_WEBRTC_EPR:-$NEKO_WEBRTC_UDP_RANGE_1}" NEKO_WEBRTC_EPR_1="${NEKO_WEBRTC_EPR_1:-$NEKO_WEBRTC_EPR}" NEKO_WEBRTC_EPR_2="${NEKO_WEBRTC_EPR_2:-$NEKO_WEBRTC_UDP_RANGE_2}" NEKO_WEBRTC_EPR_3="${NEKO_WEBRTC_EPR_3:-$NEKO_WEBRTC_UDP_RANGE_3}" NEKO_START_URL="${NEKO_START_URL:-https://accounts.google.com/ServiceLogin?continue=https://www.youtube.com/}" NEKO_IMAGE_X86="${NEKO_IMAGE_X86:-ghcr.io/m1k1o/neko/google-chrome:3.0.0}" NEKO_IMAGE_ARM64="${NEKO_IMAGE_ARM64:-ghcr.io/m1k1o/neko/chromium:3.0.0}" case "$NEKO_IMAGE_X86" in ghcr.io/m1k1o/neko/google-chrome:latest) NEKO_IMAGE_X86="ghcr.io/m1k1o/neko/google-chrome:3.0.0" ;; esac case "$NEKO_IMAGE_ARM64" in ghcr.io/m1k1o/neko/chromium:latest) NEKO_IMAGE_ARM64="ghcr.io/m1k1o/neko/chromium:3.0.0" ;; esac NEKO_SHM_SIZE="${NEKO_SHM_SIZE:-512mb}" NEKO_DESKTOP_SCREEN="${NEKO_DESKTOP_SCREEN:-1280x720@15}" NEKO_MAX_FPS="${NEKO_MAX_FPS:-15}" NEKO_VIDEO_BITRATE="${NEKO_VIDEO_BITRATE:-1200}" NEKO_BROWSER_RENDER_MODE="${NEKO_BROWSER_RENDER_MODE:-auto}" ENABLE_NEKO="${ENABLE_NEKO:-1}" ENABLE_MITMPROXY="${ENABLE_MITMPROXY:-1}" MITMPROXY_WEB_PORT="${MITMPROXY_WEB_PORT:-8086}" MITMPROXY_PROXY_PORT="${MITMPROXY_PROXY_PORT:-8087}" ENABLE_VSCODE_SERVER="${ENABLE_VSCODE_SERVER:-1}" VSCODE_SERVER_PORT="${VSCODE_SERVER_PORT:-8440}" VSCODE_SERVER_PASSWORD="${VSCODE_SERVER_PASSWORD:-12345678}" ENABLE_UPTIME_KUMA="${ENABLE_UPTIME_KUMA:-1}" UPTIME_KUMA_PORT="${UPTIME_KUMA_PORT:-3001}" ENABLE_PULSEAUDIO="${ENABLE_PULSEAUDIO:-1}" PULSEAUDIO_TCP_PORT="${PULSEAUDIO_TCP_PORT:-4713}" ENABLE_KMSCUBE="${ENABLE_KMSCUBE:-1}" KMSCUBE_DRM_DEVICE="${KMSCUBE_DRM_DEVICE:-/dev/dri/card0}" ENABLE_OPENCLAW="${ENABLE_OPENCLAW:-1}" OPENCLAW_IMAGE="${OPENCLAW_IMAGE:-ghcr.io/openclaw/openclaw:latest}" OPENCLAW_GATEWAY_PORT="${OPENCLAW_GATEWAY_PORT:-18789}" OPENCLAW_BRIDGE_PORT="${OPENCLAW_BRIDGE_PORT:-18790}" OPENCLAW_GATEWAY_TOKEN="${OPENCLAW_GATEWAY_TOKEN:-12345678}" OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-1}" OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-/var/lib/live-openclaw/config}" OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-/var/lib/live-openclaw/workspace}" ENABLE_REDROID="${ENABLE_REDROID:-1}" REDROID_DEFAULT_PORT="${REDROID_DEFAULT_PORT:-5555}" REDROID_INSTANCE_PREFIX="${REDROID_INSTANCE_PREFIX:-redroid13}" REDROID_DATA_PREFIX="${REDROID_DATA_PREFIX:-/var/lib/live-redroid}" REDROID_GPU_MODE="${REDROID_GPU_MODE:-guest}" REDROID_DEVICE_PROFILE="${REDROID_DEVICE_PROFILE:-pixel-7-pro}" REDROID_BOOT_ARGS="${REDROID_BOOT_ARGS:-androidboot.redroid_magisk=1}" REDROID_EXPECT_GMS="${REDROID_EXPECT_GMS:-1}" REDROID_EXPECT_MAGISK="${REDROID_EXPECT_MAGISK:-1}" REDROID_IMAGE_X86="${REDROID_IMAGE_X86:-fahaddz/redroid:13}" REDROID_IMAGE_X86_ALT="${REDROID_IMAGE_X86_ALT:-fahaddz/redroid:13}" REDROID_IMAGE_ARM64="${REDROID_IMAGE_ARM64:-fahaddz/redroid:13-arm-pi5}" REDROID_IMAGE_ARM64_ALT="${REDROID_IMAGE_ARM64_ALT:-fahaddz/redroid:13-arm-pi5}" if [ "$REDROID_IMAGE_ARM64" = "cnflysky/redroid-rk3588:lineage-20" ]; then REDROID_IMAGE_ARM64="fahaddz/redroid:13-arm-pi5" fi if [ "$REDROID_IMAGE_ARM64_ALT" = "cnflysky/redroid-rk3588:lineage-20" ]; then REDROID_IMAGE_ARM64_ALT="fahaddz/redroid:13-arm-pi5" fi if [ "${REDROID_IMAGE:-}" = "cnflysky/redroid-rk3588:lineage-20" ]; then REDROID_IMAGE="fahaddz/redroid:13-arm-pi5" fi case " ${REDROID_BOOT_ARGS} " in *" androidboot.redroid_gpu_mode="*) ;; *) REDROID_BOOT_ARGS="${REDROID_BOOT_ARGS} androidboot.redroid_gpu_mode=${REDROID_GPU_MODE:-guest}" ;; esac CONSOLE_BASE_URL="http://${HOSTNAME_ALIAS}.local:${PORT}" kv() { printf '%s=%s\n' "$1" "$2" } have() { command -v "$1" >/dev/null 2>&1 } allow_tcp_port_if_firewall_active() { local port="$1" if have ufw && ufw status 2>/dev/null | grep -q '^Status: active'; then ufw allow "${port}/tcp" >/dev/null 2>&1 || true fi } allow_udp_port_if_firewall_active() { local port="$1" if have ufw && ufw status 2>/dev/null | grep -q '^Status: active'; then ufw allow "${port}/udp" >/dev/null 2>&1 || true fi } allow_udp_range_if_firewall_active() { local port_range="$1" local ufw_range="${port_range//-/:}" if have ufw && ufw status 2>/dev/null | grep -q '^Status: active'; then ufw allow "${ufw_range}/udp" >/dev/null 2>&1 || true fi } prune_neko_profile() { local prof="$1" [ -n "$prof" ] || return 0 mkdir -p "$prof" rm -rf \ "$prof/SingletonLock" \ "$prof/SingletonSocket" \ "$prof/SingletonCookie" \ "$prof/SingletonStartupLock" \ "$prof/lockfile" \ "$prof/DevToolsActivePort" \ "$prof/BrowserMetrics" \ "$prof/Crash Reports" \ "$prof/Crashpad" \ "$prof/ShaderCache" \ "$prof/GrShaderCache" \ "$prof/GraphiteDawnCache" \ "$prof/Default/Cache" \ "$prof/Default/Code Cache" \ "$prof/Default/GPUCache" \ "$prof/Default/DawnGraphiteCache" \ "$prof/Default/DawnWebGPUCache" \ 2>/dev/null || true chmod -R 0777 "$prof" 2>/dev/null || true find "$prof" -type d -exec chmod 0777 {} \; 2>/dev/null || true find "$prof" -type f -exec chmod 0666 {} \; 2>/dev/null || true } filtered_pgrep() { local pattern="$1" pgrep -fa "$pattern" 2>/dev/null | grep -v -F "$0" | grep -v -F "pgrep -fa" || true } unit_exists() { systemctl list-unit-files "$1" --no-legend 2>/dev/null | grep -q "^$1" } unit_active() { systemctl is-active --quiet "$1" } compose_bin() { if have docker && docker compose version >/dev/null 2>&1; then echo "docker compose" return 0 fi if have docker-compose; then echo "docker-compose" return 0 fi return 1 } compose_run() { local compose_file="$1" shift local cmd cmd="$(compose_bin)" || return 127 if [ "$cmd" = "docker compose" ]; then docker compose -f "$compose_file" "$@" else docker-compose -f "$compose_file" "$@" 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 return 1 fi docker ps -a --filter "name=^/${name}$" --format '{{.State}}|{{.Status}}' | head -n 1 } resolve_neko_image() { case "$(uname -m)" in x86_64|amd64) printf '%s' "$NEKO_IMAGE_X86" ;; aarch64|arm64) printf '%s' "$NEKO_IMAGE_ARM64" ;; *) printf '%s' "$NEKO_IMAGE_ARM64" ;; esac } resolve_neko_supervisor_conf() { case "$(uname -m)" in x86_64|amd64) printf '%s' "google-chrome.conf" ;; aarch64|arm64) printf '%s' "chromium.conf" ;; *) printf '%s' "chromium.conf" ;; esac } resolve_neko_profile_dir() { local family family="$(resolve_neko_supervisor_conf)" family="${family%.conf}" printf '%s' "$ROOT_DIR/services/neko/data/${family}-profile-v2" } resolve_neko_profile_dir_for_instance() { local idx="${1:-1}" local base base="${NEKO_PROFILE_HOST_DIR:-$(resolve_neko_profile_dir)}" case "$idx" in 1) printf '%s' "$base" ;; *) printf '%s-%s' "$base" "$idx" ;; esac } resolve_neko_instance_count() { local requested="${NEKO_INSTANCE_COUNT:-2}" local cpu count cpu="$(nproc 2>/dev/null || echo 4)" case "$requested" in 1|2|3) count="$requested" ;; auto|"") if [ "$cpu" -le 4 ]; then count=2 else count=3 fi ;; *) count=2 ;; esac if [ "$count" -lt 1 ]; then count=1 fi if [ "$count" -gt 3 ]; then count=3 fi printf '%s' "$count" } remove_legacy_neko_container() { if have docker && docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qx 'live-neko-chromium'; then docker stop live-neko-chromium >/dev/null 2>&1 || true docker rm live-neko-chromium >/dev/null 2>&1 || true fi } detect_preferred_lan_ip() { local src iface candidate path name src="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for (i=1; i<=NF; i++) if ($i=="src") {print $(i+1); exit}}')" if [ -n "$src" ]; then printf '%s\n' "$src" return 0 fi iface="$(ip route show default 2>/dev/null | awk 'NR==1 {print $5}')" if [ -n "$iface" ]; then src="$(ip -4 addr show dev "$iface" 2>/dev/null | awk '$1=="inet" {sub(/\/.*/, "", $2); print $2; exit}')" if [ -n "$src" ]; then printf '%s\n' "$src" return 0 fi fi for path in /sys/class/net/*; do [ -e "$path" ] || continue name="${path##*/}" case "$name" in lo|docker*|br-*|veth*|virbr*|tun*|tap*|zt*|tailscale*|wg*|sit*|dummy*|p2p-dev-*|mon.*) continue ;; esac candidate="$(ip -4 addr show dev "$name" 2>/dev/null | awk '$1=="inet" {sub(/\/.*/, "", $2); print $2; exit}')" if [ -n "$candidate" ]; then printf '%s\n' "$candidate" return 0 fi done hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.' | grep -Ev '^(127\.|169\.254\.)' | head -n 1 } sync_mdns_hosts_entry() { local preferred_lan_ip preferred_lan_ip="$(detect_preferred_lan_ip || true)" python3 - </dev/null; then ipv6="disabled" fi detail="hostname=${hostname}; avahi_ipv6=${ipv6}" kv available 1 kv running "$(unit_active avahi-daemon && echo 1 || echo 0)" kv status "$(unit_active avahi-daemon && echo online || echo stopped)" kv host "${HOSTNAME_ALIAS}.local" kv port 22 kv url "ssh ${WEBTTY_USER}@${HOSTNAME_ALIAS}.local" kv detail "$detail" } service_mdns_start() { hostnamectl set-hostname "$HOSTNAME_ALIAS" sync_mdns_hosts_entry systemctl restart avahi-daemon kv message "mDNS hostname refreshed" } service_mdns_stop() { systemctl stop avahi-daemon kv message "avahi-daemon stopped" } wifi_detect_iface() { local iface path name if have iw; then while read -r iface; do [ -n "$iface" ] || continue case "$iface" in p2p-dev-*|mon.*) continue ;; esac printf '%s\n' "$iface" return 0 done < <(iw dev 2>/dev/null | awk '$1=="Interface"{print $2}') fi for path in /sys/class/net/*; do [ -e "$path" ] || continue name="${path##*/}" case "$name" in lo|docker*|br-*|veth*|virbr*|tun*|tap*|p2p-dev-*|mon.*) continue ;; esac if [ -d "$path/wireless" ]; then printf '%s\n' "$name" return 0 fi done return 1 } service_wifi_status() { if ! have nmcli; then kv available 0 kv running 0 kv status unavailable kv detail "NetworkManager (nmcli) is not available" return 0 fi local active detail iface profile_exists running status iface="$(wifi_detect_iface || true)" active="$(nmcli -t -f NAME,DEVICE connection show --active 2>/dev/null | awk -F: -v want="$WIFI_CONNECTION_NAME" '$1==want{print $2; exit}')" profile_exists="$(nmcli -t -f NAME connection show 2>/dev/null | grep -Fx "$WIFI_CONNECTION_NAME" || true)" detail="ssid=${WIFI_SSID};iface=${iface:-none};profile=$([ -n "$profile_exists" ] && echo present || echo missing)" if [ -z "$iface" ]; then detail="${detail};warning=no_wireless_interface_detected" fi if [ -n "$active" ]; then running=1 status=online detail="${detail};active_device=${active}" elif [ -n "$profile_exists" ]; then running=0 status=configured else running=0 status=provisioning fi kv available 1 kv running "$running" kv status "$status" kv host "${HOSTNAME_ALIAS}.local" kv port "" kv url "" kv detail "$detail" } service_wifi_start() { if ! have nmcli; then kv message "NetworkManager (nmcli) is not available" exit 1 fi local iface ifname iface="$(wifi_detect_iface || true)" ifname="${iface:-*}" if ! nmcli connection show "$WIFI_CONNECTION_NAME" >/dev/null 2>&1; then nmcli connection add type wifi ifname "$ifname" con-name "$WIFI_CONNECTION_NAME" ssid "$WIFI_SSID" \ wifi-sec.key-mgmt wpa-psk wifi-sec.psk "${WIFI_PSK:-12345678}" ipv4.method auto connection.autoconnect yes else nmcli connection modify "$WIFI_CONNECTION_NAME" \ connection.interface-name "$ifname" \ 802-11-wireless.ssid "$WIFI_SSID" \ wifi-sec.key-mgmt wpa-psk \ wifi-sec.psk "${WIFI_PSK:-12345678}" \ connection.autoconnect yes \ ipv4.method auto >/dev/null 2>&1 || true fi nmcli connection up "$WIFI_CONNECTION_NAME" >/dev/null 2>&1 || true kv message "Wi-Fi profile ensured (iface=${iface:-*})" } service_wifi_stop() { nmcli connection down "$WIFI_CONNECTION_NAME" >/dev/null 2>&1 || true kv message "Wi-Fi profile disconnected" } service_shellcrash_status() { local running detail if [ ! -x "$SHELLCRASH_DIR/start.sh" ] && ! unit_exists shellcrash.service; then kv available 0 kv running 0 kv status unavailable kv detail "ShellCrash is not installed" return 0 fi running=0 detail="$(filtered_pgrep 'CrashCore|shellcrash' | tr '\n' ';' || true)" if unit_active shellcrash.service || [ -n "$detail" ]; then running=1 fi [ -z "$detail" ] && detail="dir=$SHELLCRASH_DIR" kv available 1 kv running "$running" kv status "$([ "$running" = 1 ] && echo online || echo stopped)" kv url "${CONSOLE_BASE_URL}/shellcrash" kv host "${HOSTNAME_ALIAS}.local" kv port "" kv detail "$detail" } service_shellcrash_start() { if unit_exists shellcrash.service; then systemctl enable --now shellcrash.service elif [ -x "$SHELLCRASH_DIR/start.sh" ]; then "$SHELLCRASH_DIR/start.sh" start else kv message "ShellCrash is not installed" exit 1 fi kv message "ShellCrash started" } service_shellcrash_stop() { if unit_exists shellcrash.service; then systemctl stop shellcrash.service elif [ -x "$SHELLCRASH_DIR/start.sh" ]; then "$SHELLCRASH_DIR/start.sh" stop fi kv message "ShellCrash stopped" } service_srs_status() { if ! have docker || [ ! -f "$SRS_COMPOSE" ]; then kv available 0 kv running 0 kv status unavailable kv detail "Docker or SRS compose file is missing" return 0 fi local state state="$(docker_container_status live-srs || 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 "$SRS_HTTP_PORT" kv url "http://${HOSTNAME_ALIAS}.local:${SRS_HTTP_PORT}" kv detail "${state:-container=missing};rtmp=${SRS_RTMP_PORT};api=${SRS_API_PORT};srt=${SRS_SRT_PORT};rtc=${SRS_WEBRTC_PORT}" } service_srs_start() { compose_in_service_dir "$SRS_COMPOSE" up -d kv message "SRS started" } service_srs_stop() { compose_in_service_dir "$SRS_COMPOSE" down kv message "SRS stopped" } service_webtty_status() { if ! unit_exists live-webtty.service && ! have ttyd; then kv available 0 kv running 0 kv status unavailable kv detail "ttyd is not installed" return 0 fi kv available 1 kv running "$(unit_active live-webtty.service && echo 1 || echo 0)" kv status "$(unit_active live-webtty.service && echo online || echo stopped)" kv host "${HOSTNAME_ALIAS}.local" kv port "$WEBTTY_PORT" kv url "http://${HOSTNAME_ALIAS}.local:${WEBTTY_PORT}" kv detail "web user=${WEBTTY_USER}" } service_webtty_start() { systemctl enable --now live-webtty.service kv message "WebTTY started" } service_webtty_stop() { systemctl stop live-webtty.service kv message "WebTTY stopped" } service_cockpit_status() { if ! unit_exists cockpit.socket; then kv available 0 kv running 0 kv status unavailable kv detail "Cockpit is not installed" return 0 fi kv available 1 kv running "$(unit_active cockpit.socket && echo 1 || echo 0)" kv status "$(unit_active cockpit.socket && echo online || echo stopped)" kv host "${HOSTNAME_ALIAS}.local" kv port 9090 kv url "https://${HOSTNAME_ALIAS}.local:9090" kv detail "login with live or any enabled system account" } service_cockpit_start() { systemctl enable --now cockpit.socket kv message "Cockpit started" } service_cockpit_stop() { systemctl stop cockpit.socket kv message "Cockpit stopped" } service_filebrowser_status() { if ! have docker || [ ! -f "$FILEBROWSER_COMPOSE" ]; then kv available 0 kv running 0 kv status unavailable kv detail "Docker or File Browser compose file is missing" return 0 fi local state state="$(docker_container_status live-filebrowser || 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 "$FILEBROWSER_PORT" kv url "http://${HOSTNAME_ALIAS}.local:${FILEBROWSER_PORT}" kv detail "${state:-container=missing};user=${FILEBROWSER_USER:-live}" } service_filebrowser_start() { compose_in_service_dir "$FILEBROWSER_COMPOSE" up -d kv message "File Browser started" } service_filebrowser_stop() { compose_in_service_dir "$FILEBROWSER_COMPOSE" down kv message "File Browser stopped" } service_homepage_status() { if ! have docker || [ ! -f "$HOMEPAGE_COMPOSE" ]; then kv available 0 kv running 0 kv status unavailable kv detail "Docker or Homepage compose file is missing" return 0 fi local state state="$(docker_container_status live-homepage || 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 "$HOMEPAGE_PORT" kv url "$([ "$HOMEPAGE_PORT" = 80 ] && echo "http://${HOSTNAME_ALIAS}.local" || echo "http://${HOSTNAME_ALIAS}.local:${HOMEPAGE_PORT}")" kv detail "${state:-container=missing}" } service_homepage_start() { compose_in_service_dir "$HOMEPAGE_COMPOSE" up -d kv message "Homepage started" } service_homepage_stop() { compose_in_service_dir "$HOMEPAGE_COMPOSE" down kv message "Homepage stopped" } service_netdata_status() { if ! unit_exists netdata.service; then kv available 0 kv running 0 kv status unavailable kv detail "Netdata is not installed" return 0 fi kv available 1 kv running "$(unit_active netdata.service && echo 1 || echo 0)" kv status "$(unit_active netdata.service && echo online || echo stopped)" kv host "${HOSTNAME_ALIAS}.local" kv port "$NETDATA_PORT" kv url "http://${HOSTNAME_ALIAS}.local:${NETDATA_PORT}" kv detail "metrics and alarms API available at /api/v1" } service_netdata_start() { systemctl enable --now netdata kv message "Netdata started" } service_netdata_stop() { systemctl stop netdata kv message "Netdata stopped" } service_adb_status() { if ! have adb; then kv available 0 kv running 0 kv status unavailable kv detail "adb is not installed" return 0 fi local details details="$(adb devices -l 2>/dev/null | tr '\n' ';' || true)" kv available 1 kv running "$(filtered_pgrep 'adb -L tcp:5037|[ /]adb([ ]|$)' >/dev/null 2>&1 && echo 1 || echo 0)" kv status "$(filtered_pgrep 'adb -L tcp:5037|[ /]adb([ ]|$)' >/dev/null 2>&1 && echo online || echo idle)" kv host "${HOSTNAME_ALIAS}.local" kv port "" kv url "" kv detail "${details:-adb server ready}" } service_adb_start() { adb start-server >/dev/null kv message "ADB server started" } service_adb_stop() { adb kill-server >/dev/null 2>&1 || true kv message "ADB server stopped" } service_android_panel_status() { if ! unit_exists live-android-panel.service; then kv available 0 kv running 0 kv status unavailable kv detail "Android web panel is not installed" return 0 fi kv available 1 kv running "$(unit_active live-android-panel.service && echo 1 || echo 0)" kv status "$(unit_active live-android-panel.service && echo online || echo stopped)" kv host "${HOSTNAME_ALIAS}.local" kv port "$ANDROID_PANEL_PORT" kv url "${CONSOLE_BASE_URL}/android" kv detail "android-center=${CONSOLE_BASE_URL}/android;ws-scrcpy=${CONSOLE_BASE_URL}/android;git=ANDROID_PANEL_GIT_URL" } service_android_panel_start() { systemctl enable --now live-android-panel.service kv message "Android web panel started" } service_android_panel_stop() { systemctl stop live-android-panel.service kv message "Android web panel stopped" } service_chromium_status() { if ! have chromium && ! have chromium-browser && ! have google-chrome; then kv available 0 kv running 0 kv status unavailable kv detail "Chromium or Google Chrome is not installed" return 0 fi local details details="$(filtered_pgrep 'chromium|chrome' | tr '\n' ';' || true)" kv available 1 kv running "$([ -n "$details" ] && echo 1 || echo 0)" kv status "$([ -n "$details" ] && echo online || echo stopped)" kv host "${HOSTNAME_ALIAS}.local" kv port "${BROWSER_REMOTE_DEBUG_PORT:-9222}" kv url "" kv detail "${details:-profile=${BROWSER_PROFILE_DIR:-$HOME/.local/share/live-browser/profile}}" } service_chromium_start() { 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" } service_chromium_stop() { pkill -f 'chromium|chrome' || true kv message "Chromium stopped" } service_neko_status() { if [ "${ENABLE_NEKO:-1}" != "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 kv status unavailable kv detail "services/neko/docker-compose.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 kv available 1 local state local running_count=0 total_count=0 states="" name instance_count instance_count="$(resolve_neko_instance_count)" total_count="$instance_count" for name in live-neko-1 live-neko-2 live-neko-3; do case "$name" in live-neko-1) [ "$instance_count" -ge 1 ] || continue ;; live-neko-2) [ "$instance_count" -ge 2 ] || continue ;; live-neko-3) [ "$instance_count" -ge 3 ] || continue ;; esac state="$(docker_container_status "$name" || true)" if echo "$state" | grep -qi running; then running_count=$((running_count + 1)) fi states="${states}${name}:${state:-missing};" done if [ "$running_count" -eq 0 ]; then state="$(docker_container_status live-neko-chromium || true)" if echo "$state" | grep -qi running; then running_count=1 total_count=1 states="live-neko-chromium:${state};" fi fi kv running "$([ "$running_count" -ge 1 ] && echo 1 || echo 0)" kv status "$([ "$running_count" -ge "$total_count" ] && echo online || ([ "$running_count" -gt 0 ] && echo degraded || echo stopped))" kv host "${HOSTNAME_ALIAS}.local" kv port "$NEKO_HTTP_PORT_1" kv url "http://${HOSTNAME_ALIAS}.local:${NEKO_HTTP_PORT_1}" if ! neko_nat1to1_is_usable "${NEKO_WEBRTC_NAT1TO1:-}"; then NEKO_WEBRTC_NAT1TO1="$(detect_preferred_lan_ip || true)" fi kv detail "image=$(resolve_neko_image); supervisor_conf=$(resolve_neko_supervisor_conf); start_url=${NEKO_START_URL}; nat1to1=${NEKO_WEBRTC_NAT1TO1:-unset}; instances=${running_count}/${total_count}; ports=${NEKO_HTTP_PORT_1},${NEKO_HTTP_PORT_2},${NEKO_HTTP_PORT_3}; profiles=$(resolve_neko_profile_dir_for_instance 1),$(resolve_neko_profile_dir_for_instance 2),$(resolve_neko_profile_dir_for_instance 3); screen=${NEKO_DESKTOP_SCREEN}; shm=${NEKO_SHM_SIZE}; fps=${NEKO_MAX_FPS}; bitrate=${NEKO_VIDEO_BITRATE}; render=${NEKO_BROWSER_RENDER_MODE}; udp_ranges=${NEKO_WEBRTC_UDP_RANGE_1},${NEKO_WEBRTC_UDP_RANGE_2},${NEKO_WEBRTC_UDP_RANGE_3}; tcpmux=${NEKO_WEBRTC_TCPMUX_1},${NEKO_WEBRTC_TCPMUX_2},${NEKO_WEBRTC_TCPMUX_3}; udpmux=${NEKO_WEBRTC_UDPMUX_1},${NEKO_WEBRTC_UDPMUX_2},${NEKO_WEBRTC_UDPMUX_3}; containers=${states}" } service_neko_start() { if [ "${ENABLE_NEKO:-1}" != "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 prof local instance_count local service_names="" extra_service_names="" idx_name instance_count="$(resolve_neko_instance_count)" for idx_name in 1 2 3; do case "$idx_name" in 1) if [ "$instance_count" -ge 1 ]; then service_names="${service_names} neko1" else extra_service_names="${extra_service_names} neko1" fi ;; 2) if [ "$instance_count" -ge 2 ]; then service_names="${service_names} neko2" else extra_service_names="${extra_service_names} neko2" fi ;; 3) if [ "$instance_count" -ge 3 ]; then service_names="${service_names} neko3" else extra_service_names="${extra_service_names} neko3" fi ;; esac done for idx in 1 2 3; do [ "$idx" -le "$instance_count" ] || break prof="$(resolve_neko_profile_dir_for_instance "$idx")" prune_neko_profile "$prof" done export_neko_env for port in "$NEKO_HTTP_PORT_1" "$NEKO_HTTP_PORT_2" "$NEKO_HTTP_PORT_3" "$NEKO_WEBRTC_TCPMUX_1" "$NEKO_WEBRTC_TCPMUX_2" "$NEKO_WEBRTC_TCPMUX_3"; do case "$port" in "$NEKO_HTTP_PORT_1"|"$NEKO_WEBRTC_TCPMUX_1") [ "$instance_count" -ge 1 ] || continue ;; "$NEKO_HTTP_PORT_2"|"$NEKO_WEBRTC_TCPMUX_2") [ "$instance_count" -ge 2 ] || continue ;; "$NEKO_HTTP_PORT_3"|"$NEKO_WEBRTC_TCPMUX_3") [ "$instance_count" -ge 3 ] || continue ;; esac allow_tcp_port_if_firewall_active "$port" done for port in "$NEKO_WEBRTC_UDPMUX_1" "$NEKO_WEBRTC_UDPMUX_2" "$NEKO_WEBRTC_UDPMUX_3"; do case "$port" in "$NEKO_WEBRTC_UDPMUX_1") [ "$instance_count" -ge 1 ] || continue ;; "$NEKO_WEBRTC_UDPMUX_2") [ "$instance_count" -ge 2 ] || continue ;; "$NEKO_WEBRTC_UDPMUX_3") [ "$instance_count" -ge 3 ] || continue ;; esac allow_udp_port_if_firewall_active "$port" done for port_range in "$NEKO_WEBRTC_UDP_RANGE_1" "$NEKO_WEBRTC_UDP_RANGE_2" "$NEKO_WEBRTC_UDP_RANGE_3"; do case "$port_range" in "$NEKO_WEBRTC_UDP_RANGE_1") [ "$instance_count" -ge 1 ] || continue ;; "$NEKO_WEBRTC_UDP_RANGE_2") [ "$instance_count" -ge 2 ] || continue ;; "$NEKO_WEBRTC_UDP_RANGE_3") [ "$instance_count" -ge 3 ] || continue ;; esac allow_udp_range_if_firewall_active "$port_range" done remove_legacy_neko_container # 只启动实际需要的实例,避免多余的 Chrome/Xorg 进程继续吃 CPU # shellcheck disable=SC2086 compose_in_service_dir "$NEKO_COMPOSE" up -d $service_names if [ -n "${extra_service_names# }" ]; then # shellcheck disable=SC2086 compose_in_service_dir "$NEKO_COMPOSE" stop $extra_service_names >/dev/null 2>&1 || true fi kv message "Neko stack started (${instance_count} browser instances)" } export_neko_env() { export NEKO_HTTP_PORT="${NEKO_HTTP_PORT:-9200}" export NEKO_HTTP_PORT_1="${NEKO_HTTP_PORT_1:-$NEKO_HTTP_PORT}" export NEKO_HTTP_PORT_2="${NEKO_HTTP_PORT_2:-9201}" export NEKO_HTTP_PORT_3="${NEKO_HTTP_PORT_3:-9202}" export NEKO_INSTANCE_COUNT="${NEKO_INSTANCE_COUNT:-2}" export NEKO_USER_PASS="${NEKO_USER_PASS:-12345678}" export NEKO_ADMIN_PASS="${NEKO_ADMIN_PASS:-12345678}" export NEKO_PROFILE_HOST_DIR="${NEKO_PROFILE_HOST_DIR:-$(resolve_neko_profile_dir)}" export NEKO_PROFILE_HOST_DIR_1="${NEKO_PROFILE_HOST_DIR_1:-$(resolve_neko_profile_dir_for_instance 1)}" export NEKO_PROFILE_HOST_DIR_2="${NEKO_PROFILE_HOST_DIR_2:-$(resolve_neko_profile_dir_for_instance 2)}" export NEKO_PROFILE_HOST_DIR_3="${NEKO_PROFILE_HOST_DIR_3:-$(resolve_neko_profile_dir_for_instance 3)}" export NEKO_START_URL="${NEKO_START_URL:-https://accounts.google.com/ServiceLogin?continue=https://www.youtube.com/}" export NEKO_IMAGE="$(resolve_neko_image)" export NEKO_BROWSER_SUPERVISOR_CONF="$(resolve_neko_supervisor_conf)" export NEKO_SHM_SIZE="${NEKO_SHM_SIZE:-512mb}" export NEKO_DESKTOP_SCREEN="${NEKO_DESKTOP_SCREEN:-1280x720@15}" export NEKO_MAX_FPS="${NEKO_MAX_FPS:-15}" export NEKO_VIDEO_BITRATE="${NEKO_VIDEO_BITRATE:-1200}" export NEKO_BROWSER_RENDER_MODE="${NEKO_BROWSER_RENDER_MODE:-auto}" export NEKO_WEBRTC_UDP_RANGE="${NEKO_WEBRTC_UDP_RANGE:-52000-52099}" export NEKO_WEBRTC_UDP_RANGE_1="${NEKO_WEBRTC_UDP_RANGE_1:-$NEKO_WEBRTC_UDP_RANGE}" export NEKO_WEBRTC_UDP_RANGE_2="${NEKO_WEBRTC_UDP_RANGE_2:-52101-52199}" export NEKO_WEBRTC_UDP_RANGE_3="${NEKO_WEBRTC_UDP_RANGE_3:-52201-52299}" export NEKO_WEBRTC_EPR="${NEKO_WEBRTC_EPR:-$NEKO_WEBRTC_UDP_RANGE_1}" export NEKO_WEBRTC_EPR_1="${NEKO_WEBRTC_EPR_1:-$NEKO_WEBRTC_EPR}" export NEKO_WEBRTC_EPR_2="${NEKO_WEBRTC_EPR_2:-$NEKO_WEBRTC_UDP_RANGE_2}" export NEKO_WEBRTC_EPR_3="${NEKO_WEBRTC_EPR_3:-$NEKO_WEBRTC_UDP_RANGE_3}" # Upstream n.eko images include STUN servers by default, and ICE Lite with ICE servers is unstable. # Keep full ICE on by default even if an older system-stack.env still contains NEKO_WEBRTC_ICELITE=1. if [ "${NEKO_FORCE_ICELITE:-0}" = "1" ]; then export NEKO_WEBRTC_ICELITE="${NEKO_WEBRTC_ICELITE:-1}" else export NEKO_WEBRTC_ICELITE=0 fi export NEKO_WEBRTC_TCPMUX="${NEKO_WEBRTC_TCPMUX:-52300}" export NEKO_WEBRTC_UDPMUX="${NEKO_WEBRTC_UDPMUX:-52300}" export NEKO_WEBRTC_TCPMUX_1="${NEKO_WEBRTC_TCPMUX_1:-$NEKO_WEBRTC_TCPMUX}" export NEKO_WEBRTC_TCPMUX_2="${NEKO_WEBRTC_TCPMUX_2:-52301}" export NEKO_WEBRTC_TCPMUX_3="${NEKO_WEBRTC_TCPMUX_3:-52302}" export NEKO_WEBRTC_UDPMUX_1="${NEKO_WEBRTC_UDPMUX_1:-$NEKO_WEBRTC_UDPMUX}" export NEKO_WEBRTC_UDPMUX_2="${NEKO_WEBRTC_UDPMUX_2:-52301}" export NEKO_WEBRTC_UDPMUX_3="${NEKO_WEBRTC_UDPMUX_3:-52302}" if ! neko_nat1to1_is_usable "${NEKO_WEBRTC_NAT1TO1:-}"; then export NEKO_WEBRTC_NAT1TO1="$(detect_preferred_lan_ip || true)" else export NEKO_WEBRTC_NAT1TO1 fi export NEKO_PLUGINS_ENABLED="${NEKO_PLUGINS_ENABLED:-true}" } service_neko_stop() { if [ ! -f "$NEKO_COMPOSE" ]; then kv message "Neko compose missing" return 0 fi export_neko_env compose_in_service_dir "$NEKO_COMPOSE" down kv message "Neko stack stopped" } service_mitmproxy_status() { if [ "${ENABLE_MITMPROXY:-1}" != "1" ]; then kv available 0 kv running 0 kv status unavailable kv detail "Set ENABLE_MITMPROXY=1 in config/system-stack.env to enable mitmproxy" return 0 fi if ! have docker || [ ! -f "$MITMPROXY_COMPOSE" ]; then kv available 0 kv running 0 kv status unavailable kv detail "Docker or mitmproxy compose file is missing" return 0 fi local state state="$(docker_container_status live-mitmproxy || 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 "$MITMPROXY_WEB_PORT" kv url "http://${HOSTNAME_ALIAS}.local:${MITMPROXY_WEB_PORT}" kv detail "${state:-container=missing};proxy=${MITMPROXY_PROXY_PORT}" } service_mitmproxy_start() { compose_in_service_dir "$MITMPROXY_COMPOSE" up -d kv message "mitmproxy started" } service_mitmproxy_stop() { compose_in_service_dir "$MITMPROXY_COMPOSE" down kv message "mitmproxy stopped" } service_vscode_server_status() { if [ "${ENABLE_VSCODE_SERVER:-1}" != "1" ]; then kv available 0 kv running 0 kv status unavailable kv detail "Set ENABLE_VSCODE_SERVER=1 in config/system-stack.env to enable code-server" return 0 fi if ! have docker || [ ! -f "$VSCODE_SERVER_COMPOSE" ]; then kv available 0 kv running 0 kv status unavailable kv detail "Docker or code-server compose file is missing" return 0 fi local state state="$(docker_container_status live-code-server || 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 "$VSCODE_SERVER_PORT" kv url "http://${HOSTNAME_ALIAS}.local:${VSCODE_SERVER_PORT}" kv detail "${state:-container=missing};password=${VSCODE_SERVER_PASSWORD}" } service_vscode_server_start() { compose_in_service_dir "$VSCODE_SERVER_COMPOSE" up -d kv message "code-server started" } service_vscode_server_stop() { compose_in_service_dir "$VSCODE_SERVER_COMPOSE" down kv message "code-server stopped" } service_uptime_kuma_status() { if [ "${ENABLE_UPTIME_KUMA:-1}" != "1" ]; then kv available 0 kv running 0 kv status unavailable kv detail "Set ENABLE_UPTIME_KUMA=1 in config/system-stack.env to enable Uptime Kuma" return 0 fi if ! have docker || [ ! -f "$UPTIME_KUMA_COMPOSE" ]; then kv available 0 kv running 0 kv status unavailable kv detail "Docker or Uptime Kuma compose file is missing" return 0 fi local state state="$(docker_container_status live-uptime-kuma || 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 "$UPTIME_KUMA_PORT" kv url "http://${HOSTNAME_ALIAS}.local:${UPTIME_KUMA_PORT}" kv detail "${state:-container=missing}" } service_uptime_kuma_start() { compose_in_service_dir "$UPTIME_KUMA_COMPOSE" up -d kv message "Uptime Kuma started" } service_uptime_kuma_stop() { compose_in_service_dir "$UPTIME_KUMA_COMPOSE" down kv message "Uptime Kuma stopped" } service_pulseaudio_status() { if [ "${ENABLE_PULSEAUDIO:-1}" != "1" ]; then kv available 0 kv running 0 kv status unavailable kv detail "Set ENABLE_PULSEAUDIO=1 in config/system-stack.env to enable PulseAudio" return 0 fi if ! unit_exists live-pulseaudio.service && ! have pulseaudio; then kv available 0 kv running 0 kv status unavailable kv detail "PulseAudio is not installed" return 0 fi kv available 1 kv running "$(unit_active live-pulseaudio.service && echo 1 || echo 0)" kv status "$(unit_active live-pulseaudio.service && echo online || echo stopped)" kv host "${HOSTNAME_ALIAS}.local" kv port "$PULSEAUDIO_TCP_PORT" kv url "${CONSOLE_BASE_URL}/?view=services" kv detail "tcp=${PULSEAUDIO_TCP_PORT}; control=${CONSOLE_BASE_URL}" } service_pulseaudio_start() { systemctl enable --now live-pulseaudio.service kv message "PulseAudio started" } service_pulseaudio_stop() { systemctl stop live-pulseaudio.service kv message "PulseAudio stopped" } service_kmscube_status() { if [ "${ENABLE_KMSCUBE:-1}" != "1" ]; then kv available 0 kv running 0 kv status unavailable kv detail "Set ENABLE_KMSCUBE=1 in config/system-stack.env to enable kmscube diagnostics" return 0 fi if ! unit_exists live-kmscube.service && ! have kmscube; then kv available 0 kv running 0 kv status unavailable kv detail "kmscube is not installed" return 0 fi kv available 1 kv running "$(unit_active live-kmscube.service && echo 1 || echo 0)" kv status "$(unit_active live-kmscube.service && echo online || echo stopped)" kv host "${HOSTNAME_ALIAS}.local" kv port "" kv url "${CONSOLE_BASE_URL}/?view=services" kv detail "launcher=${KMSCUBE_LAUNCHER}; drm=${KMSCUBE_DRM_DEVICE}" } service_kmscube_start() { systemctl start live-kmscube.service kv message "kmscube started" } service_kmscube_stop() { systemctl stop live-kmscube.service kv message "kmscube stopped" } service_openclaw_status() { if [ "${ENABLE_OPENCLAW:-1}" != "1" ]; then kv available 0 kv running 0 kv status unavailable kv detail "Set ENABLE_OPENCLAW=1 in config/system-stack.env to enable OpenClaw" return 0 fi if ! have docker || [ ! -f "$OPENCLAW_COMPOSE" ]; then kv available 0 kv running 0 kv status unavailable kv detail "Docker or OpenClaw compose file is missing" return 0 fi local state state="$(docker_container_status live-openclaw || 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 "$OPENCLAW_GATEWAY_PORT" kv url "http://${HOSTNAME_ALIAS}.local:${OPENCLAW_GATEWAY_PORT}" kv detail "${state:-container=missing};bridge=${OPENCLAW_BRIDGE_PORT}" } service_openclaw_start() { mkdir -p "$OPENCLAW_CONFIG_DIR" "$OPENCLAW_WORKSPACE_DIR" compose_in_service_dir "$OPENCLAW_COMPOSE" up -d kv message "OpenClaw started" } service_openclaw_stop() { compose_in_service_dir "$OPENCLAW_COMPOSE" down kv message "OpenClaw stopped" } redroid_default_name() { printf '%s' "${REDROID_INSTANCE_PREFIX}-1" } redroid_resolved_image() { if [ -n "${REDROID_IMAGE:-}" ]; then printf '%s' "$REDROID_IMAGE" return 0 fi case "$(uname -m)" in x86_64|amd64) printf '%s' "${REDROID_IMAGE_X86_ALT:-$REDROID_IMAGE_X86}" ;; aarch64|arm64) printf '%s' "${REDROID_IMAGE_ARM64_ALT:-$REDROID_IMAGE_ARM64}" ;; *) printf '%s' "$REDROID_IMAGE_X86" ;; esac } redroid_binderfs_ready() { [ -e /dev/binderfs/binder-control ] } redroid_legacy_binder_devices_ready() { [ -e /dev/binder ] && [ -e /dev/hwbinder ] && [ -e /dev/vndbinder ] } redroid_binder_mode() { if redroid_binderfs_ready; then printf '%s\n' binderfs return 0 fi if redroid_legacy_binder_devices_ready; then printf '%s\n' legacy return 0 fi return 1 } redroid_binder_is_provisionable() { redroid_binder_mode >/dev/null 2>&1 && return 0 grep -qw binder /proc/filesystems 2>/dev/null } redroid_support_detail() { if redroid_binderfs_ready; then printf '%s' "binderfs mounted" return 0 fi if redroid_legacy_binder_devices_ready; then printf '%s' "legacy binder devices available" return 0 fi if grep -qw binder /proc/filesystems 2>/dev/null; then printf '%s' "binder filesystem supported but not mounted" return 0 fi printf '%s' "binder kernel support unavailable" } ensure_redroid_host_support() { if redroid_binder_mode >/dev/null 2>&1; then return 0 fi if have modprobe; then modprobe binder_linux devices=binder,hwbinder,vndbinder >/dev/null 2>&1 || \ modprobe binder_linux >/dev/null 2>&1 || true fi if redroid_binder_mode >/dev/null 2>&1; then return 0 fi if grep -qw binder /proc/filesystems 2>/dev/null; then mkdir -p /dev/binderfs if have mountpoint; then if ! mountpoint -q /dev/binderfs 2>/dev/null; then mount -t binder binder /dev/binderfs >/dev/null 2>&1 || \ mount -t binderfs binderfs /dev/binderfs >/dev/null 2>&1 || true fi elif ! grep -qE '[[:space:]]/dev/binderfs[[:space:]]' /proc/mounts 2>/dev/null; then mount -t binder binder /dev/binderfs >/dev/null 2>&1 || \ mount -t binderfs binderfs /dev/binderfs >/dev/null 2>&1 || true fi fi redroid_binder_mode >/dev/null 2>&1 } redroid_container_matches_host_support() { local name="$1" case "$(redroid_binder_mode 2>/dev/null || true)" in binderfs) docker inspect -f '{{range .Mounts}}{{println .Destination}}{{end}}' "$name" 2>/dev/null | grep -qx '/dev/binderfs' ;; legacy) docker inspect -f '{{range .HostConfig.Devices}}{{println .PathOnHost}}{{end}}' "$name" 2>/dev/null | grep -qx '/dev/binder' && \ docker inspect -f '{{range .HostConfig.Devices}}{{println .PathOnHost}}{{end}}' "$name" 2>/dev/null | grep -qx '/dev/hwbinder' && \ docker inspect -f '{{range .HostConfig.Devices}}{{println .PathOnHost}}{{end}}' "$name" 2>/dev/null | grep -qx '/dev/vndbinder' ;; *) return 1 ;; esac } service_redroid_status() { if [ "${ENABLE_REDROID:-1}" != "1" ]; then kv available 0 kv running 0 kv status unavailable kv detail "Set ENABLE_REDROID=1 in config/system-stack.env to enable Redroid" 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 default_name running_line all_lines binder_detail default_name="$(redroid_default_name)" binder_detail="$(redroid_support_detail)" running_line="$(docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' 2>/dev/null | grep -E "^${REDROID_INSTANCE_PREFIX}-|^${default_name} " | head -n1 || true)" all_lines="$(docker ps -a --format '{{.Names}} {{.Image}} {{.Status}} {{.Ports}}' 2>/dev/null | grep -i redroid || true)" if ! redroid_binder_mode >/dev/null 2>&1 && ! redroid_binder_is_provisionable; then kv available 0 kv running 0 kv status unavailable kv host "${HOSTNAME_ALIAS}.local" kv port "$REDROID_DEFAULT_PORT" kv url "" kv detail "${binder_detail}; image=$(redroid_resolved_image); default=${default_name}; adb=${HOSTNAME_ALIAS}.local:${REDROID_DEFAULT_PORT}" return 0 fi kv available 1 kv running "$([ -n "$running_line" ] && echo 1 || echo 0)" kv status "$([ -n "$running_line" ] && echo online || echo stopped)" kv host "${HOSTNAME_ALIAS}.local" kv port "$REDROID_DEFAULT_PORT" kv url "" kv detail "${all_lines:-binder=${binder_detail}; image=$(redroid_resolved_image); default=${default_name}; adb=${HOSTNAME_ALIAS}.local:${REDROID_DEFAULT_PORT}; boot=${REDROID_BOOT_ARGS}; expect_gms=${REDROID_EXPECT_GMS}; expect_magisk=${REDROID_EXPECT_MAGISK}}" } service_redroid_start() { if [ "${ENABLE_REDROID:-1}" != "1" ]; then kv message "Set ENABLE_REDROID=1 first" exit 1 fi if ! have docker; then kv message "docker not installed" exit 1 fi local name image data_dir name="$(redroid_default_name)" image="$(redroid_resolved_image)" data_dir="${REDROID_DATA_PREFIX}-${name}" if ! ensure_redroid_host_support; then kv message "Redroid host prerequisites unavailable: $(redroid_support_detail)" exit 1 fi mkdir -p "$data_dir" if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$name"; then if redroid_container_matches_host_support "$name"; then kv message "Redroid already running" exit 0 fi docker rm -f "$name" >/dev/null fi if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qx "$name"; then if ! redroid_container_matches_host_support "$name"; then docker rm -f "$name" >/dev/null else docker start "$name" >/dev/null kv message "Redroid container started" exit 0 fi fi docker run -d \ --name "$name" \ --privileged \ --pull always \ --restart unless-stopped \ -p "${REDROID_DEFAULT_PORT}:5555" \ -v "${data_dir}:/data" \ -e "REDROID_GPU_MODE=${REDROID_GPU_MODE:-guest}" \ -e "REDROID_DEVICE_PROFILE=${REDROID_DEVICE_PROFILE:-pixel-7-pro}" \ $( [ "${REDROID_ADB_INSECURE:-1}" = "1" ] && printf '%s ' '-e' 'REDROID_ADB_INSECURE=1' ) \ $( [ -e /dev/kvm ] && printf '%s ' '--device' '/dev/kvm' ) \ $( [ -d /dev/dri ] && printf '%s ' '-v /dev/dri:/dev/dri' ) \ $( [ -d /dev/dma_heap ] && printf '%s ' '-v /dev/dma_heap:/dev/dma_heap' ) \ $( redroid_binderfs_ready && printf '%s ' '-v' '/dev/binderfs:/dev/binderfs' ) \ $( redroid_legacy_binder_devices_ready && printf '%s ' '--device' '/dev/binder' '--device' '/dev/hwbinder' '--device' '/dev/vndbinder' ) \ $( [ -e /dev/rga ] && printf '%s ' '--device /dev/rga' ) \ $( [ -e /dev/mpp_service ] && printf '%s ' '--device /dev/mpp_service' ) \ "$image" ${REDROID_BOOT_ARGS:-} >/dev/null kv message "Redroid container created" } service_redroid_stop() { local name name="$(redroid_default_name)" if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$name"; then docker stop "$name" >/dev/null kv message "Redroid container stopped" return 0 fi kv message "Redroid container not running" } service_android_gateway_status() { if ! have nmcli; then kv available 0 kv running 0 kv status unavailable kv detail "需要 NetworkManager(nmcli) 才能创建共享连接" return 0 fi if [ -z "${ANDROID_DOWNLINK_IF:-}" ]; then kv available 1 kv running 0 kv status skipped kv detail "单网口或未配置下行口:无需透明网关。安卓可经 USB 网络共享 / Wi‑Fi / 同 LAN 访问板子。仅当板载第二以太网直连安卓板时,在 system-stack.env 设置 ANDROID_DOWNLINK_IF=eth1(或实际接口名)" kv url "" return 0 fi if [ ! -d "/sys/class/net/$ANDROID_DOWNLINK_IF" ]; then kv available 0 kv running 0 kv status unavailable kv detail "Downlink interface $ANDROID_DOWNLINK_IF is missing" return 0 fi local active active="$(nmcli -t -f NAME connection show --active 2>/dev/null | grep "^${ANDROID_GATEWAY_NAME}$" || true)" kv available 1 kv running "$([ -n "$active" ] && echo 1 || echo 0)" kv status "$([ -n "$active" ] && echo online || echo configured)" kv host "${HOSTNAME_ALIAS}.local" kv port "" kv url "" kv detail "uplink=${ANDROID_UPLINK_IF};downlink=${ANDROID_DOWNLINK_IF};gateway=${ANDROID_GATEWAY_CIDR}" } service_android_gateway_start() { if [ -z "${ANDROID_DOWNLINK_IF:-}" ]; then kv message "未配置 ANDROID_DOWNLINK_IF:单网口场景无需启动安卓网关" exit 0 fi if ! nmcli connection show "$ANDROID_GATEWAY_NAME" >/dev/null 2>&1; then nmcli connection add type ethernet ifname "$ANDROID_DOWNLINK_IF" con-name "$ANDROID_GATEWAY_NAME" \ ipv4.method shared ipv4.addresses "$ANDROID_GATEWAY_CIDR" connection.autoconnect yes fi nmcli connection up "$ANDROID_GATEWAY_NAME" kv message "Android gateway started" } service_android_gateway_stop() { nmcli connection down "$ANDROID_GATEWAY_NAME" >/dev/null 2>&1 || true kv message "Android gateway stopped" } dispatch() { local fn="service_${SERVICE_ID}_${ACTION}" if ! declare -F "$fn" >/dev/null 2>&1; then if [ "$ACTION" = "restart" ] && declare -F "service_${SERVICE_ID}_stop" >/dev/null 2>&1 && declare -F "service_${SERVICE_ID}_start" >/dev/null 2>&1; then "service_${SERVICE_ID}_stop" >/dev/null 2>&1 || true "service_${SERVICE_ID}_start" return 0 fi if [ "$ACTION" = "install" ] && declare -F "service_${SERVICE_ID}_start" >/dev/null 2>&1; then "service_${SERVICE_ID}_start" return 0 fi if [ "$ACTION" = "uninstall" ] && declare -F "service_${SERVICE_ID}_stop" >/dev/null 2>&1; then "service_${SERVICE_ID}_stop" >/dev/null 2>&1 || true kv message "Service uninstalled via stop fallback" return 0 fi if [ "$ACTION" = "upgrade" ] && declare -F "service_${SERVICE_ID}_stop" >/dev/null 2>&1 && declare -F "service_${SERVICE_ID}_start" >/dev/null 2>&1; then "service_${SERVICE_ID}_stop" >/dev/null 2>&1 || true "service_${SERVICE_ID}_start" >/dev/null 2>&1 || true kv message "Service upgraded via restart fallback" return 0 fi echo "MESSAGE=Unsupported service/action: ${SERVICE_ID}/${ACTION}" exit 1 fi "$fn" } if [ -z "$SERVICE_ID" ]; then echo "MESSAGE=Missing service id" exit 1 fi dispatch