#!/bin/sh # VDI 云桌面串流启动器(Moonlight 客户端侧) # # 职责: # 1. 读取本地配置(主机、应用、分辨率、帧率、码率、编解码) # 2. 按配置拉起 Moonlight,全屏串流云桌面 # 3. 写心跳文件,供 vdi-kiosk-monitor 判断是否假死 # 4. 检测串流假死(CPU 与网络均无流量)并杀掉重连 # # 被 vdi-kiosk-monitor 守护: # vdi-kiosk-monitor -- /home/armbian/vdi-terminal/bin/vdi-moonlight # # 说明:RK3528 当前没有可用的 V4L2 request/m2m 硬解设备,moonlight-qt 的 # --video-decoder auto 会选到 h264_v4l2m2m 并报 "Could not find a valid device", # 因此系统客户端默认 decoder=software;验证后的独立 RKMPP 客户端 # 通过 VDI_MOONLIGHT_PROGRAM 与 decoder=hardware 显式启用。 set -u CONF="${VDI_MOONLIGHT_CONF:-$HOME/.config/vdi-terminal/moonlight.conf}" HEARTBEAT="${VDI_MOONLIGHT_HEARTBEAT:-/tmp/vdi-moonlight.heartbeat}" PIDFILE="${VDI_MOONLIGHT_PIDFILE:-/tmp/vdi-moonlight.pid}" LAUNCHER_PIDFILE="${VDI_MOONLIGHT_LAUNCHER_PIDFILE:-/tmp/vdi-moonlight-launcher.pid}" LOG_DIR="${VDI_MOONLIGHT_LOG_DIR:-$HOME/vdi-terminal/logs}" LOG_FILE="$LOG_DIR/moonlight-$(date +%Y%m%d).log" ML_PID="" # 默认值,均可被配置文件覆盖 HOST="" PORT="" APP="Desktop" WIDTH=1920 HEIGHT=1080 FPS=60 BITRATE=20000 CODEC="H.264" DECODER="software" DISPLAY_MODE="fullscreen" AUDIO=1 ABSOLUTE_MOUSE=1 VSYNC=1 FRAME_PACING=1 PERFORMANCE_OVERLAY=0 PROFILE="" RETRY=3 RETRY_DELAY=5 HANG_TIMEOUT=90 WAIT_HOST=120 mkdir -p "$LOG_DIR" log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG_FILE" } # 读取 key=value 形式的配置 load_config() { [ -f "$CONF" ] || return 0 while IFS='=' read -r key value; do case "$key" in ''|\#*) continue ;; esac value="${value%\"}"; value="${value#\"}" case "$key" in host) HOST="$value" ;; port) PORT="$value" ;; app) APP="$value" ;; width) WIDTH="$value" ;; height) HEIGHT="$value" ;; fps) FPS="$value" ;; bitrate) BITRATE="$value" ;; codec) CODEC="$value" ;; decoder) DECODER="$value" ;; display-mode) DISPLAY_MODE="$value" ;; audio) AUDIO="$value" ;; absolute-mouse) ABSOLUTE_MOUSE="$value" ;; vsync) VSYNC="$value" ;; frame-pacing) FRAME_PACING="$value" ;; performance-overlay) PERFORMANCE_OVERLAY="$value" ;; profile) PROFILE="$value" ;; retry) RETRY="$value" ;; retry-delay) RETRY_DELAY="$value" ;; hang-timeout) HANG_TIMEOUT="$value" ;; wait-host) WAIT_HOST="$value" ;; esac done < "$CONF" } apply_profile() { case "$PROFILE" in smooth) VSYNC=1 FRAME_PACING=1 ;; low-latency) VSYNC=0 FRAME_PACING=0 ;; diagnostic) VSYNC=1 FRAME_PACING=1 PERFORMANCE_OVERLAY=1 ;; "") # 兼容旧配置:没有profile时保留moonlight.conf中的显式开关。 ;; *) log "未知串流 profile=$PROFILE,回退 smooth" PROFILE=smooth VSYNC=1 FRAME_PACING=1 ;; esac } # 默认路由出接口的累计收字节数,用于判断串流是否还有数据流入 rx_bytes() { iface=$(awk '$2=="00000000" {print $1; exit}' /proc/net/route) [ -n "$iface" ] || iface=lo awk -v i="$iface:" '$1==i {print $2; exit}' /proc/net/dev } # 进程累计 CPU 时间(utime+stime),用于判断串流是否还在解码 cpu_ticks() { pid="$1" [ -r "/proc/$pid/stat" ] || { echo 0; return; } cut -d')' -f2 "/proc/$pid/stat" 2>/dev/null | awk '{print $12+$13}' } host_reachable() { # 用 TCP 探测 Sunshine 的 HTTPS 控制端口。 # 注意:Windows 防火墙默认屏蔽 ICMP,ping 不通不代表服务不可用, # 所以优先做 TCP 连接探测,ping 只作为最后兜底。 probe_port="${PORT:-47984}" if command -v nc >/dev/null 2>&1; then nc -z -w 2 "$HOST" "$probe_port" 2>/dev/null && return 0 return 1 fi if command -v bash >/dev/null 2>&1; then timeout 3 bash -c "exec 3<>/dev/tcp/$HOST/$probe_port" 2>/dev/null && return 0 return 1 fi ping -c 1 -W 2 "$HOST" >/dev/null 2>&1 } kill_stale() { # 清理上次崩溃残留的串流进程,避免孤儿进程抢占显示。 # 顺序很重要:先结束残留的启动器实例(否则它会再拉起一条串流), # 再清理残留的串流进程。 if [ -f "$LAUNCHER_PIDFILE" ]; then old=$(cat "$LAUNCHER_PIDFILE" 2>/dev/null) if [ -n "$old" ] && [ "$old" != "$$" ]; then kill -9 "$old" 2>/dev/null fi rm -f "$LAUNCHER_PIDFILE" fi if [ -f "$PIDFILE" ]; then old=$(cat "$PIDFILE" 2>/dev/null) [ -n "$old" ] && kill -9 "$old" 2>/dev/null rm -f "$PIDFILE" fi # 用 ^ 锚定命令行开头,避免误杀包含该字符串的 shell 包装进程 pkill -f '^moonlight-qt stream' 2>/dev/null sleep 1 } disable_screensaver() { # Kiosk 场景不允许息屏把画面遮掉 command -v gsettings >/dev/null 2>&1 || return 0 gsettings set org.mate.screensaver idle-activation-enabled false 2>/dev/null gsettings set org.mate.screensaver lock-enabled false 2>/dev/null } build_cmd() { # moonlight-qt stream 只接受 位置参数,没有 --port 选项, # 服务端端口由 moonlight 自身按 GameStream 协议协商。 CMD="${VDI_MOONLIGHT_PROGRAM:-moonlight-qt} stream $HOST $APP" CMD="$CMD --resolution ${WIDTH}x${HEIGHT}" CMD="$CMD --fps $FPS" CMD="$CMD --bitrate $BITRATE" CMD="$CMD --display-mode $DISPLAY_MODE" CMD="$CMD --video-codec $CODEC" CMD="$CMD --video-decoder $DECODER" CMD="$CMD --keep-awake" # The kiosk must keep system shortcuts inside the streamed desktop. Without # this, combinations such as Win+Shift+S are handled by the box OS and can # expose its UI over the cloud desktop. Apply this to Windows, Ubuntu and # Kylin sessions alike. CMD="$CMD --capture-system-keys always" [ "$ABSOLUTE_MOUSE" = "1" ] && CMD="$CMD --absolute-mouse" [ "$AUDIO" = "1" ] && CMD="$CMD --audio-on-host" || CMD="$CMD --no-audio-on-host" # 按profile选择同步策略;默认保持当前稳定档。 [ "$VSYNC" = "1" ] && CMD="$CMD --vsync" || CMD="$CMD --no-vsync" [ "$FRAME_PACING" = "1" ] && CMD="$CMD --frame-pacing" || CMD="$CMD --no-frame-pacing" [ "$PERFORMANCE_OVERLAY" = "1" ] && CMD="$CMD --performance-overlay" } run_stream() { build_cmd log "启动串流 profile=${PROFILE:-config} decoder=$DECODER codec=$CODEC vsync=$VSYNC frame-pacing=$FRAME_PACING renderer=${SDL_RENDER_DRIVER:-} video-driver=${SDL_VIDEODRIVER:-}: $CMD" # shellcheck disable=SC2086 $CMD >> "$LOG_FILE" 2>&1 & ML_PID=$! echo "$ML_PID" > "$PIDFILE" # 收到终止信号时把串流进程一起带走,避免留下占用显示的孤儿进程 trap 'if [ -n "$ML_PID" ] && kill -0 "$ML_PID" 2>/dev/null; then kill -TERM "$ML_PID" 2>/dev/null; sleep 1; kill -9 "$ML_PID" 2>/dev/null; fi; rm -f "$PIDFILE"; exit 143' TERM INT last_cpu=$(cpu_ticks "$ML_PID") last_rx=$(rx_bytes) flat_since=$(date +%s) while kill -0 "$ML_PID" 2>/dev/null; do sleep 5 date +%s > "$HEARTBEAT" 2>/dev/null cur_cpu=$(cpu_ticks "$ML_PID") cur_rx=$(rx_bytes) now=$(date +%s) if [ "$cur_cpu" != "$last_cpu" ] || [ "$cur_rx" != "$last_rx" ]; then flat_since=$now elif [ "$HANG_TIMEOUT" -gt 0 ] && [ $((now - flat_since)) -ge "$HANG_TIMEOUT" ]; then log "检测到串流假死(${HANG_TIMEOUT}s 内无 CPU 与网络活动),重启串流" kill -9 "$ML_PID" 2>/dev/null wait "$ML_PID" 2>/dev/null return 3 fi last_cpu=$cur_cpu last_rx=$cur_rx done wait "$ML_PID" code=$? rm -f "$PIDFILE" log "串流进程退出, code=$code" return $code } case "${1:-start}" in status) if pgrep -f 'moonlight-qt stream' >/dev/null 2>&1; then echo "moonlight: running" exit 0 fi echo "moonlight: stopped" exit 1 ;; stop) kill_stale echo "moonlight: stopped" exit 0 ;; esac load_config PROFILE="${VDI_STREAM_PROFILE:-$PROFILE}" apply_profile DECODER="${VDI_MOONLIGHT_DECODER:-$DECODER}" CODEC="${VDI_MOONLIGHT_CODEC:-$CODEC}" case "$DECODER" in software|hardware|auto) ;; *) log "未知解码器 $DECODER,回退 software"; DECODER=software ;; esac case "$CODEC" in H.264|HEVC|AV1|auto) ;; *) log "未知视频编码 $CODEC,回退 H.264"; CODEC=H.264 ;; esac PERFORMANCE_OVERLAY="${VDI_STREAM_STATS:-$PERFORMANCE_OVERLAY}" if [ -z "$HOST" ]; then log "配置缺失或缺少 host: $CONF" exit 2 fi export DISPLAY="${VDI_DISPLAY:-:0}" export XAUTHORITY="${VDI_XAUTHORITY:-$HOME/.Xauthority}" export SDL_RENDER_DRIVER="${VDI_SDL_RENDER_DRIVER:-opengles2}" export SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR="${VDI_SDL_BYPASS_COMPOSITOR:-1}" [ -n "${VDI_SDL_FORCE_EGL:-}" ] && export SDL_VIDEO_X11_FORCE_EGL="$VDI_SDL_FORCE_EGL" [ -n "${VDI_SDL_VIDEODRIVER:-}" ] && export SDL_VIDEODRIVER="$VDI_SDL_VIDEODRIVER" disable_screensaver kill_stale echo "$$" > "$LAUNCHER_PIDFILE" # 等待云桌面主机就绪(开机自启时虚拟机可能还没起来) waited=0 log "等待云桌面主机 $HOST 就绪(最长 ${WAIT_HOST}s)" while [ "$waited" -lt "$WAIT_HOST" ]; do if host_reachable; then log "主机 $HOST 已就绪(等待 ${waited}s)" break fi sleep 5 waited=$((waited + 5)) done if [ "$waited" -ge "$WAIT_HOST" ]; then log "等待主机 $HOST 超时 ${WAIT_HOST}s,仍尝试启动串流" fi attempt=1 while [ "$attempt" -le "$RETRY" ]; do run_stream code=$? if [ "$code" = "0" ]; then log "串流正常结束" exit 0 fi if [ "$code" = "3" ]; then # 假死重启,直接下一轮 attempt=$((attempt + 1)) continue fi log "第 $attempt/$RETRY 次串流失败 (code=$code),${RETRY_DELAY}s 后重试" attempt=$((attempt + 1)) sleep "$RETRY_DELAY" done log "连续 $RETRY 次串流失败,退出,交由 Monitor 重启" exit 1