功能:实现桌面选择流程并接入 Moonlight 会话
变更详情: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 低延迟开关
This commit is contained in:
parent
f74d3ceadf
commit
61127fb714
@ -23,6 +23,9 @@ add_executable(kiosk-shell
|
|||||||
src/main.cpp
|
src/main.cpp
|
||||||
src/main_window.cpp
|
src/main_window.cpp
|
||||||
src/login_page.cpp
|
src/login_page.cpp
|
||||||
|
src/desktop_list_page.cpp
|
||||||
|
src/desktop_service.cpp
|
||||||
|
src/moonlight_session.cpp
|
||||||
src/state_machine.cpp
|
src/state_machine.cpp
|
||||||
src/config.cpp
|
src/config.cpp
|
||||||
src/rdp_session.cpp
|
src/rdp_session.cpp
|
||||||
@ -39,4 +42,17 @@ target_link_libraries(kiosk-shell PRIVATE
|
|||||||
PkgConfig::FREERDP
|
PkgConfig::FREERDP
|
||||||
)
|
)
|
||||||
|
|
||||||
install(TARGETS kiosk-shell RUNTIME DESTINATION bin)
|
# 看门狗 Monitor:独立进程,监控主进程假死/崩溃
|
||||||
|
add_executable(vdi-kiosk-monitor
|
||||||
|
src/monitor_main.cpp
|
||||||
|
src/monitor.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(vdi-kiosk-monitor PRIVATE
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||||
|
)
|
||||||
|
|
||||||
|
find_package(Threads REQUIRED)
|
||||||
|
target_link_libraries(vdi-kiosk-monitor PRIVATE Threads::Threads)
|
||||||
|
|
||||||
|
install(TARGETS kiosk-shell vdi-kiosk-monitor RUNTIME DESTINATION bin)
|
||||||
|
|||||||
39
terminal/kiosk-shell/etc/moonlight.conf
Normal file
39
terminal/kiosk-shell/etc/moonlight.conf
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# VDI 云桌面串流参数
|
||||||
|
#
|
||||||
|
# 本文件当前仅供 scripts/vdi-moonlight 调试脚本使用;
|
||||||
|
# 产品流程中 kiosk-shell 的 MoonlightSession 由连接票据直接传参,不读本文件。
|
||||||
|
#
|
||||||
|
# host/port Sunshine 服务端地址与端口(当前联调环境为 VM 100)
|
||||||
|
# app 应用名。用名称而不是 app id:Sunshine 重装或升级后 app id 会变。
|
||||||
|
# width/height 分辨率,需与服务端显示模式匹配(当前虚拟显示器 1920x1080@60Hz)
|
||||||
|
# fps 目标帧率
|
||||||
|
# bitrate 码率,单位 Kbps
|
||||||
|
# codec 视频编码:H.264 / HEVC / AV1 / auto
|
||||||
|
# decoder 解码器:software / hardware / auto
|
||||||
|
# RK3528 当前没有可用的 V4L2 request/m2m 设备,auto 会选到
|
||||||
|
# h264_v4l2m2m 并报 "Could not find a valid device",故用 software。
|
||||||
|
# vsync 1=开启垂直同步(--vsync),0=关闭(--no-vsync)
|
||||||
|
# 关闭可降低约 50ms 的呈现/VSync 延迟,可能引入撕裂
|
||||||
|
# frame-pacing 1=开启帧率匀速(--frame-pacing),0=关闭(--no-frame-pacing)
|
||||||
|
# 关闭可降低帧队列延迟,可能造成节奏不均
|
||||||
|
# performance-overlay 1=显示性能叠图(调试用)
|
||||||
|
# hang-timeout 串流假死判定时间(秒),0 表示关闭该检测
|
||||||
|
host=192.168.1.70
|
||||||
|
port=47984
|
||||||
|
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
|
||||||
269
terminal/kiosk-shell/scripts/vdi-moonlight
Normal file
269
terminal/kiosk-shell/scripts/vdi-moonlight
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
#!/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
|
||||||
@ -92,6 +92,10 @@ TerminalConfig loadConfig() {
|
|||||||
return cfg;
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool hasConnectionConfig(const TerminalConfig& cfg) {
|
||||||
|
return !cfg.server.empty() && !cfg.username.empty();
|
||||||
|
}
|
||||||
|
|
||||||
bool saveConfig(const TerminalConfig& cfg) {
|
bool saveConfig(const TerminalConfig& cfg) {
|
||||||
std::string dir = configDir();
|
std::string dir = configDir();
|
||||||
mkdir(dir.c_str(), 0755);
|
mkdir(dir.c_str(), 0755);
|
||||||
|
|||||||
@ -39,6 +39,9 @@ std::string configPath();
|
|||||||
// 读取配置;文件不存在时返回默认值
|
// 读取配置;文件不存在时返回默认值
|
||||||
TerminalConfig loadConfig();
|
TerminalConfig loadConfig();
|
||||||
|
|
||||||
|
// 判断是否已经有 Agent/API 下发的可用桌面连接配置。
|
||||||
|
bool hasConnectionConfig(const TerminalConfig& cfg);
|
||||||
|
|
||||||
// 保存配置
|
// 保存配置
|
||||||
bool saveConfig(const TerminalConfig& cfg);
|
bool saveConfig(const TerminalConfig& cfg);
|
||||||
|
|
||||||
|
|||||||
184
terminal/kiosk-shell/src/desktop_list_page.cpp
Normal file
184
terminal/kiosk-shell/src/desktop_list_page.cpp
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
#include "desktop_list_page.h"
|
||||||
|
|
||||||
|
#include <QFont>
|
||||||
|
#include <QFrame>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QScrollArea>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
namespace vdi {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// 在信息行里加一个拉伸占位(独立函数避免在类外重复声明)
|
||||||
|
void addStretchToRow(QHBoxLayout* row) {
|
||||||
|
row->addStretch(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
DesktopListPage::DesktopListPage(QWidget* parent) : QWidget(parent) {
|
||||||
|
auto* root = new QVBoxLayout(this);
|
||||||
|
root->setContentsMargins(80, 50, 80, 40);
|
||||||
|
root->setSpacing(16);
|
||||||
|
|
||||||
|
// ---- 标题区 ----
|
||||||
|
auto* title = new QLabel(QStringLiteral("选择云桌面"), this);
|
||||||
|
QFont titleFont = title->font();
|
||||||
|
titleFont.setPointSize(26);
|
||||||
|
titleFont.setBold(true);
|
||||||
|
title->setFont(titleFont);
|
||||||
|
title->setAlignment(Qt::AlignCenter);
|
||||||
|
root->addWidget(title);
|
||||||
|
|
||||||
|
auto* infoRow = new QHBoxLayout();
|
||||||
|
labelUser_ = new QLabel(this);
|
||||||
|
labelUser_->setStyleSheet(QStringLiteral("color: #8a94a6;"));
|
||||||
|
buttonLogout_ = new QPushButton(QStringLiteral("退出登录"), this);
|
||||||
|
buttonLogout_->setMinimumSize(110, 38);
|
||||||
|
infoRow->addWidget(labelUser_);
|
||||||
|
addStretchToRow(infoRow);
|
||||||
|
infoRow->addWidget(buttonLogout_);
|
||||||
|
root->addLayout(infoRow);
|
||||||
|
|
||||||
|
// ---- 卡片区 ----
|
||||||
|
auto* scroll = new QScrollArea(this);
|
||||||
|
scroll->setWidgetResizable(true);
|
||||||
|
scroll->setFrameShape(QFrame::NoFrame);
|
||||||
|
scroll->setStyleSheet(QStringLiteral(
|
||||||
|
"QScrollArea { background: transparent; }"
|
||||||
|
"QWidget { background: transparent; }"));
|
||||||
|
|
||||||
|
auto* cardsHost = new QWidget(scroll);
|
||||||
|
cardsLayout_ = new QVBoxLayout(cardsHost);
|
||||||
|
cardsLayout_->setSpacing(14);
|
||||||
|
cardsLayout_->addStretch(1);
|
||||||
|
scroll->setWidget(cardsHost);
|
||||||
|
root->addWidget(scroll, 1);
|
||||||
|
|
||||||
|
// ---- 状态条 ----
|
||||||
|
labelStatus_ = new QLabel(this);
|
||||||
|
labelStatus_->setAlignment(Qt::AlignCenter);
|
||||||
|
labelStatus_->setStyleSheet(QStringLiteral("color: #8a94a6;"));
|
||||||
|
root->addWidget(labelStatus_);
|
||||||
|
|
||||||
|
connect(buttonLogout_, &QPushButton::clicked, this, [this]() {
|
||||||
|
if (!busy_) {
|
||||||
|
emit logoutRequested();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void DesktopListPage::setDesktops(const std::vector<DesktopInfo>& desktops) {
|
||||||
|
desktops_ = desktops;
|
||||||
|
rebuildCards();
|
||||||
|
}
|
||||||
|
|
||||||
|
void DesktopListPage::rebuildCards() {
|
||||||
|
// 清空旧卡片(最后一项是 stretch)
|
||||||
|
while (cardsLayout_->count() > 1) {
|
||||||
|
QLayoutItem* item = cardsLayout_->takeAt(0);
|
||||||
|
if (item->widget()) {
|
||||||
|
item->widget()->deleteLater();
|
||||||
|
}
|
||||||
|
delete item;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const DesktopInfo& d : desktops_) {
|
||||||
|
auto* card = new QFrame(this);
|
||||||
|
card->setMinimumHeight(96);
|
||||||
|
card->setStyleSheet(QStringLiteral(
|
||||||
|
"QFrame { background: #1a2233; border: 1px solid #2b3654; border-radius: 10px; }"
|
||||||
|
"QFrame:hover { border: 1px solid #2f6feb; }"
|
||||||
|
"QLabel { border: none; background: transparent; }"));
|
||||||
|
|
||||||
|
auto* row = new QHBoxLayout(card);
|
||||||
|
row->setContentsMargins(24, 16, 24, 16);
|
||||||
|
row->setSpacing(18);
|
||||||
|
|
||||||
|
auto* textCol = new QVBoxLayout();
|
||||||
|
textCol->setSpacing(4);
|
||||||
|
auto* name = new QLabel(d.name, card);
|
||||||
|
QFont nameFont = name->font();
|
||||||
|
nameFont.setPointSize(15);
|
||||||
|
nameFont.setBold(true);
|
||||||
|
name->setFont(nameFont);
|
||||||
|
auto* osLine = new QLabel(d.os, card);
|
||||||
|
osLine->setStyleSheet(QStringLiteral("color: #8a94a6;"));
|
||||||
|
textCol->addWidget(name);
|
||||||
|
textCol->addWidget(osLine);
|
||||||
|
row->addLayout(textCol, 1);
|
||||||
|
|
||||||
|
// 状态/协议标签
|
||||||
|
QString stateText;
|
||||||
|
QString stateColor;
|
||||||
|
switch (d.state) {
|
||||||
|
case DesktopState::kAvailable:
|
||||||
|
stateText = QStringLiteral("%1 · 可连接").arg(
|
||||||
|
QString::fromLatin1(protocolToString(d.protocol)).toUpper());
|
||||||
|
stateColor = QStringLiteral("#3fb950");
|
||||||
|
break;
|
||||||
|
case DesktopState::kPending:
|
||||||
|
stateText = QStringLiteral("任务进行中");
|
||||||
|
stateColor = QStringLiteral("#d29922");
|
||||||
|
break;
|
||||||
|
case DesktopState::kUnassigned:
|
||||||
|
stateText = QStringLiteral("待分配");
|
||||||
|
stateColor = QStringLiteral("#8a94a6");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
auto* state = new QLabel(stateText, card);
|
||||||
|
state->setStyleSheet(QStringLiteral("color: %1; font-weight: bold;").arg(stateColor));
|
||||||
|
row->addWidget(state);
|
||||||
|
|
||||||
|
if (d.state == DesktopState::kAvailable) {
|
||||||
|
auto* connectBtn = new QPushButton(QStringLiteral("连接"), card);
|
||||||
|
connectBtn->setMinimumSize(110, 42);
|
||||||
|
connectBtn->setStyleSheet(QStringLiteral(
|
||||||
|
"QPushButton { background: #2f6feb; color: white; border: none; "
|
||||||
|
"border-radius: 6px; font-weight: bold; }"
|
||||||
|
"QPushButton:disabled { background: #3a4a63; }"));
|
||||||
|
connect(connectBtn, &QPushButton::clicked, this, [this, id = d.id]() {
|
||||||
|
if (!busy_) {
|
||||||
|
setStatus(QStringLiteral("正在连接,请稍候…"));
|
||||||
|
emit desktopSelected(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
row->addWidget(connectBtn);
|
||||||
|
} else {
|
||||||
|
auto* btn = new QPushButton(QStringLiteral("连接"), card);
|
||||||
|
btn->setMinimumSize(110, 42);
|
||||||
|
btn->setEnabled(false);
|
||||||
|
row->addWidget(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
cardsLayout_->insertWidget(cardsLayout_->count() - 1, card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DesktopListPage::setStatus(const QString& text, bool error) {
|
||||||
|
labelStatus_->setText(text);
|
||||||
|
labelStatus_->setStyleSheet(
|
||||||
|
QStringLiteral("color: %1;").arg(error ? QStringLiteral("#e05561")
|
||||||
|
: QStringLiteral("#8a94a6")));
|
||||||
|
}
|
||||||
|
|
||||||
|
void DesktopListPage::setBusy(bool busy) {
|
||||||
|
busy_ = busy;
|
||||||
|
buttonLogout_->setEnabled(!busy);
|
||||||
|
// 连接按钮在 rebuildCards 时根据 busy_ 生成;这里统一刷新点击行为由 busy_ 拦截
|
||||||
|
}
|
||||||
|
|
||||||
|
QString DesktopListPage::currentUser() const {
|
||||||
|
return currentUser_;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DesktopListPage::setCurrentUser(const QString& user) {
|
||||||
|
currentUser_ = user;
|
||||||
|
labelUser_->setText(user.isEmpty()
|
||||||
|
? QStringLiteral("当前用户:未登录")
|
||||||
|
: QStringLiteral("当前用户:%1").arg(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace vdi
|
||||||
57
terminal/kiosk-shell/src/desktop_list_page.h
Normal file
57
terminal/kiosk-shell/src/desktop_list_page.h
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
// 桌面列表页
|
||||||
|
//
|
||||||
|
// 登录成功后展示当前用户可用的云桌面卡片;
|
||||||
|
// 选择桌面后由 MainWindow 获取连接票据并启动对应协议会话;
|
||||||
|
// 会话退出后返回本页。
|
||||||
|
|
||||||
|
#ifndef KIOSK_SHELL_DESKTOP_LIST_PAGE_H
|
||||||
|
#define KIOSK_SHELL_DESKTOP_LIST_PAGE_H
|
||||||
|
|
||||||
|
#include <QWidget>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "desktop_session.h"
|
||||||
|
|
||||||
|
class QLabel;
|
||||||
|
class QPushButton;
|
||||||
|
class QVBoxLayout;
|
||||||
|
|
||||||
|
namespace vdi {
|
||||||
|
|
||||||
|
class DesktopListPage : public QWidget {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit DesktopListPage(QWidget* parent = nullptr);
|
||||||
|
|
||||||
|
// 用桌面数据刷新卡片
|
||||||
|
void setDesktops(const std::vector<DesktopInfo>& desktops);
|
||||||
|
|
||||||
|
void setStatus(const QString& text, bool error = false);
|
||||||
|
void setBusy(bool busy);
|
||||||
|
|
||||||
|
QString currentUser() const;
|
||||||
|
void setCurrentUser(const QString& user);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
// 用户点击一张可用桌面卡片
|
||||||
|
void desktopSelected(const QString& desktopId);
|
||||||
|
// 用户点击退出登录
|
||||||
|
void logoutRequested();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void rebuildCards();
|
||||||
|
|
||||||
|
std::vector<DesktopInfo> desktops_;
|
||||||
|
QString currentUser_;
|
||||||
|
|
||||||
|
QLabel* labelUser_ = nullptr;
|
||||||
|
QLabel* labelStatus_ = nullptr;
|
||||||
|
QVBoxLayout* cardsLayout_ = nullptr;
|
||||||
|
QPushButton* buttonLogout_ = nullptr;
|
||||||
|
bool busy_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace vdi
|
||||||
|
|
||||||
|
#endif // KIOSK_SHELL_DESKTOP_LIST_PAGE_H
|
||||||
97
terminal/kiosk-shell/src/desktop_service.cpp
Normal file
97
terminal/kiosk-shell/src/desktop_service.cpp
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
#include "desktop_service.h"
|
||||||
|
|
||||||
|
namespace vdi {
|
||||||
|
|
||||||
|
void DesktopService::login(const QString& username, const QString& password, AuthCallback callback) {
|
||||||
|
// 本地演示认证:账号与密码非空即通过。
|
||||||
|
// 正式版本替换为 /api/v1/terminal/session/login(SN+用户身份校验)。
|
||||||
|
AuthResult result;
|
||||||
|
if (username.trimmed().isEmpty() || password.isEmpty()) {
|
||||||
|
result.ok = false;
|
||||||
|
result.message = QStringLiteral("请输入用户名和密码");
|
||||||
|
} else {
|
||||||
|
result.ok = true;
|
||||||
|
result.displayName = username.trimmed();
|
||||||
|
currentUser_ = username.trimmed();
|
||||||
|
}
|
||||||
|
if (callback) {
|
||||||
|
callback(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DesktopService::listDesktops(DesktopListCallback callback) {
|
||||||
|
// 本地演示数据:Windows 可用(Moonlight),Ubuntu/麒麟尚未分配连接信息。
|
||||||
|
// 正式版本替换为 /api/v1/terminal/session/desktops(按用户绑定返回)。
|
||||||
|
std::vector<DesktopInfo> desktops;
|
||||||
|
|
||||||
|
DesktopInfo windows;
|
||||||
|
windows.id = QStringLiteral("desk-win10-01");
|
||||||
|
windows.name = QStringLiteral("Windows 10");
|
||||||
|
windows.os = QStringLiteral("Windows 10 22H2 专业版");
|
||||||
|
windows.protocol = Protocol::kMoonlight;
|
||||||
|
windows.state = DesktopState::kAvailable;
|
||||||
|
windows.endpoint = QStringLiteral("192.168.1.70");
|
||||||
|
windows.app = QStringLiteral("Desktop");
|
||||||
|
desktops.push_back(windows);
|
||||||
|
|
||||||
|
DesktopInfo ubuntu;
|
||||||
|
ubuntu.id = QStringLiteral("desk-ubuntu-01");
|
||||||
|
ubuntu.name = QStringLiteral("Ubuntu");
|
||||||
|
ubuntu.os = QStringLiteral("Ubuntu 桌面");
|
||||||
|
ubuntu.protocol = Protocol::kRdp;
|
||||||
|
ubuntu.state = DesktopState::kUnassigned;
|
||||||
|
desktops.push_back(ubuntu);
|
||||||
|
|
||||||
|
DesktopInfo kylin;
|
||||||
|
kylin.id = QStringLiteral("desk-kylin-01");
|
||||||
|
kylin.name = QStringLiteral("麒麟");
|
||||||
|
kylin.os = QStringLiteral("银河麒麟桌面");
|
||||||
|
kylin.protocol = Protocol::kRdp;
|
||||||
|
kylin.state = DesktopState::kUnassigned;
|
||||||
|
desktops.push_back(kylin);
|
||||||
|
|
||||||
|
if (callback) {
|
||||||
|
callback(std::move(desktops), QString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DesktopService::createTicket(const QString& desktopId, TicketCallback callback) {
|
||||||
|
// 本地演示票据:直接由桌面列表数据构造。
|
||||||
|
// 正式版本替换为 /api/v1/terminal/session/connect-ticket(短期一次性票据)。
|
||||||
|
ConnectionTicket ticket;
|
||||||
|
ticket.desktopId = desktopId;
|
||||||
|
|
||||||
|
std::vector<DesktopInfo> desktops;
|
||||||
|
QString error;
|
||||||
|
listDesktops([&desktops, &error](std::vector<DesktopInfo> list, const QString& err) {
|
||||||
|
desktops = std::move(list);
|
||||||
|
error = err;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const DesktopInfo& d : desktops) {
|
||||||
|
if (d.id == desktopId) {
|
||||||
|
if (d.state != DesktopState::kAvailable) {
|
||||||
|
error = QStringLiteral("桌面 %1 尚未分配连接信息").arg(d.name);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ticket.protocol = d.protocol;
|
||||||
|
ticket.endpoint = d.endpoint;
|
||||||
|
ticket.app = d.app;
|
||||||
|
ticket.width = d.width;
|
||||||
|
ticket.height = d.height;
|
||||||
|
ticket.fps = d.fps;
|
||||||
|
error.clear();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callback) {
|
||||||
|
callback(ticket, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DesktopService::logout() {
|
||||||
|
currentUser_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace vdi
|
||||||
45
terminal/kiosk-shell/src/desktop_service.h
Normal file
45
terminal/kiosk-shell/src/desktop_service.h
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
// 桌面服务适配层
|
||||||
|
//
|
||||||
|
// 职责:登录认证、桌面列表、连接票据。
|
||||||
|
// 当前为本地演示实现(账号非空即通过;Windows 桌面走 Moonlight,
|
||||||
|
// Ubuntu/麒麟未分配连接信息)。后续接入管理平台时替换/扩展本类为
|
||||||
|
// API 适配器(/api/v1/terminal/session/login|desktops|connect-ticket),
|
||||||
|
// 页面与状态机不感知数据来源变化。
|
||||||
|
|
||||||
|
#ifndef KIOSK_SHELL_DESKTOP_SERVICE_H
|
||||||
|
#define KIOSK_SHELL_DESKTOP_SERVICE_H
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
#include <functional>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "desktop_session.h"
|
||||||
|
|
||||||
|
namespace vdi {
|
||||||
|
|
||||||
|
class DesktopService {
|
||||||
|
public:
|
||||||
|
using AuthCallback = std::function<void(const AuthResult&)>;
|
||||||
|
using DesktopListCallback = std::function<void(std::vector<DesktopInfo>, const QString& error)>;
|
||||||
|
using TicketCallback = std::function<void(const ConnectionTicket&, const QString& error)>;
|
||||||
|
|
||||||
|
// 异步认证(本地实现立即回调;API 版本将走 HTTPS)
|
||||||
|
void login(const QString& username, const QString& password, AuthCallback callback);
|
||||||
|
|
||||||
|
// 拉取当前用户可用的桌面列表
|
||||||
|
void listDesktops(DesktopListCallback callback);
|
||||||
|
|
||||||
|
// 为指定桌面申请连接票据
|
||||||
|
void createTicket(const QString& desktopId, TicketCallback callback);
|
||||||
|
|
||||||
|
void logout();
|
||||||
|
|
||||||
|
QString currentUser() const { return currentUser_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString currentUser_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace vdi
|
||||||
|
|
||||||
|
#endif // KIOSK_SHELL_DESKTOP_SERVICE_H
|
||||||
70
terminal/kiosk-shell/src/desktop_session.h
Normal file
70
terminal/kiosk-shell/src/desktop_session.h
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
// 桌面与会话领域模型
|
||||||
|
//
|
||||||
|
// 对应产品流程:
|
||||||
|
// 登录 -> 桌面列表 -> 选择桌面 -> 获取连接票据 -> 启动 RDP/Moonlight 会话
|
||||||
|
//
|
||||||
|
// 当前阶段由本地 DesktopService 提供演示数据;后续接入管理平台后,
|
||||||
|
// 由 API 适配器返回同样的模型,页面与状态机不感知数据来源。
|
||||||
|
|
||||||
|
#ifndef KIOSK_SHELL_DESKTOP_SESSION_H
|
||||||
|
#define KIOSK_SHELL_DESKTOP_SESSION_H
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace vdi {
|
||||||
|
|
||||||
|
enum class Protocol {
|
||||||
|
kRdp,
|
||||||
|
kMoonlight,
|
||||||
|
};
|
||||||
|
|
||||||
|
inline const char* protocolToString(Protocol p) {
|
||||||
|
return p == Protocol::kRdp ? "rdp" : "moonlight";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 桌面卡片的运行状态
|
||||||
|
enum class DesktopState {
|
||||||
|
kAvailable, // 可连接
|
||||||
|
kPending, // 任务进行中(开机/重置等)
|
||||||
|
kUnassigned, // 尚未分配连接地址/协议,暂不可连接
|
||||||
|
};
|
||||||
|
|
||||||
|
struct DesktopInfo {
|
||||||
|
QString id;
|
||||||
|
QString name; // 显示名,如 "Windows 10"
|
||||||
|
QString os; // 操作系统描述
|
||||||
|
Protocol protocol = Protocol::kMoonlight;
|
||||||
|
DesktopState state = DesktopState::kUnassigned;
|
||||||
|
|
||||||
|
// 连接参数(kUnassigned 时为空)
|
||||||
|
QString endpoint; // 主机地址
|
||||||
|
QString app; // Moonlight 应用名
|
||||||
|
int width = 1920;
|
||||||
|
int height = 1080;
|
||||||
|
int fps = 60;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 一次会话的连接凭据;当前为本地构造,后续由平台 connect-ticket 接口下发
|
||||||
|
struct ConnectionTicket {
|
||||||
|
QString desktopId;
|
||||||
|
Protocol protocol = Protocol::kMoonlight;
|
||||||
|
QString endpoint;
|
||||||
|
QString app;
|
||||||
|
int width = 1920;
|
||||||
|
int height = 1080;
|
||||||
|
int fps = 60;
|
||||||
|
QString username;
|
||||||
|
QString password;
|
||||||
|
int rdpPort = 3389;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct AuthResult {
|
||||||
|
bool ok = false;
|
||||||
|
QString message;
|
||||||
|
QString displayName; // 登录用户显示名
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace vdi
|
||||||
|
|
||||||
|
#endif // KIOSK_SHELL_DESKTOP_SESSION_H
|
||||||
@ -1,7 +1,6 @@
|
|||||||
#include "login_page.h"
|
#include "login_page.h"
|
||||||
|
|
||||||
#include <QCheckBox>
|
#include <QFile>
|
||||||
#include <QComboBox>
|
|
||||||
#include <QFont>
|
#include <QFont>
|
||||||
#include <QGridLayout>
|
#include <QGridLayout>
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
@ -9,35 +8,75 @@
|
|||||||
#include <QLineEdit>
|
#include <QLineEdit>
|
||||||
#include <QProcess>
|
#include <QProcess>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QTextStream>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
namespace vdi {
|
namespace vdi {
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
const char* kResolutionIds[] = {"1920x1440", "2560x1440", "1920x1080", "1280x720"};
|
QString defaultRouteInterface();
|
||||||
|
QString interfaceIPv4(const QString& dev);
|
||||||
|
|
||||||
QString detectNetworkSummary() {
|
QString detectNetworkSummary() {
|
||||||
// 取默认路由的出接口与该接口上的 IPv4 地址
|
// 取默认路由的出接口与该接口上的 IPv4 地址
|
||||||
|
// 用 QFile 读 /proc,避免 shell 引号在不同终端下的转义问题
|
||||||
|
const QString dev = defaultRouteInterface();
|
||||||
|
if (dev.isEmpty()) {
|
||||||
|
return QStringLiteral("网络:未连接");
|
||||||
|
}
|
||||||
|
const QString addr = interfaceIPv4(dev);
|
||||||
|
if (addr.isEmpty()) {
|
||||||
|
return QStringLiteral("网络:%1").arg(dev);
|
||||||
|
}
|
||||||
|
return QStringLiteral("网络:%1 (%2)").arg(dev, addr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 /proc/net/route,找到默认路由(Destination 为 0.0.0.0)的出接口
|
||||||
|
QString defaultRouteInterface() {
|
||||||
|
QFile file(QStringLiteral("/proc/net/route"));
|
||||||
|
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
QTextStream stream(&file);
|
||||||
|
QString header = stream.readLine();
|
||||||
|
while (!stream.atEnd()) {
|
||||||
|
const QString line = stream.readLine();
|
||||||
|
const QStringList parts = line.split(QRegularExpression(QStringLiteral("\\s+")),
|
||||||
|
Qt::SkipEmptyParts);
|
||||||
|
// Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
|
||||||
|
if (parts.size() < 4) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (parts[1] == QStringLiteral("00000000")) {
|
||||||
|
return parts[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用 ip 命令取该接口的 IPv4
|
||||||
|
QString interfaceIPv4(const QString& dev) {
|
||||||
QProcess process;
|
QProcess process;
|
||||||
process.start("sh", QStringList() << "-c" <<
|
process.start(QStringLiteral("ip"),
|
||||||
"dev=$(ip -4 route show default | awk '{print $NF}' | head -n 1); "
|
QStringList() << QStringLiteral("-4") << QStringLiteral("-o") << QStringLiteral("addr")
|
||||||
"addr=$(ip -4 -o addr show dev \"$dev\" | awk '{print $4}' | head -n 1); "
|
<< QStringLiteral("show") << QStringLiteral("dev") << dev);
|
||||||
"echo \"$dev $addr\"");
|
if (!process.waitForFinished(2000)) {
|
||||||
process.waitForFinished(2000);
|
return QString();
|
||||||
const QString out = QString::fromUtf8(process.readAllStandardOutput()).trimmed();
|
|
||||||
if (out.isEmpty()) {
|
|
||||||
return QStringLiteral("网络:未连接");
|
|
||||||
}
|
}
|
||||||
const QStringList parts = out.split(' ', Qt::SkipEmptyParts);
|
const QString out = QString::fromUtf8(process.readAllStandardOutput());
|
||||||
QString iface = parts.value(0);
|
for (const QString& line : out.split('\n')) {
|
||||||
QString addr = parts.value(1);
|
const QStringList parts = line.split(QRegularExpression(QStringLiteral("\\s+")),
|
||||||
if (addr.contains('/')) {
|
Qt::SkipEmptyParts);
|
||||||
addr = addr.split('/').value(0);
|
if (parts.size() >= 4 && parts[0] == dev) {
|
||||||
|
QString addr = parts[3];
|
||||||
|
if (addr.contains('/')) {
|
||||||
|
addr = addr.split('/').value(0);
|
||||||
|
}
|
||||||
|
return addr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (iface.isEmpty()) {
|
return QString();
|
||||||
return QStringLiteral("网络:未连接");
|
|
||||||
}
|
|
||||||
return QStringLiteral("网络:%1 (%2)").arg(iface, addr);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
@ -57,7 +96,7 @@ LoginPage::LoginPage(const TerminalConfig& initial, QWidget* parent)
|
|||||||
title->setAlignment(Qt::AlignCenter);
|
title->setAlignment(Qt::AlignCenter);
|
||||||
root->addWidget(title);
|
root->addWidget(title);
|
||||||
|
|
||||||
auto* subtitle = new QLabel(QStringLiteral("RK3528 远程桌面接入"), this);
|
auto* subtitle = new QLabel(QStringLiteral("登录后进入您的云桌面"), this);
|
||||||
QFont subFont = subtitle->font();
|
QFont subFont = subtitle->font();
|
||||||
subFont.setPointSize(13);
|
subFont.setPointSize(13);
|
||||||
subtitle->setFont(subFont);
|
subtitle->setFont(subFont);
|
||||||
@ -76,39 +115,25 @@ LoginPage::LoginPage(const TerminalConfig& initial, QWidget* parent)
|
|||||||
infoBox->addWidget(labelNetwork_);
|
infoBox->addWidget(labelNetwork_);
|
||||||
root->addLayout(infoBox);
|
root->addLayout(infoBox);
|
||||||
|
|
||||||
// ---- 表单 ----
|
// ---- 登录表单 ----
|
||||||
auto* grid = new QGridLayout();
|
auto* grid = new QGridLayout();
|
||||||
grid->setHorizontalSpacing(14);
|
grid->setHorizontalSpacing(14);
|
||||||
grid->setVerticalSpacing(12);
|
grid->setVerticalSpacing(12);
|
||||||
grid->setColumnStretch(1, 1);
|
grid->setColumnStretch(1, 1);
|
||||||
|
|
||||||
editServer_ = new QLineEdit(QString::fromStdString(cfg_.server), this);
|
editUser_ = new QLineEdit(this);
|
||||||
editServer_->setPlaceholderText(QStringLiteral("服务器地址,例如 192.168.1.70"));
|
editUser_->setPlaceholderText(QStringLiteral("用户名"));
|
||||||
editUser_ = new QLineEdit(QString::fromStdString(cfg_.username), this);
|
editPassword_ = new QLineEdit(this);
|
||||||
editUser_->setPlaceholderText(QStringLiteral("登录账号"));
|
|
||||||
editPassword_ = new QLineEdit(QString::fromStdString(cfg_.password), this);
|
|
||||||
editPassword_->setEchoMode(QLineEdit::Password);
|
editPassword_->setEchoMode(QLineEdit::Password);
|
||||||
editPassword_->setPlaceholderText(QStringLiteral("登录密码(可留空)"));
|
editPassword_->setPlaceholderText(QStringLiteral("密码"));
|
||||||
|
|
||||||
comboResolution_ = new QComboBox(this);
|
|
||||||
for (const char* id : kResolutionIds) {
|
|
||||||
comboResolution_->addItem(QString::fromLatin1(id), QString::fromLatin1(id));
|
|
||||||
}
|
|
||||||
int resIndex = comboResolution_->findData(QString::fromStdString(cfg_.resolution));
|
|
||||||
comboResolution_->setCurrentIndex(resIndex >= 0 ? resIndex : 0);
|
|
||||||
|
|
||||||
checkRemember_ = new QCheckBox(QStringLiteral("记住连接参数"), this);
|
|
||||||
checkRemember_->setChecked(cfg_.remember);
|
|
||||||
|
|
||||||
struct Row {
|
struct Row {
|
||||||
QString label;
|
QString label;
|
||||||
QWidget* widget;
|
QWidget* widget;
|
||||||
};
|
};
|
||||||
const Row rows[] = {
|
const Row rows[] = {
|
||||||
{QStringLiteral("服务器地址"), editServer_},
|
{QStringLiteral("用户名"), editUser_},
|
||||||
{QStringLiteral("登录账号"), editUser_},
|
{QStringLiteral("密码"), editPassword_},
|
||||||
{QStringLiteral("登录密码"), editPassword_},
|
|
||||||
{QStringLiteral("显示分辨率"), comboResolution_},
|
|
||||||
};
|
};
|
||||||
int row = 0;
|
int row = 0;
|
||||||
for (const Row& r : rows) {
|
for (const Row& r : rows) {
|
||||||
@ -118,84 +143,76 @@ LoginPage::LoginPage(const TerminalConfig& initial, QWidget* parent)
|
|||||||
grid->addWidget(r.widget, row, 1);
|
grid->addWidget(r.widget, row, 1);
|
||||||
++row;
|
++row;
|
||||||
}
|
}
|
||||||
grid->addWidget(checkRemember_, row, 1);
|
|
||||||
root->addLayout(grid);
|
root->addLayout(grid);
|
||||||
|
|
||||||
// ---- 按钮 ----
|
// ---- 按钮 ----
|
||||||
auto* buttonRow = new QHBoxLayout();
|
auto* buttonRow = new QHBoxLayout();
|
||||||
buttonRow->addStretch(1);
|
buttonRow->addStretch(1);
|
||||||
buttonConnect_ = new QPushButton(QStringLiteral("连接"), this);
|
buttonLogin_ = new QPushButton(QStringLiteral("登 录"), this);
|
||||||
buttonConnect_->setMinimumSize(160, 46);
|
buttonLogin_->setMinimumSize(160, 46);
|
||||||
buttonConnect_->setStyleSheet(
|
buttonLogin_->setStyleSheet(
|
||||||
QStringLiteral("QPushButton { background: #2f6feb; color: white; border: none; "
|
QStringLiteral("QPushButton { background: #2f6feb; color: white; border: none; "
|
||||||
"border-radius: 6px; font-weight: bold; }"
|
"border-radius: 6px; font-weight: bold; }"
|
||||||
"QPushButton:disabled { background: #3a4a63; }"));
|
"QPushButton:disabled { background: #3a4a63; }"));
|
||||||
buttonConnect_->setDefault(true);
|
buttonLogin_->setDefault(true);
|
||||||
buttonQuit_ = new QPushButton(QStringLiteral("退出"), this);
|
buttonQuit_ = new QPushButton(QStringLiteral("退出"), this);
|
||||||
buttonQuit_->setMinimumSize(110, 46);
|
buttonQuit_->setMinimumSize(110, 46);
|
||||||
|
|
||||||
buttonRow->addWidget(buttonConnect_);
|
buttonRow->addWidget(buttonLogin_);
|
||||||
buttonRow->addWidget(buttonQuit_);
|
buttonRow->addWidget(buttonQuit_);
|
||||||
buttonRow->addStretch(1);
|
buttonRow->addStretch(1);
|
||||||
root->addLayout(buttonRow);
|
root->addLayout(buttonRow);
|
||||||
|
|
||||||
// ---- 状态条 ----
|
// ---- 状态条 ----
|
||||||
labelStatus_ = new QLabel(QStringLiteral("就绪"), this);
|
labelStatus_ = new QLabel(QStringLiteral("请输入用户名和密码登录"), this);
|
||||||
labelStatus_->setAlignment(Qt::AlignCenter);
|
labelStatus_->setAlignment(Qt::AlignCenter);
|
||||||
labelStatus_->setStyleSheet(QStringLiteral("color: #8a94a6;"));
|
labelStatus_->setStyleSheet(QStringLiteral("color: #8a94a6;"));
|
||||||
root->addWidget(labelStatus_);
|
root->addWidget(labelStatus_);
|
||||||
root->addStretch(1);
|
root->addStretch(1);
|
||||||
|
|
||||||
connect(buttonConnect_, &QPushButton::clicked, this, &LoginPage::onConnectClicked);
|
connect(buttonLogin_, &QPushButton::clicked, this, &LoginPage::onLoginClicked);
|
||||||
connect(buttonQuit_, &QPushButton::clicked, this, [this]() { emit quitRequested(); });
|
connect(buttonQuit_, &QPushButton::clicked, this, [this]() { emit quitRequested(); });
|
||||||
|
// 密码框回车直接登录
|
||||||
|
connect(editPassword_, &QLineEdit::returnPressed, this, &LoginPage::onLoginClicked);
|
||||||
|
connect(editUser_, &QLineEdit::returnPressed, this, &LoginPage::onLoginClicked);
|
||||||
}
|
}
|
||||||
|
|
||||||
void LoginPage::setConnectHandler(ConnectHandler handler) {
|
void LoginPage::setLoginHandler(LoginHandler handler) {
|
||||||
connectHandler_ = std::move(handler);
|
loginHandler_ = std::move(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
void LoginPage::setStatus(const QString& text, bool error) {
|
void LoginPage::setStatus(const QString& text, bool error) {
|
||||||
labelStatus_->setText(text);
|
labelStatus_->setText(text);
|
||||||
labelStatus_->setStyleSheet(
|
labelStatus_->setStyleSheet(
|
||||||
QStringLiteral("color: %1;").arg(error ? QStringLiteral("#e05561")
|
QStringLiteral("color: %1;").arg(error ? QStringLiteral("#e05561")
|
||||||
: QStringLiteral("#8a94a6")));
|
: QStringLiteral("#8a94a6")));
|
||||||
}
|
}
|
||||||
|
|
||||||
void LoginPage::setBusy(bool busy) {
|
void LoginPage::setBusy(bool busy) {
|
||||||
buttonConnect_->setEnabled(!busy);
|
buttonLogin_->setEnabled(!busy);
|
||||||
buttonConnect_->setText(busy ? QStringLiteral("连接中…") : QStringLiteral("连接"));
|
buttonLogin_->setText(busy ? QStringLiteral("登录中…") : QStringLiteral("登 录"));
|
||||||
editServer_->setEnabled(!busy);
|
|
||||||
editUser_->setEnabled(!busy);
|
editUser_->setEnabled(!busy);
|
||||||
editPassword_->setEnabled(!busy);
|
editPassword_->setEnabled(!busy);
|
||||||
comboResolution_->setEnabled(!busy);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
TerminalConfig LoginPage::currentConfig() const {
|
TerminalConfig LoginPage::deviceConfig() const {
|
||||||
TerminalConfig out = cfg_;
|
return cfg_;
|
||||||
out.server = editServer_->text().trimmed().toStdString();
|
|
||||||
out.username = editUser_->text().trimmed().toStdString();
|
|
||||||
out.password = editPassword_->text().toStdString();
|
|
||||||
out.resolution = comboResolution_->currentData().toString().toStdString();
|
|
||||||
out.remember = checkRemember_->isChecked();
|
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void LoginPage::onConnectClicked() {
|
void LoginPage::onLoginClicked() {
|
||||||
TerminalConfig cfg = currentConfig();
|
const QString username = editUser_->text().trimmed();
|
||||||
if (cfg.server.empty()) {
|
const QString password = editPassword_->text();
|
||||||
setStatus(QStringLiteral("请填写服务器地址"), true);
|
if (username.isEmpty() || password.isEmpty()) {
|
||||||
|
setStatus(QStringLiteral("请输入用户名和密码"), true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setStatus(QStringLiteral("正在连接 %1 …").arg(QString::fromStdString(cfg.server)));
|
setStatus(QStringLiteral("正在认证…"));
|
||||||
|
|
||||||
if (cfg.remember) {
|
if (loginHandler_) {
|
||||||
saveConfig(cfg);
|
loginHandler_(username, password);
|
||||||
}
|
}
|
||||||
if (connectHandler_) {
|
emit loginRequested(username, password);
|
||||||
connectHandler_(cfg);
|
|
||||||
}
|
|
||||||
emit connectRequested(cfg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace vdi
|
} // namespace vdi
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
// 首配 / 登录页
|
// 登录页
|
||||||
//
|
//
|
||||||
// 对应《VDI 云桌面软件开发方案》3.2 节:
|
// 产品流程第一步:设备 SN/网络展示 + 用户认证。
|
||||||
// 首配页(无配置时进入)——SN 绑定、WiFi 扫码、服务器地址
|
// 当前为本地演示认证(账号密码非空通过),由 DesktopService 承担;
|
||||||
// 状态条——网络 / 会话状态
|
// 页面只负责输入采集与状态展示,不直接连接云桌面。
|
||||||
|
|
||||||
#ifndef KIOSK_SHELL_LOGIN_PAGE_H
|
#ifndef KIOSK_SHELL_LOGIN_PAGE_H
|
||||||
#define KIOSK_SHELL_LOGIN_PAGE_H
|
#define KIOSK_SHELL_LOGIN_PAGE_H
|
||||||
@ -13,8 +13,6 @@
|
|||||||
#include "config.h"
|
#include "config.h"
|
||||||
|
|
||||||
class QLineEdit;
|
class QLineEdit;
|
||||||
class QComboBox;
|
|
||||||
class QCheckBox;
|
|
||||||
class QLabel;
|
class QLabel;
|
||||||
class QPushButton;
|
class QPushButton;
|
||||||
class QGridLayout;
|
class QGridLayout;
|
||||||
@ -26,42 +24,38 @@ class LoginPage : public QWidget {
|
|||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
using ConnectHandler = std::function<void(const TerminalConfig&)>;
|
// 认证回调:由 MainWindow 接到 DesktopService
|
||||||
|
using LoginHandler = std::function<void(const QString& username, const QString& password)>;
|
||||||
|
|
||||||
explicit LoginPage(const TerminalConfig& initial, QWidget* parent = nullptr);
|
explicit LoginPage(const TerminalConfig& initial, QWidget* parent = nullptr);
|
||||||
|
|
||||||
void setConnectHandler(ConnectHandler handler);
|
void setLoginHandler(LoginHandler handler);
|
||||||
void setStatus(const QString& text, bool error = false);
|
void setStatus(const QString& text, bool error = false);
|
||||||
void setBusy(bool busy);
|
void setBusy(bool busy);
|
||||||
|
|
||||||
// 供外部回填
|
// 保留设备首配信息(SN/服务器地址),登录不再承载 Windows 直连参数
|
||||||
TerminalConfig currentConfig() const;
|
TerminalConfig deviceConfig() const;
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void connectRequested(const vdi::TerminalConfig& cfg);
|
void loginRequested(const QString& username, const QString& password);
|
||||||
void quitRequested();
|
void quitRequested();
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void onConnectClicked();
|
void onLoginClicked();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
TerminalConfig cfg_;
|
TerminalConfig cfg_;
|
||||||
ConnectHandler connectHandler_;
|
LoginHandler loginHandler_;
|
||||||
|
|
||||||
QLineEdit* editServer_ = nullptr;
|
|
||||||
QLineEdit* editUser_ = nullptr;
|
QLineEdit* editUser_ = nullptr;
|
||||||
QLineEdit* editPassword_ = nullptr;
|
QLineEdit* editPassword_ = nullptr;
|
||||||
QComboBox* comboResolution_ = nullptr;
|
|
||||||
QCheckBox* checkRemember_ = nullptr;
|
|
||||||
QLabel* labelStatus_ = nullptr;
|
QLabel* labelStatus_ = nullptr;
|
||||||
QLabel* labelSn_ = nullptr;
|
QLabel* labelSn_ = nullptr;
|
||||||
QLabel* labelNetwork_ = nullptr;
|
QLabel* labelNetwork_ = nullptr;
|
||||||
QPushButton* buttonConnect_ = nullptr;
|
QPushButton* buttonLogin_ = nullptr;
|
||||||
QPushButton* buttonQuit_ = nullptr;
|
QPushButton* buttonQuit_ = nullptr;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace vdi
|
} // namespace vdi
|
||||||
|
|
||||||
Q_DECLARE_METATYPE(vdi::TerminalConfig)
|
|
||||||
|
|
||||||
#endif // KIOSK_SHELL_LOGIN_PAGE_H
|
#endif // KIOSK_SHELL_LOGIN_PAGE_H
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
#include "main_window.h"
|
#include "main_window.h"
|
||||||
|
|
||||||
#include <QAction>
|
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QCloseEvent>
|
#include <QDateTime>
|
||||||
|
#include <QEvent>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QKeyEvent>
|
||||||
#include <QKeySequence>
|
#include <QKeySequence>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QMenu>
|
#include <QMenu>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
|
#include <QMouseEvent>
|
||||||
|
#include <QPoint>
|
||||||
#include <QScreen>
|
#include <QScreen>
|
||||||
#include <QShortcut>
|
#include <QShortcut>
|
||||||
#include <QStackedWidget>
|
#include <QStackedWidget>
|
||||||
@ -14,8 +18,12 @@
|
|||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
#include <freerdp/codec/color.h>
|
#include <freerdp/codec/color.h>
|
||||||
|
#include <freerdp/locale/keyboard.h>
|
||||||
|
#include <freerdp/scancode.h>
|
||||||
|
|
||||||
|
#include "desktop_list_page.h"
|
||||||
#include "login_page.h"
|
#include "login_page.h"
|
||||||
|
|
||||||
namespace vdi {
|
namespace vdi {
|
||||||
@ -31,15 +39,21 @@ QImage::Format toQImageFormat(uint32_t freerdpFormat) {
|
|||||||
return QImage::Format_RGB888;
|
return QImage::Format_RGB888;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 把 FreeRDP 的 4 字节/像素缓冲转成 QImage 可用的 RGB888
|
// FreeRDP BGRX32 在当前 ARM64 小端目标上与 QImage::Format_RGB32 的内存布局一致。
|
||||||
// bytesPerLine 是 FreeRDP 的每行字节数(width*4)
|
|
||||||
QImage convertFrame(const uint8_t* data, uint32_t width, uint32_t height, uint32_t bytesPerLine,
|
QImage convertFrame(const uint8_t* data, uint32_t width, uint32_t height, uint32_t bytesPerLine,
|
||||||
uint32_t freerdpFormat) {
|
uint32_t freerdpFormat) {
|
||||||
if (!data || width == 0 || height == 0) {
|
if (!data || width == 0 || height == 0) {
|
||||||
return QImage();
|
return QImage();
|
||||||
}
|
}
|
||||||
const bool bgr = (freerdpFormat == PIXEL_FORMAT_BGRX32) ||
|
if (freerdpFormat == PIXEL_FORMAT_BGRX32 || freerdpFormat == PIXEL_FORMAT_BGRA32) {
|
||||||
(freerdpFormat == PIXEL_FORMAT_BGRA32);
|
QImage out(static_cast<int>(width), static_cast<int>(height), QImage::Format_RGB32);
|
||||||
|
const size_t rowBytes = static_cast<size_t>(width) * 4;
|
||||||
|
for (uint32_t y = 0; y < height; ++y) {
|
||||||
|
std::memcpy(out.scanLine(static_cast<int>(y)),
|
||||||
|
data + static_cast<size_t>(y) * bytesPerLine, rowBytes);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
QImage out(static_cast<int>(width), static_cast<int>(height), QImage::Format_RGB888);
|
QImage out(static_cast<int>(width), static_cast<int>(height), QImage::Format_RGB888);
|
||||||
for (uint32_t y = 0; y < height; ++y) {
|
for (uint32_t y = 0; y < height; ++y) {
|
||||||
@ -47,15 +61,9 @@ QImage convertFrame(const uint8_t* data, uint32_t width, uint32_t height, uint32
|
|||||||
uint8_t* dst = out.scanLine(static_cast<int>(y));
|
uint8_t* dst = out.scanLine(static_cast<int>(y));
|
||||||
for (uint32_t x = 0; x < width; ++x) {
|
for (uint32_t x = 0; x < width; ++x) {
|
||||||
const uint8_t* p = src + static_cast<size_t>(x) * 4;
|
const uint8_t* p = src + static_cast<size_t>(x) * 4;
|
||||||
if (bgr) {
|
dst[0] = p[0];
|
||||||
dst[0] = p[2];
|
dst[1] = p[1];
|
||||||
dst[1] = p[1];
|
dst[2] = p[2];
|
||||||
dst[2] = p[0];
|
|
||||||
} else {
|
|
||||||
dst[0] = p[0];
|
|
||||||
dst[1] = p[1];
|
|
||||||
dst[2] = p[2];
|
|
||||||
}
|
|
||||||
dst += 3;
|
dst += 3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -73,15 +81,19 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
|
|||||||
});
|
});
|
||||||
setupUi();
|
setupUi();
|
||||||
setupHotkey();
|
setupHotkey();
|
||||||
|
setupHeartbeat();
|
||||||
|
keyboardLayout_ = freerdp_keyboard_init(0);
|
||||||
|
|
||||||
// BOOT -> CONFIG
|
|
||||||
config_ = loadConfig();
|
config_ = loadConfig();
|
||||||
fsm_.transitionTo(State::kConfig, "启动完成");
|
|
||||||
|
// 开机永远进入登录页;不再根据本地配置自动连接 RDP,
|
||||||
|
// 会话只能由用户在桌面列表选择后启动。
|
||||||
|
fsm_.transitionTo(State::kConfig, QStringLiteral("等待登录"));
|
||||||
showLogin();
|
showLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
MainWindow::~MainWindow() {
|
MainWindow::~MainWindow() {
|
||||||
stopSession();
|
stopSessions();
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::setupUi() {
|
void MainWindow::setupUi() {
|
||||||
@ -94,7 +106,6 @@ void MainWindow::setupUi() {
|
|||||||
setCentralWidget(stack_);
|
setCentralWidget(stack_);
|
||||||
|
|
||||||
// 登录页
|
// 登录页
|
||||||
config_ = loadConfig();
|
|
||||||
auto* login = new LoginPage(config_, this);
|
auto* login = new LoginPage(config_, this);
|
||||||
loginPage_ = login;
|
loginPage_ = login;
|
||||||
login->setStyleSheet(
|
login->setStyleSheet(
|
||||||
@ -102,23 +113,36 @@ void MainWindow::setupUi() {
|
|||||||
"QLineEdit { background: #1a2233; border: 1px solid #2b3654; "
|
"QLineEdit { background: #1a2233; border: 1px solid #2b3654; "
|
||||||
"border-radius: 4px; padding: 8px; color: #e6e9ef; }"
|
"border-radius: 4px; padding: 8px; color: #e6e9ef; }"
|
||||||
"QLineEdit:focus { border: 1px solid #2f6feb; }"
|
"QLineEdit:focus { border: 1px solid #2f6feb; }"
|
||||||
"QComboBox { background: #1a2233; border: 1px solid #2b3654; "
|
|
||||||
"border-radius: 4px; padding: 8px; color: #e6e9ef; }"
|
|
||||||
"QPushButton { background: #232d44; border: 1px solid #2b3654; "
|
"QPushButton { background: #232d44; border: 1px solid #2b3654; "
|
||||||
"border-radius: 6px; color: #e6e9ef; }"
|
"border-radius: 6px; color: #e6e9ef; }"
|
||||||
"QPushButton:hover { background: #2b3654; }"
|
"QPushButton:hover { background: #2b3654; }"));
|
||||||
"QCheckBox { color: #8a94a6; }"));
|
|
||||||
stack_->addWidget(login);
|
stack_->addWidget(login);
|
||||||
|
|
||||||
connect(login, &LoginPage::connectRequested, this, &MainWindow::onConnectRequested);
|
connect(login, &LoginPage::loginRequested, this, &MainWindow::onLoginRequested);
|
||||||
connect(login, &LoginPage::quitRequested, this, &MainWindow::onQuitRequested);
|
connect(login, &LoginPage::quitRequested, this, &MainWindow::onQuitRequested);
|
||||||
|
|
||||||
|
// 桌面列表页
|
||||||
|
auto* list = new DesktopListPage(this);
|
||||||
|
desktopListPage_ = list;
|
||||||
|
list->setStyleSheet(
|
||||||
|
QStringLiteral("QWidget { background: #0f1420; color: #e6e9ef; }"
|
||||||
|
"QPushButton { background: #232d44; border: 1px solid #2b3654; "
|
||||||
|
"border-radius: 6px; color: #e6e9ef; }"
|
||||||
|
"QPushButton:hover { background: #2b3654; }"));
|
||||||
|
stack_->addWidget(list);
|
||||||
|
|
||||||
|
connect(list, &DesktopListPage::desktopSelected, this, &MainWindow::onDesktopSelected);
|
||||||
|
connect(list, &DesktopListPage::logoutRequested, this, &MainWindow::onLogoutRequested);
|
||||||
|
|
||||||
// 会话视图
|
// 会话视图
|
||||||
auto* sessionWidget = new QWidget(this);
|
auto* sessionWidget = new QWidget(this);
|
||||||
auto* sessionLayout = new QVBoxLayout(sessionWidget);
|
auto* sessionLayout = new QVBoxLayout(sessionWidget);
|
||||||
sessionLayout->setContentsMargins(0, 0, 0, 0);
|
sessionLayout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
|
||||||
sessionView_ = new QLabel(sessionWidget);
|
sessionView_ = new QLabel(sessionWidget);
|
||||||
|
sessionView_->setFocusPolicy(Qt::StrongFocus);
|
||||||
|
sessionView_->setMouseTracking(true);
|
||||||
|
sessionView_->installEventFilter(this);
|
||||||
sessionView_->setAlignment(Qt::AlignCenter);
|
sessionView_->setAlignment(Qt::AlignCenter);
|
||||||
sessionView_->setStyleSheet(QStringLiteral("background: #000000; color: #e6e9ef;"));
|
sessionView_->setStyleSheet(QStringLiteral("background: #000000; color: #e6e9ef;"));
|
||||||
sessionView_->setText(QStringLiteral("正在建立远程会话…"));
|
sessionView_->setText(QStringLiteral("正在建立远程会话…"));
|
||||||
@ -140,17 +164,27 @@ void MainWindow::setupUi() {
|
|||||||
statusBar_->setStyleSheet(QStringLiteral("QStatusBar { background: #161d2e; color: #8a94a6; }"));
|
statusBar_->setStyleSheet(QStringLiteral("QStatusBar { background: #161d2e; color: #8a94a6; }"));
|
||||||
setStatusBar(statusBar_);
|
setStatusBar(statusBar_);
|
||||||
|
|
||||||
// 重连定时器
|
// RDP 重连定时器
|
||||||
reconnectTimer_ = new QTimer(this);
|
reconnectTimer_ = new QTimer(this);
|
||||||
reconnectTimer_->setSingleShot(true);
|
reconnectTimer_->setSingleShot(true);
|
||||||
connect(reconnectTimer_, &QTimer::timeout, this, [this]() {
|
connect(reconnectTimer_, &QTimer::timeout, this, [this]() {
|
||||||
if (fsm_.current() == State::kReconnect && reconnectAttempts_ < kMaxReconnectAttempts) {
|
if (fsm_.current() == State::kReconnect && reconnectAttempts_ < kMaxReconnectAttempts) {
|
||||||
++reconnectAttempts_;
|
++reconnectAttempts_;
|
||||||
startSession(config_);
|
if (activeTicket_.protocol == Protocol::kMoonlight) {
|
||||||
|
startMoonlightSession(activeTicket_);
|
||||||
|
} else {
|
||||||
|
startRdpSession(activeTicket_);
|
||||||
|
}
|
||||||
} else if (fsm_.current() == State::kReconnect) {
|
} else if (fsm_.current() == State::kReconnect) {
|
||||||
fsm_.transitionTo(State::kError, "重连失败,请检查网络");
|
stopSessions();
|
||||||
|
fsm_.transitionTo(State::kDesktopList, QStringLiteral("重连失败,已返回桌面列表"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
frameRefreshTimer_ = new QTimer(this);
|
||||||
|
frameRefreshTimer_->setInterval(33);
|
||||||
|
connect(frameRefreshTimer_, &QTimer::timeout, this, &MainWindow::onFrameRefresh);
|
||||||
|
frameRefreshTimer_->start();
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::setupHotkey() {
|
void MainWindow::setupHotkey() {
|
||||||
@ -160,6 +194,22 @@ void MainWindow::setupHotkey() {
|
|||||||
connect(shortcut, &QShortcut::activated, this, [this]() { onMenuAction(0); });
|
connect(shortcut, &QShortcut::activated, this, [this]() { onMenuAction(0); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MainWindow::setupHeartbeat() {
|
||||||
|
// 定期写心跳文件,供看门狗 Monitor 判断主进程是否假死
|
||||||
|
// 对应会议纪要要求的双进程架构
|
||||||
|
const QString path = QStringLiteral("/tmp/vdi-kiosk.heartbeat");
|
||||||
|
heartbeatTimer_ = new QTimer(this);
|
||||||
|
heartbeatTimer_->setInterval(5000);
|
||||||
|
connect(heartbeatTimer_, &QTimer::timeout, this, [path]() {
|
||||||
|
QFile file(path);
|
||||||
|
if (file.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
||||||
|
file.write(QByteArray::number(QDateTime::currentSecsSinceEpoch()));
|
||||||
|
file.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
heartbeatTimer_->start();
|
||||||
|
}
|
||||||
|
|
||||||
void MainWindow::onStateChanged(State from, State to, const QString& detail) {
|
void MainWindow::onStateChanged(State from, State to, const QString& detail) {
|
||||||
Q_UNUSED(from);
|
Q_UNUSED(from);
|
||||||
const QString stateName = QString::fromLatin1(stateToString(to));
|
const QString stateName = QString::fromLatin1(stateToString(to));
|
||||||
@ -169,15 +219,22 @@ void MainWindow::onStateChanged(State from, State to, const QString& detail) {
|
|||||||
case State::kConfig:
|
case State::kConfig:
|
||||||
showLogin();
|
showLogin();
|
||||||
break;
|
break;
|
||||||
|
case State::kAuthenticating:
|
||||||
|
break;
|
||||||
|
case State::kDesktopList:
|
||||||
|
showDesktopList();
|
||||||
|
refreshDesktopList();
|
||||||
|
break;
|
||||||
case State::kConnecting:
|
case State::kConnecting:
|
||||||
if (stack_->currentWidget() != sessionView_->parentWidget()) {
|
if (stack_->currentWidget() != sessionView_->parentWidget()) {
|
||||||
stack_->setCurrentWidget(sessionView_->parentWidget());
|
stack_->setCurrentWidget(sessionView_->parentWidget());
|
||||||
}
|
}
|
||||||
sessionOverlay_->setText(QStringLiteral("正在连接 %1 …")
|
sessionView_->setFocus(Qt::OtherFocusReason);
|
||||||
.arg(QString::fromStdString(config_.server)));
|
sessionOverlay_->setText(QStringLiteral("正在连接 %1 …").arg(activeTicket_.endpoint));
|
||||||
sessionOverlay_->show();
|
sessionOverlay_->show();
|
||||||
break;
|
break;
|
||||||
case State::kSession:
|
case State::kSession:
|
||||||
|
sessionView_->setFocus(Qt::OtherFocusReason);
|
||||||
sessionOverlay_->hide();
|
sessionOverlay_->hide();
|
||||||
break;
|
break;
|
||||||
case State::kReconnect:
|
case State::kReconnect:
|
||||||
@ -186,11 +243,11 @@ void MainWindow::onStateChanged(State from, State to, const QString& detail) {
|
|||||||
reconnectTimer_->start(kReconnectDelayMs);
|
reconnectTimer_->start(kReconnectDelayMs);
|
||||||
break;
|
break;
|
||||||
case State::kError:
|
case State::kError:
|
||||||
sessionOverlay_->setText(QStringLiteral("连接失败:%1").arg(detail));
|
stopSessions();
|
||||||
sessionOverlay_->show();
|
fsm_.transitionTo(State::kDesktopList, QStringLiteral("连接失败"));
|
||||||
if (loginPage_) {
|
if (desktopListPage_) {
|
||||||
loginPage_->setBusy(false);
|
desktopListPage_->setBusy(false);
|
||||||
loginPage_->setStatus(QStringLiteral("连接失败:%1").arg(detail), true);
|
desktopListPage_->setStatus(QStringLiteral("连接失败:%1").arg(detail), true);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case State::kBoot:
|
case State::kBoot:
|
||||||
@ -206,38 +263,104 @@ void MainWindow::showLogin() {
|
|||||||
stack_->setCurrentWidget(loginPage_);
|
stack_->setCurrentWidget(loginPage_);
|
||||||
if (loginPage_) {
|
if (loginPage_) {
|
||||||
loginPage_->setBusy(false);
|
loginPage_->setBusy(false);
|
||||||
loginPage_->setStatus(QStringLiteral("就绪"));
|
loginPage_->setStatus(QStringLiteral("请输入用户名和密码登录"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::onConnectRequested(const vdi::TerminalConfig& cfg) {
|
void MainWindow::showDesktopList() {
|
||||||
config_ = cfg;
|
if (!desktopListPage_) {
|
||||||
reconnectAttempts_ = 0;
|
return;
|
||||||
startSession(cfg);
|
}
|
||||||
|
stack_->setCurrentWidget(desktopListPage_);
|
||||||
|
desktopListPage_->setCurrentUser(currentUser_);
|
||||||
|
desktopListPage_->setBusy(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::startSession(const TerminalConfig& cfg) {
|
void MainWindow::refreshDesktopList() {
|
||||||
if (!fsm_.transitionTo(State::kConnecting, QString::fromStdString(cfg.server))) {
|
desktopService_.listDesktops([this](std::vector<DesktopInfo> list, const QString& error) {
|
||||||
// 已经在 CONNECTING 或 SESSION,忽略重复触发
|
if (error.isEmpty()) {
|
||||||
|
desktops_ = list;
|
||||||
|
}
|
||||||
|
if (desktopListPage_) {
|
||||||
|
desktopListPage_->setDesktops(desktops_);
|
||||||
|
if (!error.isEmpty()) {
|
||||||
|
desktopListPage_->setStatus(error, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::onLoginRequested(const QString& username, const QString& password) {
|
||||||
|
desktopService_.login(username, password, [this](const AuthResult& result) {
|
||||||
|
if (!loginPage_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
loginPage_->setBusy(false);
|
||||||
|
loginPage_->setStatus(result.message, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentUser_ = result.displayName;
|
||||||
|
fsm_.transitionTo(State::kAuthenticating, QStringLiteral("认证成功"));
|
||||||
|
fsm_.transitionTo(State::kDesktopList, QStringLiteral("欢迎 %1").arg(currentUser_));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::onDesktopSelected(const QString& desktopId) {
|
||||||
|
if (!fsm_.canConnect()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
desktopService_.createTicket(desktopId, [this](const ConnectionTicket& ticket,
|
||||||
|
const QString& error) {
|
||||||
|
if (!error.isEmpty()) {
|
||||||
|
if (desktopListPage_) {
|
||||||
|
desktopListPage_->setBusy(false);
|
||||||
|
desktopListPage_->setStatus(error, true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeTicket_ = ticket;
|
||||||
|
reconnectAttempts_ = 0;
|
||||||
|
|
||||||
|
if (ticket.protocol == Protocol::kMoonlight) {
|
||||||
|
startMoonlightSession(ticket);
|
||||||
|
} else {
|
||||||
|
startRdpSession(ticket);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::onLogoutRequested() {
|
||||||
|
stopSessions();
|
||||||
|
desktopService_.logout();
|
||||||
|
currentUser_.clear();
|
||||||
|
fsm_.transitionTo(State::kConfig, QStringLiteral("已退出登录"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::startRdpSession(const ConnectionTicket& ticket) {
|
||||||
|
++sessionEpoch_;
|
||||||
|
{
|
||||||
|
QMutexLocker locker(&frameMutex_);
|
||||||
|
latestFrame_ = QImage();
|
||||||
|
frameEpoch_ = sessionEpoch_;
|
||||||
|
frameWakePending_ = false;
|
||||||
|
}
|
||||||
|
if (fsm_.current() == State::kSession) {
|
||||||
|
fsm_.transitionTo(State::kReconnect, QStringLiteral("手动重新连接"));
|
||||||
|
}
|
||||||
|
if (!fsm_.transitionTo(State::kConnecting, ticket.endpoint)) {
|
||||||
if (fsm_.current() != State::kConnecting) {
|
if (fsm_.current() != State::kConnecting) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RdpOptions opts;
|
RdpOptions opts;
|
||||||
opts.server = cfg.server;
|
opts.server = ticket.endpoint.toStdString();
|
||||||
opts.username = cfg.username;
|
opts.username = ticket.username.toStdString();
|
||||||
opts.password = cfg.password;
|
opts.password = ticket.password.toStdString();
|
||||||
|
opts.keyboardLayout = keyboardLayout_;
|
||||||
// 解析分辨率
|
opts.width = ticket.width;
|
||||||
int w = 1920;
|
opts.height = ticket.height;
|
||||||
int h = 1440;
|
|
||||||
if (sscanf(cfg.resolution.c_str(), "%dx%d", &w, &h) != 2) {
|
|
||||||
w = 1920;
|
|
||||||
h = 1440;
|
|
||||||
}
|
|
||||||
opts.width = w;
|
|
||||||
opts.height = h;
|
|
||||||
opts.bpp = 32;
|
opts.bpp = 32;
|
||||||
opts.fullscreen = true;
|
opts.fullscreen = true;
|
||||||
// 与 xfreerdp3 相同的 GFX 能力组合语义:
|
// 与 xfreerdp3 相同的 GFX 能力组合语义:
|
||||||
@ -248,12 +371,20 @@ void MainWindow::startSession(const TerminalConfig& cfg) {
|
|||||||
RdpCallbacks cb;
|
RdpCallbacks cb;
|
||||||
cb.onFrame = [this](const uint8_t* data, uint32_t width, uint32_t height, uint32_t stride,
|
cb.onFrame = [this](const uint8_t* data, uint32_t width, uint32_t height, uint32_t stride,
|
||||||
uint32_t format) {
|
uint32_t format) {
|
||||||
// 帧数据来自 FreeRDP 线程,切换到 GUI 线程更新
|
// 在 FreeRDP 线程中复制帧,不能把其内部缓冲区裸指针排队到 GUI 线程。
|
||||||
QMetaObject::invokeMethod(
|
const QImage image = convertFrame(data, width, height, stride, format);
|
||||||
this, [this, data, width, height, stride, format]() {
|
if (image.isNull()) {
|
||||||
onFrame(data, width, height, stride, format);
|
return;
|
||||||
},
|
}
|
||||||
Qt::QueuedConnection);
|
QMutexLocker locker(&frameMutex_);
|
||||||
|
latestFrame_ = image;
|
||||||
|
frameEpoch_ = sessionEpoch_;
|
||||||
|
if (frameWakePending_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
frameWakePending_ = true;
|
||||||
|
locker.unlock();
|
||||||
|
QMetaObject::invokeMethod(this, &MainWindow::onFrameWakeup, Qt::QueuedConnection);
|
||||||
};
|
};
|
||||||
cb.onState = [this](bool connected, const std::string& message) {
|
cb.onState = [this](bool connected, const std::string& message) {
|
||||||
QMetaObject::invokeMethod(
|
QMetaObject::invokeMethod(
|
||||||
@ -264,15 +395,54 @@ void MainWindow::startSession(const TerminalConfig& cfg) {
|
|||||||
Qt::QueuedConnection);
|
Qt::QueuedConnection);
|
||||||
};
|
};
|
||||||
|
|
||||||
stopSession();
|
rdpSession_ = std::make_unique<RdpSession>();
|
||||||
session_ = std::make_unique<RdpSession>();
|
rdpSession_->start(opts, cb);
|
||||||
session_->start(opts, cb);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::stopSession() {
|
void MainWindow::startMoonlightSession(const ConnectionTicket& ticket) {
|
||||||
if (session_) {
|
if (!fsm_.transitionTo(State::kConnecting, ticket.endpoint)) {
|
||||||
session_->stop();
|
if (fsm_.current() != State::kConnecting) {
|
||||||
session_.reset();
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Moonlight 是外部全屏进程:Kiosk 画面停在"正在连接"覆盖层,
|
||||||
|
// 串流窗口由 moonlight-qt 自行置顶显示。
|
||||||
|
if (stack_->currentWidget() != sessionView_->parentWidget()) {
|
||||||
|
stack_->setCurrentWidget(sessionView_->parentWidget());
|
||||||
|
}
|
||||||
|
sessionOverlay_->setText(QStringLiteral("正在连接 %1 …").arg(ticket.endpoint));
|
||||||
|
sessionOverlay_->show();
|
||||||
|
|
||||||
|
MoonlightSession::Options opts;
|
||||||
|
opts.decoder = QStringLiteral("software"); // RK3528 无可用 V4L2 硬解设备
|
||||||
|
opts.videoCodec = QStringLiteral("H.264");
|
||||||
|
opts.audioOnHost = true;
|
||||||
|
|
||||||
|
moonlightSession_ = std::make_unique<MoonlightSession>(/*parent=*/nullptr);
|
||||||
|
moonlightSession_->setOptions(opts);
|
||||||
|
moonlightSession_->start(ticket, [this](bool running, const QString& message) {
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
this,
|
||||||
|
[this, running, message]() { onMoonlightState(running, message); },
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::onMoonlightState(bool running, const QString& message) {
|
||||||
|
if (running) {
|
||||||
|
// 进程已拉起;串流窗口是否成功由 moonlight 自身展示
|
||||||
|
updateStatusBar(QStringLiteral("SESSION %1").arg(message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 进程退出:回桌面列表
|
||||||
|
moonlightSession_.reset();
|
||||||
|
stopSessions();
|
||||||
|
if (fsm_.current() == State::kConnecting || fsm_.current() == State::kSession) {
|
||||||
|
fsm_.transitionTo(State::kDesktopList, message);
|
||||||
|
}
|
||||||
|
if (desktopListPage_) {
|
||||||
|
desktopListPage_->setBusy(false);
|
||||||
|
desktopListPage_->setStatus(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -282,56 +452,102 @@ void MainWindow::onSessionState(bool connected, const QString& message) {
|
|||||||
fsm_.transitionTo(State::kSession, message);
|
fsm_.transitionTo(State::kSession, message);
|
||||||
} else {
|
} else {
|
||||||
if (fsm_.current() == State::kConnecting) {
|
if (fsm_.current() == State::kConnecting) {
|
||||||
// 首次连接失败
|
// 首次连接失败:回桌面列表并提示
|
||||||
fsm_.transitionTo(State::kError, message);
|
stopSessions();
|
||||||
|
fsm_.transitionTo(State::kDesktopList, message);
|
||||||
|
if (desktopListPage_) {
|
||||||
|
desktopListPage_->setBusy(false);
|
||||||
|
desktopListPage_->setStatus(QStringLiteral("连接失败:%1").arg(message), true);
|
||||||
|
}
|
||||||
} else if (fsm_.current() == State::kSession) {
|
} else if (fsm_.current() == State::kSession) {
|
||||||
fsm_.transitionTo(State::kReconnect, message);
|
fsm_.transitionTo(State::kReconnect, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::onFrame(const uint8_t* data, uint32_t width, uint32_t height, uint32_t stride,
|
void MainWindow::onFrameWakeup() {
|
||||||
uint32_t format) {
|
QMutexLocker locker(&frameMutex_);
|
||||||
if (!data || width == 0 || height == 0) {
|
frameWakePending_ = false;
|
||||||
return;
|
}
|
||||||
|
|
||||||
|
void MainWindow::onFrameRefresh() {
|
||||||
|
QImage image;
|
||||||
|
uint64_t epoch = 0;
|
||||||
|
{
|
||||||
|
QMutexLocker locker(&frameMutex_);
|
||||||
|
image = latestFrame_;
|
||||||
|
epoch = frameEpoch_;
|
||||||
|
latestFrame_ = QImage();
|
||||||
|
frameWakePending_ = false;
|
||||||
}
|
}
|
||||||
// FreeRDP 主缓冲是 4 字节/像素,需转换后交给 Qt
|
if (epoch == sessionEpoch_) {
|
||||||
QImage image = convertFrame(data, width, height, stride, format);
|
onFrameImage(image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::onFrameImage(const QImage& image) {
|
||||||
if (image.isNull()) {
|
if (image.isNull()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastFrame_ = image;
|
lastFrame_ = image;
|
||||||
frameDirty_ = true;
|
|
||||||
|
|
||||||
if (sessionView_) {
|
if (sessionView_) {
|
||||||
sessionView_->setPixmap(QPixmap::fromImage(lastFrame_).scaled(
|
const QSize targetSize = sessionView_->size();
|
||||||
sessionView_->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation));
|
if (lastFrame_.size() == targetSize) {
|
||||||
|
sessionView_->setPixmap(QPixmap::fromImage(lastFrame_));
|
||||||
|
} else {
|
||||||
|
sessionView_->setPixmap(QPixmap::fromImage(lastFrame_).scaled(
|
||||||
|
targetSize, Qt::KeepAspectRatio, Qt::FastTransformation));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::onQuitRequested() {
|
void MainWindow::onQuitRequested() {
|
||||||
stopSession();
|
stopSessions();
|
||||||
QApplication::quit();
|
QApplication::quit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MainWindow::stopSessions() {
|
||||||
|
if (rdpSession_) {
|
||||||
|
rdpSession_->stop();
|
||||||
|
rdpSession_.reset();
|
||||||
|
}
|
||||||
|
if (moonlightSession_) {
|
||||||
|
moonlightSession_->stop();
|
||||||
|
moonlightSession_.reset();
|
||||||
|
}
|
||||||
|
QMutexLocker locker(&frameMutex_);
|
||||||
|
latestFrame_ = QImage();
|
||||||
|
lastFrame_ = QImage();
|
||||||
|
frameWakePending_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
void MainWindow::onMenuAction(int action) {
|
void MainWindow::onMenuAction(int action) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 1: // 重新连接
|
case 1: // 重新连接当前桌面
|
||||||
reconnectAttempts_ = 0;
|
if (fsm_.canConnect() && !activeTicket_.desktopId.isEmpty()) {
|
||||||
stopSession();
|
reconnectAttempts_ = 0;
|
||||||
startSession(config_);
|
stopSessions();
|
||||||
|
if (activeTicket_.protocol == Protocol::kMoonlight) {
|
||||||
|
startMoonlightSession(activeTicket_);
|
||||||
|
} else {
|
||||||
|
startRdpSession(activeTicket_);
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 2: // 断开回登录页
|
case 2: // 断开回桌面列表
|
||||||
stopSession();
|
stopSessions();
|
||||||
fsm_.transitionTo(State::kConfig, "已断开");
|
if (fsm_.current() != State::kConfig) {
|
||||||
|
fsm_.transitionTo(State::kDesktopList, "已断开");
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 3: // 退出终端
|
case 3: // 退出终端
|
||||||
onQuitRequested();
|
onQuitRequested();
|
||||||
break;
|
break;
|
||||||
default: {
|
default: {
|
||||||
QMenu menu(this);
|
QMenu menu(this);
|
||||||
QAction* reconnect = menu.addAction(QStringLiteral("重新连接"));
|
QAction* reconnect = menu.addAction(QStringLiteral("重新连接当前桌面"));
|
||||||
QAction* disconnect = menu.addAction(QStringLiteral("断开并返回登录页"));
|
QAction* disconnect = menu.addAction(QStringLiteral("断开并返回桌面列表"));
|
||||||
menu.addSeparator();
|
menu.addSeparator();
|
||||||
QAction* quit = menu.addAction(QStringLiteral("退出终端"));
|
QAction* quit = menu.addAction(QStringLiteral("退出终端"));
|
||||||
QAction* chosen = menu.exec(QCursor::pos());
|
QAction* chosen = menu.exec(QCursor::pos());
|
||||||
@ -342,13 +558,90 @@ void MainWindow::onMenuAction(int action) {
|
|||||||
} else if (chosen == quit) {
|
} else if (chosen == quit) {
|
||||||
onMenuAction(3);
|
onMenuAction(3);
|
||||||
}
|
}
|
||||||
|
if (stack_->currentWidget() == sessionView_->parentWidget()) {
|
||||||
|
sessionView_->setFocus(Qt::OtherFocusReason);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool MainWindow::eventFilter(QObject* watched, QEvent* event) {
|
bool MainWindow::eventFilter(QObject* watched, QEvent* event) {
|
||||||
|
if (watched == sessionView_ && rdpSession_) {
|
||||||
|
if (event->type() == QEvent::KeyPress || event->type() == QEvent::KeyRelease) {
|
||||||
|
auto* keyEvent = static_cast<QKeyEvent*>(event);
|
||||||
|
return forwardKeyEvent(keyEvent, event->type() == QEvent::KeyPress);
|
||||||
|
}
|
||||||
|
if (event->type() == QEvent::MouseMove || event->type() == QEvent::MouseButtonPress ||
|
||||||
|
event->type() == QEvent::MouseButtonRelease) {
|
||||||
|
return forwardMouseEvent(static_cast<QMouseEvent*>(event));
|
||||||
|
}
|
||||||
|
if (event->type() == QEvent::Wheel) {
|
||||||
|
return forwardMouseEvent(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
return QMainWindow::eventFilter(watched, event);
|
return QMainWindow::eventFilter(watched, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool MainWindow::forwardKeyEvent(QKeyEvent* event, bool pressed) {
|
||||||
|
if (!rdpSession_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const DWORD rdpScanCode =
|
||||||
|
freerdp_keyboard_get_rdp_scancode_from_x11_keycode(event->nativeScanCode());
|
||||||
|
if (rdpScanCode == RDP_SCANCODE_UNKNOWN) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return rdpSession_->sendKeyEvent(pressed, event->isAutoRepeat(), rdpScanCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MainWindow::forwardMouseEvent(QEvent* event) {
|
||||||
|
if (!rdpSession_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
uint16_t flags = 0;
|
||||||
|
QPoint pos = lastMousePosition_;
|
||||||
|
if (event->type() == QEvent::MouseMove) {
|
||||||
|
auto* mouse = static_cast<QMouseEvent*>(event);
|
||||||
|
pos = mouse->pos();
|
||||||
|
flags = PTR_FLAGS_MOVE;
|
||||||
|
} else if (event->type() == QEvent::MouseButtonPress ||
|
||||||
|
event->type() == QEvent::MouseButtonRelease) {
|
||||||
|
auto* mouse = static_cast<QMouseEvent*>(event);
|
||||||
|
pos = mouse->pos();
|
||||||
|
if (mouse->button() == Qt::LeftButton) flags |= PTR_FLAGS_BUTTON1;
|
||||||
|
if (mouse->button() == Qt::RightButton) flags |= PTR_FLAGS_BUTTON2;
|
||||||
|
if (mouse->button() == Qt::MiddleButton) flags |= PTR_FLAGS_BUTTON3;
|
||||||
|
if (event->type() == QEvent::MouseButtonPress) flags |= PTR_FLAGS_DOWN;
|
||||||
|
} else if (event->type() == QEvent::Wheel) {
|
||||||
|
auto* wheel = static_cast<QWheelEvent*>(event);
|
||||||
|
pos = wheel->position().toPoint();
|
||||||
|
flags = PTR_FLAGS_WHEEL;
|
||||||
|
if (wheel->angleDelta().y() < 0) flags |= PTR_FLAGS_WHEEL_NEGATIVE;
|
||||||
|
}
|
||||||
|
lastMousePosition_ = pos;
|
||||||
|
|
||||||
|
if (lastFrame_.isNull() || !sessionView_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const QSize sourceSize = lastFrame_.size();
|
||||||
|
const QSize viewSize = sessionView_->size();
|
||||||
|
const QSize displayedSize = sourceSize.scaled(viewSize, Qt::KeepAspectRatio);
|
||||||
|
const int offsetX = (viewSize.width() - displayedSize.width()) / 2;
|
||||||
|
const int offsetY = (viewSize.height() - displayedSize.height()) / 2;
|
||||||
|
if (pos.x() < offsetX || pos.y() < offsetY ||
|
||||||
|
pos.x() >= offsetX + displayedSize.width() ||
|
||||||
|
pos.y() >= offsetY + displayedSize.height()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const int remoteX = qBound(0,
|
||||||
|
(pos.x() - offsetX) * sourceSize.width() / displayedSize.width(),
|
||||||
|
sourceSize.width() - 1);
|
||||||
|
const int remoteY = qBound(0,
|
||||||
|
(pos.y() - offsetY) * sourceSize.height() / displayedSize.height(),
|
||||||
|
sourceSize.height() - 1);
|
||||||
|
return rdpSession_->sendMouseEvent(flags, static_cast<uint16_t>(remoteX),
|
||||||
|
static_cast<uint16_t>(remoteY));
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace vdi
|
} // namespace vdi
|
||||||
|
|||||||
@ -1,23 +1,28 @@
|
|||||||
// Kiosk 主窗口
|
// Kiosk 主窗口
|
||||||
//
|
//
|
||||||
// 对应《VDI 云桌面软件开发方案》3.2 节:
|
// 产品流程编排:
|
||||||
// Weston(Wayland) + 全屏 Qt 壳进程
|
// 开机 -> 登录页 -> 认证 -> 桌面列表 -> 选择桌面
|
||||||
// 状态机 BOOT -> CONFIG -> CONNECTING -> SESSION -> RECONNECT -> ERROR
|
// -> 获取连接票据 -> RDP(内嵌)或 Moonlight(外部进程)会话
|
||||||
// 读取下发连接配置(地址/账号模板/显示档位)
|
// -> 会话结束回桌面列表;退出登录回登录页
|
||||||
// 状态条(网络/会话/OTA 进度)
|
//
|
||||||
// 首配页(SN 绑定、WiFi 扫码、服务器地址)
|
// 对应《VDI 云桌面软件开发方案》3.2 节的状态机,
|
||||||
// 以 libfreerdp3 API 内嵌(非拉起外部进程)
|
// 并按产品流程扩展 AUTHENTICATING / DESKTOP_LIST 两个状态。
|
||||||
// 全局热键 Ctrl+Alt+Shift+M 呼出菜单
|
// 全局热键 Ctrl+Alt+Shift+M 呼出管理菜单。
|
||||||
|
|
||||||
#ifndef KIOSK_SHELL_MAIN_WINDOW_H
|
#ifndef KIOSK_SHELL_MAIN_WINDOW_H
|
||||||
#define KIOSK_SHELL_MAIN_WINDOW_H
|
#define KIOSK_SHELL_MAIN_WINDOW_H
|
||||||
|
|
||||||
#include <QImage>
|
#include <QImage>
|
||||||
#include <QMainWindow>
|
#include <QMainWindow>
|
||||||
|
#include <QMutex>
|
||||||
#include <QPointer>
|
#include <QPointer>
|
||||||
|
#include <QPoint>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
|
#include "desktop_service.h"
|
||||||
|
#include "desktop_session.h"
|
||||||
|
#include "moonlight_session.h"
|
||||||
#include "rdp_session.h"
|
#include "rdp_session.h"
|
||||||
#include "state_machine.h"
|
#include "state_machine.h"
|
||||||
|
|
||||||
@ -25,10 +30,14 @@ class QLabel;
|
|||||||
class QStackedWidget;
|
class QStackedWidget;
|
||||||
class QStatusBar;
|
class QStatusBar;
|
||||||
class QTimer;
|
class QTimer;
|
||||||
|
class QEvent;
|
||||||
|
class QKeyEvent;
|
||||||
|
class QMouseEvent;
|
||||||
|
|
||||||
namespace vdi {
|
namespace vdi {
|
||||||
|
|
||||||
class LoginPage;
|
class LoginPage;
|
||||||
|
class DesktopListPage;
|
||||||
|
|
||||||
class MainWindow : public QMainWindow {
|
class MainWindow : public QMainWindow {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
@ -41,34 +50,59 @@ protected:
|
|||||||
bool eventFilter(QObject* watched, QEvent* event) override;
|
bool eventFilter(QObject* watched, QEvent* event) override;
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void onConnectRequested(const vdi::TerminalConfig& cfg);
|
void onLoginRequested(const QString& username, const QString& password);
|
||||||
|
void onDesktopSelected(const QString& desktopId);
|
||||||
|
void onLogoutRequested();
|
||||||
void onQuitRequested();
|
void onQuitRequested();
|
||||||
void onFrame(const uint8_t* data, uint32_t width, uint32_t height, uint32_t stride,
|
void onFrameImage(const QImage& image);
|
||||||
uint32_t format);
|
void onFrameWakeup();
|
||||||
|
void onFrameRefresh();
|
||||||
void onSessionState(bool connected, const QString& message);
|
void onSessionState(bool connected, const QString& message);
|
||||||
|
void onMoonlightState(bool running, const QString& message);
|
||||||
void onMenuAction(int action);
|
void onMenuAction(int action);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void setupUi();
|
void setupUi();
|
||||||
void setupHotkey();
|
void setupHotkey();
|
||||||
|
void setupHeartbeat();
|
||||||
void onStateChanged(vdi::State from, vdi::State to, const QString& detail);
|
void onStateChanged(vdi::State from, vdi::State to, const QString& detail);
|
||||||
void startSession(const TerminalConfig& cfg);
|
void refreshDesktopList();
|
||||||
void stopSession();
|
void startRdpSession(const ConnectionTicket& ticket);
|
||||||
|
void startMoonlightSession(const ConnectionTicket& ticket);
|
||||||
|
void stopSessions();
|
||||||
void showLogin();
|
void showLogin();
|
||||||
|
void showDesktopList();
|
||||||
void updateStatusBar(const QString& text);
|
void updateStatusBar(const QString& text);
|
||||||
|
bool forwardKeyEvent(QKeyEvent* event, bool pressed);
|
||||||
|
bool forwardMouseEvent(QEvent* event);
|
||||||
|
|
||||||
StateMachine fsm_;
|
StateMachine fsm_;
|
||||||
std::unique_ptr<RdpSession> session_;
|
DesktopService desktopService_;
|
||||||
|
std::unique_ptr<RdpSession> rdpSession_;
|
||||||
|
std::unique_ptr<MoonlightSession> moonlightSession_;
|
||||||
|
ConnectionTicket activeTicket_;
|
||||||
|
QString currentUser_;
|
||||||
|
std::vector<DesktopInfo> desktops_;
|
||||||
|
int reconnectAttempts_ = 0;
|
||||||
|
|
||||||
QPointer<LoginPage> loginPage_;
|
QPointer<LoginPage> loginPage_;
|
||||||
|
QPointer<DesktopListPage> desktopListPage_;
|
||||||
QStackedWidget* stack_ = nullptr;
|
QStackedWidget* stack_ = nullptr;
|
||||||
QLabel* sessionView_ = nullptr;
|
QLabel* sessionView_ = nullptr;
|
||||||
QLabel* sessionOverlay_ = nullptr;
|
QLabel* sessionOverlay_ = nullptr;
|
||||||
QStatusBar* statusBar_ = nullptr;
|
QStatusBar* statusBar_ = nullptr;
|
||||||
QTimer* reconnectTimer_ = nullptr;
|
QTimer* reconnectTimer_ = nullptr;
|
||||||
|
QTimer* heartbeatTimer_ = nullptr;
|
||||||
|
QTimer* frameRefreshTimer_ = nullptr;
|
||||||
TerminalConfig config_;
|
TerminalConfig config_;
|
||||||
int reconnectAttempts_ = 0;
|
|
||||||
QImage lastFrame_;
|
QImage lastFrame_;
|
||||||
bool frameDirty_ = false;
|
QImage latestFrame_;
|
||||||
|
QMutex frameMutex_;
|
||||||
|
uint64_t frameEpoch_ = 0;
|
||||||
|
uint64_t sessionEpoch_ = 0;
|
||||||
|
bool frameWakePending_ = false;
|
||||||
|
QPoint lastMousePosition_;
|
||||||
|
uint32_t keyboardLayout_ = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace vdi
|
} // namespace vdi
|
||||||
|
|||||||
203
terminal/kiosk-shell/src/monitor.cpp
Normal file
203
terminal/kiosk-shell/src/monitor.cpp
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
#include "monitor.h"
|
||||||
|
|
||||||
|
#include <signal.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <cerrno>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <ctime>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace vdi {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
Monitor* g_monitor = nullptr;
|
||||||
|
|
||||||
|
void onSignal(int) {
|
||||||
|
if (g_monitor) {
|
||||||
|
g_monitor->requestStop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主进程写心跳文件;Monitor 读取其修改时间判断是否假死
|
||||||
|
bool heartbeatAgeMs(const std::string& path, long long& ageMs) {
|
||||||
|
struct stat st {};
|
||||||
|
if (stat(path.c_str(), &st) != 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const auto now = std::chrono::system_clock::now();
|
||||||
|
const auto mtime = std::chrono::system_clock::from_time_t(st.st_mtime);
|
||||||
|
ageMs = std::chrono::duration_cast<std::chrono::milliseconds>(now - mtime).count();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Monitor::Monitor(MonitorConfig config) : config_(std::move(config)) {}
|
||||||
|
|
||||||
|
void Monitor::requestStop() {
|
||||||
|
stopRequested_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Monitor::log(const std::string& message) const {
|
||||||
|
if (!config_.verbose) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const std::time_t now = std::time(nullptr);
|
||||||
|
char timeBuf[32] = {0};
|
||||||
|
std::strftime(timeBuf, sizeof(timeBuf), "%Y-%m-%d %H:%M:%S", std::localtime(&now));
|
||||||
|
std::fprintf(stderr, "[monitor %s] %s\n", timeBuf, message.c_str());
|
||||||
|
std::fflush(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Monitor::isProcessAlive(pid_t pid) const {
|
||||||
|
if (pid <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return kill(pid, 0) == 0 || errno == EPERM;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结束主进程及其整棵进程树。
|
||||||
|
// 主进程通常还会再 fork 子进程(例如启动器拉起的串流客户端),
|
||||||
|
// 只杀直接子进程会留下孤儿进程继续占用显示设备和串流会话,
|
||||||
|
// 因此子进程启动时独立成组,结束时按进程组终止。
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
void killTree(pid_t pid, int sig) {
|
||||||
|
if (pid <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kill(-pid, sig) != 0) {
|
||||||
|
kill(pid, sig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool Monitor::isHung() const {
|
||||||
|
long long ageMs = 0;
|
||||||
|
if (!heartbeatAgeMs(config_.heartbeatFile, ageMs)) {
|
||||||
|
// 没有心跳文件:主进程还没写过心跳
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return ageMs > config_.heartbeatTimeoutMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
int Monitor::run(const std::string& command) {
|
||||||
|
if (command.empty()) {
|
||||||
|
log("用法: monitor -- <主进程命令行>");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
g_monitor = this;
|
||||||
|
signal(SIGTERM, onSignal);
|
||||||
|
signal(SIGINT, onSignal);
|
||||||
|
// 子进程退出时不发送 SIGPIPE,避免 Monitor 自身被杀
|
||||||
|
signal(SIGPIPE, SIG_IGN);
|
||||||
|
|
||||||
|
log("看门狗启动,监控: " + command);
|
||||||
|
|
||||||
|
pid_t child = -1;
|
||||||
|
auto startTime = std::chrono::steady_clock::now();
|
||||||
|
|
||||||
|
while (!stopRequested_) {
|
||||||
|
if (child <= 0) {
|
||||||
|
// 启动主进程
|
||||||
|
child = fork();
|
||||||
|
if (child == 0) {
|
||||||
|
// 子进程:恢复默认信号处理并 exec
|
||||||
|
signal(SIGTERM, SIG_DFL);
|
||||||
|
signal(SIGINT, SIG_DFL);
|
||||||
|
signal(SIGPIPE, SIG_DFL);
|
||||||
|
// 独立成组,便于结束整棵进程树
|
||||||
|
setpgid(0, 0);
|
||||||
|
execl("/bin/sh", "sh", "-c", command.c_str(), nullptr);
|
||||||
|
_exit(127);
|
||||||
|
}
|
||||||
|
if (child < 0) {
|
||||||
|
log(std::string("fork 失败: ") + std::strerror(errno));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
// 父子都调用一次 setpgid,避免父子竞争导致进程组 ID 未及时生效
|
||||||
|
setpgid(child, child);
|
||||||
|
startTime = std::chrono::steady_clock::now();
|
||||||
|
log("主进程已启动, pid=" + std::to_string(child));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(config_.checkIntervalMs));
|
||||||
|
|
||||||
|
if (stopRequested_) {
|
||||||
|
log("收到退出信号,终止主进程");
|
||||||
|
if (child > 0) {
|
||||||
|
killTree(child, SIGTERM);
|
||||||
|
// 等待退出,超时则强杀
|
||||||
|
for (int i = 0; i < 20 && isProcessAlive(child); ++i) {
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
||||||
|
}
|
||||||
|
if (isProcessAlive(child)) {
|
||||||
|
killTree(child, SIGKILL);
|
||||||
|
}
|
||||||
|
waitpid(child, nullptr, 0);
|
||||||
|
}
|
||||||
|
log("看门狗退出");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回收已退出的子进程
|
||||||
|
int status = 0;
|
||||||
|
const pid_t reaped = waitpid(child, &status, WNOHANG);
|
||||||
|
if (reaped == child) {
|
||||||
|
const int exitCode = WIFEXITED(status) ? WEXITSTATUS(status) : -1;
|
||||||
|
const int signalNum = WIFSIGNALED(status) ? WTERMSIG(status) : 0;
|
||||||
|
log("主进程已退出, code=" + std::to_string(exitCode) +
|
||||||
|
(signalNum ? (", signal=" + std::to_string(signalNum)) : ""));
|
||||||
|
|
||||||
|
if (++restartAttempts_ > config_.maxRestartAttempts) {
|
||||||
|
log("连续重启超过 " + std::to_string(config_.maxRestartAttempts) +
|
||||||
|
" 次,放弃重启(熔断)");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
log(std::to_string(config_.restartBackoffMs) + "ms 后重启");
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(config_.restartBackoffMs));
|
||||||
|
child = -1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 假死检测
|
||||||
|
if (isHung()) {
|
||||||
|
const auto elapsed =
|
||||||
|
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||||
|
std::chrono::steady_clock::now() - startTime)
|
||||||
|
.count();
|
||||||
|
// 启动宽限期内不判定假死
|
||||||
|
if (elapsed > config_.startupGraceMs) {
|
||||||
|
log("检测到主进程假死(心跳超时),执行 kill 并重启");
|
||||||
|
killTree(child, SIGKILL);
|
||||||
|
waitpid(child, &status, 0);
|
||||||
|
child = -1;
|
||||||
|
|
||||||
|
if (++restartAttempts_ > config_.maxRestartAttempts) {
|
||||||
|
log("连续重启超过 " + std::to_string(config_.maxRestartAttempts) +
|
||||||
|
" 次,放弃重启(熔断)");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(config_.restartBackoffMs));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 进程健康,重置重启计数
|
||||||
|
restartAttempts_ = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace vdi
|
||||||
65
terminal/kiosk-shell/src/monitor.h
Normal file
65
terminal/kiosk-shell/src/monitor.h
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
// 看门狗 Monitor(双进程架构)
|
||||||
|
//
|
||||||
|
// 对应《VDI 云桌面软件开发方案》与会议纪要:
|
||||||
|
// "必须部署独立的 Monitor(看门狗)程序监控主进程状态,
|
||||||
|
// 主进程假死或崩溃时由 Monitor 执行 kill 操作并重启"
|
||||||
|
// "小型 ARM 设备长时间运行必然出现卡顿或假死,无守护机制将导致用户体验严重受损"
|
||||||
|
//
|
||||||
|
// 判定主进程假死的方式:
|
||||||
|
// 1. 进程不存在 -> 崩溃
|
||||||
|
// 2. 心跳文件超时 -> 假死(主进程定期写心跳,Monitor 检查更新时间)
|
||||||
|
// 3. 连续启动失败 -> 熔断,放弃重启避免无限重启循环
|
||||||
|
//
|
||||||
|
// 用法:
|
||||||
|
// monitor -- <主进程命令行...>
|
||||||
|
// 例:monitor -- /home/armbian/vdi-terminal/bin/kiosk-shell
|
||||||
|
|
||||||
|
#ifndef KIOSK_SHELL_MONITOR_H
|
||||||
|
#define KIOSK_SHELL_MONITOR_H
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace vdi {
|
||||||
|
|
||||||
|
struct MonitorConfig {
|
||||||
|
// 心跳文件路径;主进程每 heartbeatInterval 写一次
|
||||||
|
std::string heartbeatFile = "/tmp/vdi-kiosk.heartbeat";
|
||||||
|
// 心跳超时判定(毫秒)
|
||||||
|
int heartbeatTimeoutMs = 15000;
|
||||||
|
// 检查间隔(毫秒)
|
||||||
|
int checkIntervalMs = 3000;
|
||||||
|
// 心跳文件超过该时间认为进程从未启动
|
||||||
|
int startupGraceMs = 20000;
|
||||||
|
// 连续失败多少次后熔断
|
||||||
|
int maxRestartAttempts = 5;
|
||||||
|
// 两次重启之间的间隔(毫秒)
|
||||||
|
int restartBackoffMs = 3000;
|
||||||
|
// 是否在每条日志后写 stdout
|
||||||
|
bool verbose = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
class Monitor {
|
||||||
|
public:
|
||||||
|
explicit Monitor(MonitorConfig config = MonitorConfig());
|
||||||
|
|
||||||
|
// 运行看门狗主循环,阻塞直到被信号中断
|
||||||
|
int run(const std::string& command);
|
||||||
|
|
||||||
|
// 请求退出(供信号处理调用)
|
||||||
|
void requestStop();
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool isProcessAlive(pid_t pid) const;
|
||||||
|
// 主进程是否被认为假死
|
||||||
|
bool isHung() const;
|
||||||
|
void log(const std::string& message) const;
|
||||||
|
|
||||||
|
MonitorConfig config_;
|
||||||
|
volatile bool stopRequested_ = false;
|
||||||
|
int restartAttempts_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace vdi
|
||||||
|
|
||||||
|
#endif // KIOSK_SHELL_MONITOR_H
|
||||||
66
terminal/kiosk-shell/src/monitor_main.cpp
Normal file
66
terminal/kiosk-shell/src/monitor_main.cpp
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
// 看门狗 Monitor 入口
|
||||||
|
//
|
||||||
|
// 用法:vdi-kiosk-monitor -- <主进程命令行>
|
||||||
|
// 例: vdi-kiosk-monitor -- /home/armbian/vdi-terminal/bin/kiosk-shell
|
||||||
|
//
|
||||||
|
// 对应会议纪要要求:
|
||||||
|
// "必须部署独立的 Monitor(看门狗)程序监控主进程状态,
|
||||||
|
// 主进程假死或崩溃时由 Monitor 执行 kill 操作并重启"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "monitor.h"
|
||||||
|
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
if (argc < 2) {
|
||||||
|
std::fprintf(stderr, "用法: vdi-kiosk-monitor [--verbose] -- <主进程命令行>\n");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
int idx = 1;
|
||||||
|
vdi::MonitorConfig config;
|
||||||
|
config.verbose = true;
|
||||||
|
|
||||||
|
while (idx < argc && std::string(argv[idx]).rfind("--", 0) == 0) {
|
||||||
|
const std::string opt = argv[idx];
|
||||||
|
if (opt == "--") {
|
||||||
|
++idx;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (opt == "--quiet") {
|
||||||
|
config.verbose = false;
|
||||||
|
} else if (opt == "--verbose") {
|
||||||
|
config.verbose = true;
|
||||||
|
} else if (opt == "--heartbeat-timeout" && idx + 1 < argc) {
|
||||||
|
config.heartbeatTimeoutMs = std::atoi(argv[++idx]);
|
||||||
|
} else if (opt == "--check-interval" && idx + 1 < argc) {
|
||||||
|
config.checkIntervalMs = std::atoi(argv[++idx]);
|
||||||
|
} else if (opt == "--max-restart" && idx + 1 < argc) {
|
||||||
|
config.maxRestartAttempts = std::atoi(argv[++idx]);
|
||||||
|
} else if (opt == "--heartbeat-file" && idx + 1 < argc) {
|
||||||
|
config.heartbeatFile = argv[++idx];
|
||||||
|
} else {
|
||||||
|
// 未知选项视为主进程命令行的起点
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
++idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idx >= argc) {
|
||||||
|
std::fprintf(stderr, "缺少主进程命令行\n");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拼出主进程命令行
|
||||||
|
std::string command;
|
||||||
|
for (int i = idx; i < argc; ++i) {
|
||||||
|
if (i > idx) {
|
||||||
|
command += " ";
|
||||||
|
}
|
||||||
|
command += argv[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
vdi::Monitor monitor(config);
|
||||||
|
return monitor.run(command);
|
||||||
|
}
|
||||||
138
terminal/kiosk-shell/src/moonlight_session.cpp
Normal file
138
terminal/kiosk-shell/src/moonlight_session.cpp
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
#include "moonlight_session.h"
|
||||||
|
|
||||||
|
#include <QDate>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QTextStream>
|
||||||
|
|
||||||
|
namespace vdi {
|
||||||
|
|
||||||
|
MoonlightSession::MoonlightSession(QObject* parent) : QObject(parent) {}
|
||||||
|
|
||||||
|
MoonlightSession::~MoonlightSession() {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MoonlightSession::setOptions(const Options& options) {
|
||||||
|
options_ = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MoonlightSession::isRunning() const {
|
||||||
|
return process_ && process_->state() != QProcess::NotRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MoonlightSession::start(const ConnectionTicket& ticket, StateCallback callback) {
|
||||||
|
if (isRunning()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
callback_ = std::move(callback);
|
||||||
|
|
||||||
|
// moonlight-qt 需要 X11 会话环境;由 kiosk-shell 继承当前 DISPLAY/XAUTHORITY
|
||||||
|
process_ = new QProcess(this);
|
||||||
|
process_->setProgram(QStringLiteral("moonlight-qt"));
|
||||||
|
process_->setProcessChannelMode(QProcess::MergedChannels);
|
||||||
|
|
||||||
|
QStringList args;
|
||||||
|
args << QStringLiteral("stream")
|
||||||
|
<< ticket.endpoint
|
||||||
|
<< ticket.app
|
||||||
|
<< QStringLiteral("--resolution")
|
||||||
|
<< QStringLiteral("%1x%2").arg(ticket.width).arg(ticket.height)
|
||||||
|
<< QStringLiteral("--fps") << QString::number(ticket.fps)
|
||||||
|
<< QStringLiteral("--bitrate") << QString::number(20000)
|
||||||
|
<< QStringLiteral("--display-mode") << QStringLiteral("fullscreen")
|
||||||
|
<< QStringLiteral("--video-codec") << options_.videoCodec
|
||||||
|
<< QStringLiteral("--video-decoder") << options_.decoder
|
||||||
|
<< QStringLiteral("--absolute-mouse")
|
||||||
|
<< QStringLiteral("--keep-awake");
|
||||||
|
if (options_.vsync) {
|
||||||
|
args << QStringLiteral("--vsync");
|
||||||
|
} else {
|
||||||
|
args << QStringLiteral("--no-vsync");
|
||||||
|
}
|
||||||
|
if (options_.framePacing) {
|
||||||
|
args << QStringLiteral("--frame-pacing");
|
||||||
|
} else {
|
||||||
|
args << QStringLiteral("--no-frame-pacing");
|
||||||
|
}
|
||||||
|
if (options_.performanceOverlay) {
|
||||||
|
args << QStringLiteral("--performance-overlay");
|
||||||
|
}
|
||||||
|
if (options_.audioOnHost) {
|
||||||
|
args << QStringLiteral("--audio-on-host");
|
||||||
|
} else {
|
||||||
|
args << QStringLiteral("--no-audio-on-host");
|
||||||
|
}
|
||||||
|
process_->setArguments(args);
|
||||||
|
|
||||||
|
// 日志追加到 moonlight-yyyyMMdd.log,便于现场排查
|
||||||
|
const QString logDir =
|
||||||
|
QDir::homePath() + QStringLiteral("/vdi-terminal/logs");
|
||||||
|
QDir().mkpath(logDir);
|
||||||
|
const QString logPath = logDir + QStringLiteral("/moonlight-%1.log")
|
||||||
|
.arg(QDate::currentDate().toString(QStringLiteral("yyyyMMdd")));
|
||||||
|
|
||||||
|
connect(process_, &QProcess::finished, this, &MoonlightSession::onProcessFinished);
|
||||||
|
connect(process_, &QProcess::errorOccurred, this, &MoonlightSession::onProcessError);
|
||||||
|
connect(process_, &QProcess::readyReadStandardOutput, this, [this, logPath]() {
|
||||||
|
QFile log(logPath);
|
||||||
|
if (log.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) {
|
||||||
|
QTextStream(&log) << QString::fromUtf8(process_->readAllStandardOutput());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (callback_) {
|
||||||
|
callback_(true, QStringLiteral("串流进程已启动"));
|
||||||
|
}
|
||||||
|
process_->start();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MoonlightSession::onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus) {
|
||||||
|
QString message;
|
||||||
|
if (exitStatus == QProcess::CrashExit) {
|
||||||
|
message = QStringLiteral("串流异常退出 (code=%1)").arg(exitCode);
|
||||||
|
} else {
|
||||||
|
message = QStringLiteral("串流已结束 (code=%1)").arg(exitCode);
|
||||||
|
}
|
||||||
|
process_->deleteLater();
|
||||||
|
process_ = nullptr;
|
||||||
|
if (callback_) {
|
||||||
|
callback_(false, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MoonlightSession::onProcessError(QProcess::ProcessError error) {
|
||||||
|
QString message;
|
||||||
|
switch (error) {
|
||||||
|
case QProcess::FailedToStart:
|
||||||
|
message = QStringLiteral("无法启动 moonlight-qt,请检查安装");
|
||||||
|
break;
|
||||||
|
case QProcess::Crashed:
|
||||||
|
return; // 由 finished(CrashExit) 统一上报
|
||||||
|
default:
|
||||||
|
message = QStringLiteral("串流进程错误 (%1)").arg(static_cast<int>(error));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (callback_) {
|
||||||
|
callback_(false, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MoonlightSession::stop() {
|
||||||
|
if (!process_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QProcess* proc = process_;
|
||||||
|
process_ = nullptr; // 先断开自身引用,避免 finished 回调再触发状态迁移
|
||||||
|
disconnect(proc, nullptr, this, nullptr);
|
||||||
|
proc->terminate();
|
||||||
|
if (!proc->waitForFinished(2000)) {
|
||||||
|
proc->kill();
|
||||||
|
proc->waitForFinished(1000);
|
||||||
|
}
|
||||||
|
proc->deleteLater();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace vdi
|
||||||
59
terminal/kiosk-shell/src/moonlight_session.h
Normal file
59
terminal/kiosk-shell/src/moonlight_session.h
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
// Moonlight 会话(外部进程)
|
||||||
|
//
|
||||||
|
// 以 QProcess 参数数组直启 moonlight-qt(不拼 shell 命令),
|
||||||
|
// 串流期间 moonlight-qt 独占全屏显示;进程退出后回调 MainWindow 返回桌面列表。
|
||||||
|
// 参数来自 ConnectionTicket,不再读取全局静态配置。
|
||||||
|
|
||||||
|
#ifndef KIOSK_SHELL_MOONLIGHT_SESSION_H
|
||||||
|
#define KIOSK_SHELL_MOONLIGHT_SESSION_H
|
||||||
|
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QString>
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
#include "desktop_session.h"
|
||||||
|
|
||||||
|
namespace vdi {
|
||||||
|
|
||||||
|
class MoonlightSession : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
struct Options {
|
||||||
|
// 低延迟开关(实测 A/B 后定版)
|
||||||
|
bool vsync = true;
|
||||||
|
bool framePacing = true;
|
||||||
|
bool performanceOverlay = false;
|
||||||
|
bool audioOnHost = true;
|
||||||
|
QString videoCodec = QStringLiteral("H.264");
|
||||||
|
QString decoder = QStringLiteral("software"); // RK3528 无可用 V4L2 硬解设备
|
||||||
|
};
|
||||||
|
|
||||||
|
using StateCallback = std::function<void(bool running, const QString& message)>;
|
||||||
|
|
||||||
|
explicit MoonlightSession(QObject* parent = nullptr);
|
||||||
|
~MoonlightSession() override;
|
||||||
|
|
||||||
|
void setOptions(const Options& options);
|
||||||
|
|
||||||
|
// 启动串流;成功返回 true(不代表串流已建立,仅代表进程已拉起)
|
||||||
|
bool start(const ConnectionTicket& ticket, StateCallback callback);
|
||||||
|
|
||||||
|
// 终止串流(管理菜单/退出登录时调用)
|
||||||
|
void stop();
|
||||||
|
|
||||||
|
bool isRunning() const;
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus);
|
||||||
|
void onProcessError(QProcess::ProcessError error);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QProcess* process_ = nullptr;
|
||||||
|
Options options_;
|
||||||
|
StateCallback callback_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace vdi
|
||||||
|
|
||||||
|
#endif // KIOSK_SHELL_MOONLIGHT_SESSION_H
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
#include <freerdp/input.h>
|
||||||
|
|
||||||
#include "rdp_session.h"
|
#include "rdp_session.h"
|
||||||
|
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
@ -82,6 +84,23 @@ void RdpSession::stop() {
|
|||||||
context_ = nullptr;
|
context_ = nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool RdpSession::sendKeyEvent(bool pressed, bool repeat, uint32_t rdpScanCode) {
|
||||||
|
std::lock_guard<std::mutex> lock(inputMutex_);
|
||||||
|
if (!running_.load() || !connected_.load() || !context_ || !context_->input) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return freerdp_input_send_keyboard_event_ex(context_->input, pressed ? TRUE : FALSE,
|
||||||
|
repeat ? TRUE : FALSE, rdpScanCode) == TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RdpSession::sendMouseEvent(uint16_t flags, uint16_t x, uint16_t y) {
|
||||||
|
std::lock_guard<std::mutex> lock(inputMutex_);
|
||||||
|
if (!running_.load() || !connected_.load() || !context_ || !context_->input) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return freerdp_input_send_mouse_event(context_->input, flags, x, y) == TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
void RdpSession::applySettings(rdpSettings* settings) {
|
void RdpSession::applySettings(rdpSettings* settings) {
|
||||||
// FreeRDP 3 的键名与 2.x 不同:
|
// FreeRDP 3 的键名与 2.x 不同:
|
||||||
// FreeRDP_ServerHostname / FreeRDP_Username / FreeRDP_DesktopWidth ...
|
// FreeRDP_ServerHostname / FreeRDP_Username / FreeRDP_DesktopWidth ...
|
||||||
@ -95,6 +114,9 @@ void RdpSession::applySettings(rdpSettings* settings) {
|
|||||||
if (!options_.domain.empty()) {
|
if (!options_.domain.empty()) {
|
||||||
freerdp_settings_set_string(settings, FreeRDP_Domain, options_.domain.c_str());
|
freerdp_settings_set_string(settings, FreeRDP_Domain, options_.domain.c_str());
|
||||||
}
|
}
|
||||||
|
if (options_.keyboardLayout != 0) {
|
||||||
|
freerdp_settings_set_uint32(settings, FreeRDP_KeyboardLayout, options_.keyboardLayout);
|
||||||
|
}
|
||||||
freerdp_settings_set_uint32(settings, FreeRDP_DesktopWidth,
|
freerdp_settings_set_uint32(settings, FreeRDP_DesktopWidth,
|
||||||
static_cast<UINT32>(options_.width));
|
static_cast<UINT32>(options_.width));
|
||||||
freerdp_settings_set_uint32(settings, FreeRDP_DesktopHeight,
|
freerdp_settings_set_uint32(settings, FreeRDP_DesktopHeight,
|
||||||
@ -180,7 +202,6 @@ void RdpSession::run() {
|
|||||||
SessionRegistry::remove(instance);
|
SessionRegistry::remove(instance);
|
||||||
freerdp_context_free(instance);
|
freerdp_context_free(instance);
|
||||||
freerdp_free(instance);
|
freerdp_free(instance);
|
||||||
freerdp_settings_free(settings);
|
|
||||||
instance_ = nullptr;
|
instance_ = nullptr;
|
||||||
context_ = nullptr;
|
context_ = nullptr;
|
||||||
running_.store(false);
|
running_.store(false);
|
||||||
@ -202,7 +223,6 @@ void RdpSession::run() {
|
|||||||
SessionRegistry::remove(instance);
|
SessionRegistry::remove(instance);
|
||||||
freerdp_context_free(instance);
|
freerdp_context_free(instance);
|
||||||
freerdp_free(instance);
|
freerdp_free(instance);
|
||||||
freerdp_settings_free(settings);
|
|
||||||
instance_ = nullptr;
|
instance_ = nullptr;
|
||||||
context_ = nullptr;
|
context_ = nullptr;
|
||||||
running_.store(false);
|
running_.store(false);
|
||||||
@ -243,11 +263,29 @@ BOOL RdpSession::onPostConnect(freerdp* instance) {
|
|||||||
}
|
}
|
||||||
return FALSE;
|
return FALSE;
|
||||||
}
|
}
|
||||||
// 挂载 GFX 渲染管线:把解码后的帧通过回调交给 Qt
|
// 挂载 GFX 渲染管线。
|
||||||
|
//
|
||||||
|
// 重要:必须使用默认的 gdi_graphics_pipeline_init(等价于
|
||||||
|
// init_ex(gdi, gfx, nullptr, nullptr, nullptr)),让 FreeRDP 自己把
|
||||||
|
// GFX surface 合成进 gdi->primary_buffer。
|
||||||
|
//
|
||||||
|
// 若传入自定义 UpdateSurfaceArea 回调,FreeRDP 会认为"输出已由客户端更新"
|
||||||
|
// (见 gdi_UpdateSurfaces 中对 handleInUpdateSurfaceArea 的判断),
|
||||||
|
// 由客户端承担合成责任。此时我们若只发帧不合成,取到的就是尚未合成的
|
||||||
|
// 中间画面,表现为画面分块填充、横向白带扫过。
|
||||||
|
//
|
||||||
|
// 同时不能写 gfx->custom:gdi_graphics_pipeline_init 内部会把它设为 gdi。
|
||||||
if (instance->context && instance->context->gdi && instance->context->gdi->gfx) {
|
if (instance->context && instance->context->gdi && instance->context->gdi->gfx) {
|
||||||
instance->context->gdi->gfx->custom = self;
|
RdpgfxClientContext* gfx = instance->context->gdi->gfx;
|
||||||
gdi_graphics_pipeline_init_ex(instance->context->gdi, instance->context->gdi->gfx, nullptr,
|
if (!gdi_graphics_pipeline_init(instance->context->gdi, gfx)) {
|
||||||
nullptr, onUpdateSurfaceArea);
|
if (self->callbacks_.onState) {
|
||||||
|
self->callbacks_.onState(false, "GFX 管线初始化失败");
|
||||||
|
}
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
self->originalEndFrame_ = gfx->EndFrame;
|
||||||
|
self->gfxPipelineActive_ = self->originalEndFrame_ != nullptr;
|
||||||
|
gfx->EndFrame = onEndFrame;
|
||||||
}
|
}
|
||||||
self->connected_.store(true);
|
self->connected_.store(true);
|
||||||
if (self->callbacks_.onState) {
|
if (self->callbacks_.onState) {
|
||||||
@ -295,32 +333,36 @@ BOOL RdpSession::onBitmapUpdate(rdpContext* context, const BITMAP_UPDATE* bitmap
|
|||||||
}
|
}
|
||||||
|
|
||||||
BOOL RdpSession::onEndPaint(rdpContext* context) {
|
BOOL RdpSession::onEndPaint(rdpContext* context) {
|
||||||
// 一帧绘制完成,交给 Qt 刷新
|
|
||||||
if (context && context->instance) {
|
if (context && context->instance) {
|
||||||
RdpSession* self = sessionOf(context->instance);
|
RdpSession* self = sessionOf(context->instance);
|
||||||
if (self) {
|
if (self && !self->gfxPipelineActive_) {
|
||||||
|
// 传统 Bitmap/RDP 路径没有 GFX EndFrame,在 EndPaint 提交。
|
||||||
self->emitFrame();
|
self->emitFrame();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return TRUE;
|
return TRUE;
|
||||||
}
|
}
|
||||||
|
|
||||||
UINT RdpSession::onUpdateSurfaceArea(RdpgfxClientContext* gfxContext, UINT16 surfaceId,
|
UINT RdpSession::onEndFrame(RdpgfxClientContext* gfxContext,
|
||||||
UINT32 nrRects, const RECTANGLE_16* rects) {
|
const RDPGFX_END_FRAME_PDU* endFrame) {
|
||||||
(void)surfaceId;
|
if (!gfxContext || !gfxContext->custom) {
|
||||||
(void)nrRects;
|
return ERROR_INVALID_DATA;
|
||||||
(void)rects;
|
}
|
||||||
|
auto* gdi = static_cast<rdpGdi*>(gfxContext->custom);
|
||||||
|
if (!gdi->context || !gdi->context->instance) {
|
||||||
|
return ERROR_INVALID_DATA;
|
||||||
|
}
|
||||||
|
RdpSession* self = sessionOf(gdi->context->instance);
|
||||||
|
if (!self || !self->originalEndFrame_) {
|
||||||
|
return ERROR_INVALID_DATA;
|
||||||
|
}
|
||||||
|
|
||||||
if (!gfxContext) {
|
const UINT status = self->originalEndFrame_(gfxContext, endFrame);
|
||||||
return ERROR_SUCCESS;
|
if (status == CHANNEL_RC_OK) {
|
||||||
|
// originalEndFrame_ 已完成 gdi_UpdateSurfaces,primary_buffer 此刻才是完整帧。
|
||||||
|
self->emitFrame();
|
||||||
}
|
}
|
||||||
// custom 字段在建立管线时写入,指向 C++ 会话对象
|
return status;
|
||||||
auto* self = static_cast<RdpSession*>(gfxContext->custom);
|
|
||||||
if (!self) {
|
|
||||||
return ERROR_SUCCESS;
|
|
||||||
}
|
|
||||||
self->emitFrame();
|
|
||||||
return ERROR_SUCCESS;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace vdi
|
} // namespace vdi
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
|
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
#include <mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
|
|
||||||
@ -35,6 +36,7 @@ struct RdpOptions {
|
|||||||
std::string username;
|
std::string username;
|
||||||
std::string password;
|
std::string password;
|
||||||
std::string domain;
|
std::string domain;
|
||||||
|
uint32_t keyboardLayout = 0;
|
||||||
int width = 1920;
|
int width = 1920;
|
||||||
int height = 1440;
|
int height = 1440;
|
||||||
int bpp = 32;
|
int bpp = 32;
|
||||||
@ -68,6 +70,10 @@ public:
|
|||||||
void stop();
|
void stop();
|
||||||
bool isRunning() const { return running_.load(); }
|
bool isRunning() const { return running_.load(); }
|
||||||
|
|
||||||
|
// 向远程会话注入键盘和鼠标事件;由 GUI 线程调用。
|
||||||
|
bool sendKeyEvent(bool pressed, bool repeat, uint32_t rdpScanCode);
|
||||||
|
bool sendMouseEvent(uint16_t flags, uint16_t x, uint16_t y);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void run();
|
void run();
|
||||||
void applySettings(rdpSettings* settings);
|
void applySettings(rdpSettings* settings);
|
||||||
@ -78,11 +84,12 @@ private:
|
|||||||
// NLA 认证回调:把配置中的账号/密码交给 FreeRDP
|
// NLA 认证回调:把配置中的账号/密码交给 FreeRDP
|
||||||
static BOOL onAuthenticateEx(freerdp* instance, char** username, char** password, char** domain,
|
static BOOL onAuthenticateEx(freerdp* instance, char** username, char** password, char** domain,
|
||||||
rdp_auth_reason reason);
|
rdp_auth_reason reason);
|
||||||
// GDI 输出回调:位图更新与绘制完成
|
// GDI 输出回调:传统位图路径的更新完成。
|
||||||
static BOOL onBitmapUpdate(rdpContext* context, const BITMAP_UPDATE* bitmap);
|
static BOOL onBitmapUpdate(rdpContext* context, const BITMAP_UPDATE* bitmap);
|
||||||
static BOOL onEndPaint(rdpContext* context);
|
static BOOL onEndPaint(rdpContext* context);
|
||||||
static UINT onUpdateSurfaceArea(RdpgfxClientContext* context, UINT16 surfaceId, UINT32 nrRects,
|
// GFX 完整帧边界:先让 FreeRDP 完成所有 surface 合成,再提交一次。
|
||||||
const RECTANGLE_16* rects);
|
static UINT onEndFrame(RdpgfxClientContext* context,
|
||||||
|
const RDPGFX_END_FRAME_PDU* endFrame);
|
||||||
|
|
||||||
// 把当前 GDI 帧缓冲交给上层
|
// 把当前 GDI 帧缓冲交给上层
|
||||||
void emitFrame();
|
void emitFrame();
|
||||||
@ -94,11 +101,14 @@ private:
|
|||||||
freerdp* instance_ = nullptr;
|
freerdp* instance_ = nullptr;
|
||||||
rdpContext* context_ = nullptr;
|
rdpContext* context_ = nullptr;
|
||||||
std::thread worker_;
|
std::thread worker_;
|
||||||
|
mutable std::mutex inputMutex_;
|
||||||
uint32_t frameWidth_ = 0;
|
uint32_t frameWidth_ = 0;
|
||||||
uint32_t frameHeight_ = 0;
|
uint32_t frameHeight_ = 0;
|
||||||
uint32_t frameStride_ = 0;
|
uint32_t frameStride_ = 0;
|
||||||
uint32_t frameFormat_ = 0;
|
uint32_t frameFormat_ = 0;
|
||||||
bool frameDirty_ = false;
|
bool frameDirty_ = false;
|
||||||
|
pcRdpgfxEndFrame originalEndFrame_ = nullptr;
|
||||||
|
bool gfxPipelineActive_ = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace vdi
|
} // namespace vdi
|
||||||
|
|||||||
@ -4,12 +4,14 @@ namespace vdi {
|
|||||||
|
|
||||||
const char* stateToString(State s) {
|
const char* stateToString(State s) {
|
||||||
switch (s) {
|
switch (s) {
|
||||||
case State::kBoot: return "BOOT";
|
case State::kBoot: return "BOOT";
|
||||||
case State::kConfig: return "CONFIG";
|
case State::kConfig: return "CONFIG";
|
||||||
case State::kConnecting: return "CONNECTING";
|
case State::kAuthenticating: return "AUTHENTICATING";
|
||||||
case State::kSession: return "SESSION";
|
case State::kDesktopList: return "DESKTOP_LIST";
|
||||||
case State::kReconnect: return "RECONNECT";
|
case State::kConnecting: return "CONNECTING";
|
||||||
case State::kError: return "ERROR";
|
case State::kSession: return "SESSION";
|
||||||
|
case State::kReconnect: return "RECONNECT";
|
||||||
|
case State::kError: return "ERROR";
|
||||||
}
|
}
|
||||||
return "UNKNOWN";
|
return "UNKNOWN";
|
||||||
}
|
}
|
||||||
@ -21,15 +23,24 @@ bool StateMachine::isLegalTransition(State from, State to) {
|
|||||||
case State::kBoot:
|
case State::kBoot:
|
||||||
return to == State::kConfig || to == State::kError;
|
return to == State::kConfig || to == State::kError;
|
||||||
case State::kConfig:
|
case State::kConfig:
|
||||||
return to == State::kConnecting || to == State::kError;
|
return to == State::kAuthenticating || to == State::kConnecting ||
|
||||||
case State::kConnecting:
|
to == State::kError;
|
||||||
return to == State::kSession || to == State::kReconnect || to == State::kError;
|
case State::kAuthenticating:
|
||||||
case State::kSession:
|
return to == State::kDesktopList || to == State::kConfig || to == State::kError;
|
||||||
return to == State::kReconnect || to == State::kConfig || to == State::kError;
|
case State::kDesktopList:
|
||||||
case State::kReconnect:
|
|
||||||
return to == State::kConnecting || to == State::kConfig || to == State::kError;
|
return to == State::kConnecting || to == State::kConfig || to == State::kError;
|
||||||
|
case State::kConnecting:
|
||||||
|
return to == State::kSession || to == State::kReconnect ||
|
||||||
|
to == State::kDesktopList || to == State::kError;
|
||||||
|
case State::kSession:
|
||||||
|
return to == State::kReconnect || to == State::kDesktopList ||
|
||||||
|
to == State::kConfig || to == State::kError;
|
||||||
|
case State::kReconnect:
|
||||||
|
return to == State::kConnecting || to == State::kDesktopList ||
|
||||||
|
to == State::kConfig || to == State::kError;
|
||||||
case State::kError:
|
case State::kError:
|
||||||
return to == State::kConfig || to == State::kConnecting;
|
return to == State::kConfig || to == State::kDesktopList ||
|
||||||
|
to == State::kConnecting;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -47,7 +58,8 @@ bool StateMachine::transitionTo(State next, const QString& detail) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool StateMachine::canConnect() const {
|
bool StateMachine::canConnect() const {
|
||||||
return current_ == State::kConfig || current_ == State::kReconnect || current_ == State::kError;
|
return current_ == State::kDesktopList || current_ == State::kConfig ||
|
||||||
|
current_ == State::kReconnect || current_ == State::kError;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool StateMachine::canDisconnect() const {
|
bool StateMachine::canDisconnect() const {
|
||||||
|
|||||||
@ -22,12 +22,14 @@
|
|||||||
namespace vdi {
|
namespace vdi {
|
||||||
|
|
||||||
enum class State {
|
enum class State {
|
||||||
kBoot, // 启动中
|
kBoot, // 启动中
|
||||||
kConfig, // 首配 / 登录页
|
kConfig, // 首配 / 登录页
|
||||||
kConnecting, // 连接中
|
kAuthenticating, // 正在认证
|
||||||
kSession, // 会话中
|
kDesktopList, // 桌面列表(登录成功后)
|
||||||
kReconnect, // 断线等待重连
|
kConnecting, // 连接中
|
||||||
kError, // 错误
|
kSession, // 会话中
|
||||||
|
kReconnect, // 断线等待重连
|
||||||
|
kError, // 错误
|
||||||
};
|
};
|
||||||
|
|
||||||
const char* stateToString(State s);
|
const char* stateToString(State s);
|
||||||
|
|||||||
8
terminal/kiosk-shell/vdi-kiosk.desktop
Normal file
8
terminal/kiosk-shell/vdi-kiosk.desktop
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=VDI Cloud Desktop (Kiosk)
|
||||||
|
Comment=产品唯一自启入口:看门狗守护 kiosk-shell(登录 -> 桌面列表 -> 会话)
|
||||||
|
Exec=/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor -- /home/armbian/vdi-terminal/bin/kiosk-shell
|
||||||
|
Terminal=false
|
||||||
|
X-GNOME-Autostart-enabled=true
|
||||||
|
X-GNOME-Autostart-Delay=2
|
||||||
9
terminal/kiosk-shell/vdi-moonlight.desktop
Normal file
9
terminal/kiosk-shell/vdi-moonlight.desktop
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=VDI Moonlight (debug)
|
||||||
|
Comment=独立 Moonlight 调试自启项;产品流程禁用,会话由 kiosk-shell 桌面列表拉起
|
||||||
|
Exec=/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor --heartbeat-file /tmp/vdi-moonlight.heartbeat --max-restart 200 -- /home/armbian/vdi-terminal/bin/vdi-moonlight
|
||||||
|
Terminal=false
|
||||||
|
Hidden=true
|
||||||
|
X-GNOME-Autostart-enabled=false
|
||||||
|
X-GNOME-Autostart-Delay=5
|
||||||
Loading…
Reference in New Issue
Block a user