vdi-cloud/terminal/kiosk-shell/scripts/vdi-moonlight

270 lines
8.3 KiB
Plaintext
Raw Normal View History

#!/bin/sh
# VDI 云桌面串流启动器(Moonlight 客户端侧)
#
# 职责:
# 1. 读取本地配置(主机、应用、分辨率、帧率、码率、编解码)
# 2. 以软件解码拉起 moonlight-qt,全屏串流云桌面
# 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。接入 MPP/VPU 硬解后再改回 auto。
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=0
FRAME_PACING=0
PERFORMANCE_OVERLAY=0
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" ;;
retry) RETRY="$value" ;;
retry-delay) RETRY_DELAY="$value" ;;
hang-timeout) HANG_TIMEOUT="$value" ;;
wait-host) WAIT_HOST="$value" ;;
esac
done < "$CONF"
}
# 默认路由出接口的累计收字节数,用于判断串流是否还有数据流入
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 只接受 <host> <app> 位置参数,没有 --port 选项,
# 服务端端口由 moonlight 自身按 GameStream 协议协商。
CMD="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"
[ "$ABSOLUTE_MOUSE" = "1" ] && CMD="$CMD --absolute-mouse"
[ "$AUDIO" = "1" ] && CMD="$CMD --audio-on-host" || CMD="$CMD --no-audio-on-host"
# 低延迟开关:默认关闭 vsync 与 frame-pacing,削减帧队列/呈现延迟
[ "$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 "启动串流: $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
if [ -z "$HOST" ]; then
log "配置缺失或缺少 host: $CONF"
exit 2
fi
export DISPLAY="${VDI_DISPLAY:-:0}"
export XAUTHORITY="${VDI_XAUTHORITY:-$HOME/.Xauthority}"
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