vdi-cloud/terminal/kiosk-shell/scripts/vdi-moonlight
songyiming a9e42a4084 feat(kiosk-shell): 登录/桌面列表产品流程 + Moonlight 会话 + 看门狗进程组修复
- 登录页改为用户认证(本地演示认证,账号密码非空通过)
- 新增桌面列表页:Windows/Moonlight 可连接,Ubuntu/麒麟待分配
- 新增 DesktopService 适配层(登录/桌面列表/连接票据,后续接 API)
- 新增 MoonlightSession:QProcess 参数数组直启 moonlight-qt,退出回列表
- 状态机扩展 AUTHENTICATING / DESKTOP_LIST
- 移除开机自动 RDP,收敛为 monitor -> kiosk-shell 单一自启链
- Monitor 按进程组结束整棵进程树,修复孤儿串流进程
- vdi-moonlight 启动器新增 vsync/frame-pacing/overlay 低延迟开关
2026-10-28 20:48:12 +08:00

270 lines
8.3 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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