From 1ae4be1078f30410c46d45a21cbd80c6274efdd7 Mon Sep 17 00:00:00 2001 From: songyiming <1000000008@qq.com> Date: Wed, 28 Oct 2026 20:52:20 +0800 Subject: [PATCH] =?UTF-8?q?init:=20VDI=20=E4=BA=91=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=EF=BC=88kiosk-shell=20=E7=99=BB=E5=BD=95/?= =?UTF-8?q?=E6=A1=8C=E9=9D=A2=E5=88=97=E8=A1=A8/Moonlight/RDP/=E7=9C=8B?= =?UTF-8?q?=E9=97=A8=E7=8B=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + terminal/kiosk-shell/CMakeLists.txt | 58 ++ terminal/kiosk-shell/etc/moonlight.conf | 39 ++ terminal/kiosk-shell/scripts/vdi-moonlight | 269 ++++++++ terminal/kiosk-shell/src/config.cpp | 122 ++++ terminal/kiosk-shell/src/config.h | 53 ++ .../kiosk-shell/src/desktop_list_page.cpp | 184 +++++ terminal/kiosk-shell/src/desktop_list_page.h | 57 ++ terminal/kiosk-shell/src/desktop_service.cpp | 97 +++ terminal/kiosk-shell/src/desktop_service.h | 45 ++ terminal/kiosk-shell/src/desktop_session.h | 70 ++ terminal/kiosk-shell/src/login_page.cpp | 218 ++++++ terminal/kiosk-shell/src/login_page.h | 61 ++ terminal/kiosk-shell/src/main.cpp | 22 + terminal/kiosk-shell/src/main_window.cpp | 647 ++++++++++++++++++ terminal/kiosk-shell/src/main_window.h | 110 +++ terminal/kiosk-shell/src/monitor.cpp | 203 ++++++ terminal/kiosk-shell/src/monitor.h | 65 ++ terminal/kiosk-shell/src/monitor_main.cpp | 66 ++ .../kiosk-shell/src/moonlight_session.cpp | 138 ++++ terminal/kiosk-shell/src/moonlight_session.h | 59 ++ terminal/kiosk-shell/src/rdp_session.cpp | 368 ++++++++++ terminal/kiosk-shell/src/rdp_session.h | 116 ++++ terminal/kiosk-shell/src/state_machine.cpp | 73 ++ terminal/kiosk-shell/src/state_machine.h | 64 ++ terminal/kiosk-shell/vdi-kiosk.desktop | 8 + terminal/kiosk-shell/vdi-moonlight.desktop | 9 + 27 files changed, 3224 insertions(+) create mode 100644 .gitignore create mode 100644 terminal/kiosk-shell/CMakeLists.txt create mode 100644 terminal/kiosk-shell/etc/moonlight.conf create mode 100644 terminal/kiosk-shell/scripts/vdi-moonlight create mode 100644 terminal/kiosk-shell/src/config.cpp create mode 100644 terminal/kiosk-shell/src/config.h create mode 100644 terminal/kiosk-shell/src/desktop_list_page.cpp create mode 100644 terminal/kiosk-shell/src/desktop_list_page.h create mode 100644 terminal/kiosk-shell/src/desktop_service.cpp create mode 100644 terminal/kiosk-shell/src/desktop_service.h create mode 100644 terminal/kiosk-shell/src/desktop_session.h create mode 100644 terminal/kiosk-shell/src/login_page.cpp create mode 100644 terminal/kiosk-shell/src/login_page.h create mode 100644 terminal/kiosk-shell/src/main.cpp create mode 100644 terminal/kiosk-shell/src/main_window.cpp create mode 100644 terminal/kiosk-shell/src/main_window.h create mode 100644 terminal/kiosk-shell/src/monitor.cpp create mode 100644 terminal/kiosk-shell/src/monitor.h create mode 100644 terminal/kiosk-shell/src/monitor_main.cpp create mode 100644 terminal/kiosk-shell/src/moonlight_session.cpp create mode 100644 terminal/kiosk-shell/src/moonlight_session.h create mode 100644 terminal/kiosk-shell/src/rdp_session.cpp create mode 100644 terminal/kiosk-shell/src/rdp_session.h create mode 100644 terminal/kiosk-shell/src/state_machine.cpp create mode 100644 terminal/kiosk-shell/src/state_machine.h create mode 100644 terminal/kiosk-shell/vdi-kiosk.desktop create mode 100644 terminal/kiosk-shell/vdi-moonlight.desktop diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d7d74c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +build/ +*.o +*.so diff --git a/terminal/kiosk-shell/CMakeLists.txt b/terminal/kiosk-shell/CMakeLists.txt new file mode 100644 index 0000000..94f39de --- /dev/null +++ b/terminal/kiosk-shell/CMakeLists.txt @@ -0,0 +1,58 @@ +cmake_minimum_required(VERSION 3.16) +project(kiosk-shell CXX) + +# 对应《VDI 云桌面软件开发方案》2.2 节技术栈定版: +# kiosk 与 freerdp 插件用 C/C++(贴合 GTK/GStreamer 生态) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_INCLUDE_CURRENT_DIR ON) + +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release) +endif() + +# ---- Qt5 ---- +find_package(Qt5 REQUIRED COMPONENTS Core Gui Widgets) + +# ---- FreeRDP 3 ---- +find_package(PkgConfig REQUIRED) +pkg_check_modules(FREERDP REQUIRED IMPORTED_TARGET freerdp3) + +add_executable(kiosk-shell + src/main.cpp + src/main_window.cpp + src/login_page.cpp + src/desktop_list_page.cpp + src/desktop_service.cpp + src/moonlight_session.cpp + src/state_machine.cpp + src/config.cpp + src/rdp_session.cpp +) + +target_include_directories(kiosk-shell PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src +) + +target_link_libraries(kiosk-shell PRIVATE + Qt5::Core + Qt5::Gui + Qt5::Widgets + PkgConfig::FREERDP +) + +# 看门狗 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) diff --git a/terminal/kiosk-shell/etc/moonlight.conf b/terminal/kiosk-shell/etc/moonlight.conf new file mode 100644 index 0000000..bbb9990 --- /dev/null +++ b/terminal/kiosk-shell/etc/moonlight.conf @@ -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 diff --git a/terminal/kiosk-shell/scripts/vdi-moonlight b/terminal/kiosk-shell/scripts/vdi-moonlight new file mode 100644 index 0000000..360f3fe --- /dev/null +++ b/terminal/kiosk-shell/scripts/vdi-moonlight @@ -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 只接受 位置参数,没有 --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 diff --git a/terminal/kiosk-shell/src/config.cpp b/terminal/kiosk-shell/src/config.cpp new file mode 100644 index 0000000..28c1edb --- /dev/null +++ b/terminal/kiosk-shell/src/config.cpp @@ -0,0 +1,122 @@ +#include "config.h" + +#include +#include + +#include +#include +#include +#include + +namespace vdi { +namespace { + +std::string trim(const std::string& s) { + const char* ws = " \t\r\n"; + size_t b = s.find_first_not_of(ws); + if (b == std::string::npos) return std::string(); + size_t e = s.find_last_not_of(ws); + return s.substr(b, e - b + 1); +} + +std::string readFirstLine(const char* path) { + std::ifstream f(path); + std::string line; + if (f && std::getline(f, line)) { + return trim(line); + } + return std::string(); +} + +} // namespace + +std::string configDir() { + const char* home = getenv("HOME"); + return std::string(home ? home : "/root") + "/.config/vdi-terminal"; +} + +std::string configPath() { + return configDir() + "/client.conf"; +} + +std::string readSerialNumber() { + // 优先设备树序列号(RK3528 ODM 通常提供) + std::string sn = readFirstLine("/proc/device-tree/serial-number"); + if (!sn.empty()) return sn; + // 其次 machine-id + sn = readFirstLine("/etc/machine-id"); + if (!sn.empty()) return sn; + // 兜底:用 MAC 派生 + std::ifstream f("/sys/class/net/wlan0/address"); + std::string mac; + if (f && std::getline(f, mac)) { + mac = trim(mac); + for (auto& c : mac) { + if (c == ':') c = '-'; + } + return mac; + } + return "VDI-UNKNOWN"; +} + +TerminalConfig loadConfig() { + TerminalConfig cfg; + cfg.sn = readSerialNumber(); + + std::ifstream f(configPath()); + if (!f) { + return cfg; + } + + std::string line; + while (std::getline(f, line)) { + line = trim(line); + if (line.empty() || line[0] == '#') continue; + size_t eq = line.find('='); + if (eq == std::string::npos) continue; + + std::string key = trim(line.substr(0, eq)); + std::string val = trim(line.substr(eq + 1)); + + if (key == "sn") cfg.sn = val; + else if (key == "deviceKey") cfg.deviceKey = val; + else if (key == "server") cfg.server = val; + else if (key == "username") cfg.username = val; + else if (key == "password") cfg.password = val; + else if (key == "wifiSsid") cfg.wifiSsid = val; + else if (key == "wifiPassword") cfg.wifiPassword = val; + else if (key == "resolution") cfg.resolution = val; + else if (key == "remember") cfg.remember = (val == "1" || val == "true"); + else if (key == "version") cfg.version = atoi(val.c_str()); + } + return cfg; +} + +bool hasConnectionConfig(const TerminalConfig& cfg) { + return !cfg.server.empty() && !cfg.username.empty(); +} + +bool saveConfig(const TerminalConfig& cfg) { + std::string dir = configDir(); + mkdir(dir.c_str(), 0755); + + std::string tmp = configPath() + ".tmp"; + { + std::ofstream f(tmp, std::ios::trunc); + if (!f) return false; + f << "# VDI 云终端本地配置(由 kiosk-shell 维护)\n"; + f << "version=" << (cfg.version + 1) << "\n"; + f << "sn=" << cfg.sn << "\n"; + f << "deviceKey=" << cfg.deviceKey << "\n"; + f << "server=" << cfg.server << "\n"; + f << "username=" << cfg.username << "\n"; + f << "password=" << cfg.password << "\n"; + f << "wifiSsid=" << cfg.wifiSsid << "\n"; + f << "wifiPassword=" << cfg.wifiPassword << "\n"; + f << "resolution=" << cfg.resolution << "\n"; + f << "remember=" << (cfg.remember ? "1" : "0") << "\n"; + } + return rename(tmp.c_str(), configPath().c_str()) == 0; +} + +} // namespace vdi diff --git a/terminal/kiosk-shell/src/config.h b/terminal/kiosk-shell/src/config.h new file mode 100644 index 0000000..b6e56ba --- /dev/null +++ b/terminal/kiosk-shell/src/config.h @@ -0,0 +1,53 @@ +// 终端本地配置 +// +// 对应《VDI 云桌面软件开发方案》4.2 节 config_template 的终端侧镜像: +// 终端保存一份本地的配置版本,API 下发后覆盖。 +// 当前阶段先做本地文件版本,后续由 vdt-agent 接管与 API 同步。 + +#ifndef KIOSK_SHELL_CONFIG_H +#define KIOSK_SHELL_CONFIG_H + +#include + +namespace vdi { + +struct TerminalConfig { + // 设备身份 + std::string sn; // 序列号 + std::string deviceKey; // 预置密钥(注册用) + + // 首配 + std::string server; // 服务器地址 + std::string username; // 登录账号 + std::string password; // 登录密码 + std::string wifiSsid; // WiFi SSID + std::string wifiPassword; + + // 显示档位:1920x1440 / 2560x1440 / 1920x1080 / 1280x720 + std::string resolution = "1920x1440"; + + // 是否记住连接参数 + bool remember = true; + + int version = 0; +}; + +// 配置路径:~/.config/vdi-terminal/client.conf +std::string configDir(); +std::string configPath(); + +// 读取配置;文件不存在时返回默认值 +TerminalConfig loadConfig(); + +// 判断是否已经有 Agent/API 下发的可用桌面连接配置。 +bool hasConnectionConfig(const TerminalConfig& cfg); + +// 保存配置 +bool saveConfig(const TerminalConfig& cfg); + +// 读取设备序列号(优先取 /etc/machine-id,其次 /proc/device-tree/serial-number) +std::string readSerialNumber(); + +} // namespace vdi + +#endif // KIOSK_SHELL_CONFIG_H diff --git a/terminal/kiosk-shell/src/desktop_list_page.cpp b/terminal/kiosk-shell/src/desktop_list_page.cpp new file mode 100644 index 0000000..c3f0f4c --- /dev/null +++ b/terminal/kiosk-shell/src/desktop_list_page.cpp @@ -0,0 +1,184 @@ +#include "desktop_list_page.h" + +#include +#include +#include +#include +#include +#include +#include + +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& 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 diff --git a/terminal/kiosk-shell/src/desktop_list_page.h b/terminal/kiosk-shell/src/desktop_list_page.h new file mode 100644 index 0000000..4c003b3 --- /dev/null +++ b/terminal/kiosk-shell/src/desktop_list_page.h @@ -0,0 +1,57 @@ +// 桌面列表页 +// +// 登录成功后展示当前用户可用的云桌面卡片; +// 选择桌面后由 MainWindow 获取连接票据并启动对应协议会话; +// 会话退出后返回本页。 + +#ifndef KIOSK_SHELL_DESKTOP_LIST_PAGE_H +#define KIOSK_SHELL_DESKTOP_LIST_PAGE_H + +#include +#include + +#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& 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 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 diff --git a/terminal/kiosk-shell/src/desktop_service.cpp b/terminal/kiosk-shell/src/desktop_service.cpp new file mode 100644 index 0000000..bd2e1bb --- /dev/null +++ b/terminal/kiosk-shell/src/desktop_service.cpp @@ -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 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 desktops; + QString error; + listDesktops([&desktops, &error](std::vector 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 diff --git a/terminal/kiosk-shell/src/desktop_service.h b/terminal/kiosk-shell/src/desktop_service.h new file mode 100644 index 0000000..fbc3488 --- /dev/null +++ b/terminal/kiosk-shell/src/desktop_service.h @@ -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 +#include +#include + +#include "desktop_session.h" + +namespace vdi { + +class DesktopService { +public: + using AuthCallback = std::function; + using DesktopListCallback = std::function, const QString& error)>; + using TicketCallback = std::function; + + // 异步认证(本地实现立即回调;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 diff --git a/terminal/kiosk-shell/src/desktop_session.h b/terminal/kiosk-shell/src/desktop_session.h new file mode 100644 index 0000000..d8285a9 --- /dev/null +++ b/terminal/kiosk-shell/src/desktop_session.h @@ -0,0 +1,70 @@ +// 桌面与会话领域模型 +// +// 对应产品流程: +// 登录 -> 桌面列表 -> 选择桌面 -> 获取连接票据 -> 启动 RDP/Moonlight 会话 +// +// 当前阶段由本地 DesktopService 提供演示数据;后续接入管理平台后, +// 由 API 适配器返回同样的模型,页面与状态机不感知数据来源。 + +#ifndef KIOSK_SHELL_DESKTOP_SESSION_H +#define KIOSK_SHELL_DESKTOP_SESSION_H + +#include +#include + +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 diff --git a/terminal/kiosk-shell/src/login_page.cpp b/terminal/kiosk-shell/src/login_page.cpp new file mode 100644 index 0000000..60bdf04 --- /dev/null +++ b/terminal/kiosk-shell/src/login_page.cpp @@ -0,0 +1,218 @@ +#include "login_page.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace vdi { +namespace { + +QString defaultRouteInterface(); +QString interfaceIPv4(const QString& dev); + +QString detectNetworkSummary() { + // 取默认路由的出接口与该接口上的 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; + process.start(QStringLiteral("ip"), + QStringList() << QStringLiteral("-4") << QStringLiteral("-o") << QStringLiteral("addr") + << QStringLiteral("show") << QStringLiteral("dev") << dev); + if (!process.waitForFinished(2000)) { + return QString(); + } + const QString out = QString::fromUtf8(process.readAllStandardOutput()); + for (const QString& line : out.split('\n')) { + const QStringList parts = line.split(QRegularExpression(QStringLiteral("\\s+")), + Qt::SkipEmptyParts); + if (parts.size() >= 4 && parts[0] == dev) { + QString addr = parts[3]; + if (addr.contains('/')) { + addr = addr.split('/').value(0); + } + return addr; + } + } + return QString(); +} + +} // namespace + +LoginPage::LoginPage(const TerminalConfig& initial, QWidget* parent) + : QWidget(parent), cfg_(initial) { + auto* root = new QVBoxLayout(this); + root->setContentsMargins(80, 60, 80, 40); + root->setSpacing(18); + + // ---- 标题区 ---- + auto* title = new QLabel(QStringLiteral("VDI 云终端"), this); + QFont titleFont = title->font(); + titleFont.setPointSize(28); + titleFont.setBold(true); + title->setFont(titleFont); + title->setAlignment(Qt::AlignCenter); + root->addWidget(title); + + auto* subtitle = new QLabel(QStringLiteral("登录后进入您的云桌面"), this); + QFont subFont = subtitle->font(); + subFont.setPointSize(13); + subtitle->setFont(subFont); + subtitle->setAlignment(Qt::AlignCenter); + subtitle->setStyleSheet(QStringLiteral("color: #8a94a6;")); + root->addWidget(subtitle); + + // ---- 设备信息条 ---- + auto* infoBox = new QHBoxLayout(); + labelSn_ = new QLabel(QStringLiteral("设备 SN:%1").arg(QString::fromStdString(cfg_.sn)), this); + labelNetwork_ = new QLabel(detectNetworkSummary(), this); + labelSn_->setStyleSheet(QStringLiteral("color: #8a94a6;")); + labelNetwork_->setStyleSheet(QStringLiteral("color: #8a94a6;")); + infoBox->addWidget(labelSn_); + infoBox->addStretch(1); + infoBox->addWidget(labelNetwork_); + root->addLayout(infoBox); + + // ---- 登录表单 ---- + auto* grid = new QGridLayout(); + grid->setHorizontalSpacing(14); + grid->setVerticalSpacing(12); + grid->setColumnStretch(1, 1); + + editUser_ = new QLineEdit(this); + editUser_->setPlaceholderText(QStringLiteral("用户名")); + editPassword_ = new QLineEdit(this); + editPassword_->setEchoMode(QLineEdit::Password); + editPassword_->setPlaceholderText(QStringLiteral("密码")); + + struct Row { + QString label; + QWidget* widget; + }; + const Row rows[] = { + {QStringLiteral("用户名"), editUser_}, + {QStringLiteral("密码"), editPassword_}, + }; + int row = 0; + for (const Row& r : rows) { + auto* label = new QLabel(r.label, this); + label->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + grid->addWidget(label, row, 0); + grid->addWidget(r.widget, row, 1); + ++row; + } + root->addLayout(grid); + + // ---- 按钮 ---- + auto* buttonRow = new QHBoxLayout(); + buttonRow->addStretch(1); + buttonLogin_ = new QPushButton(QStringLiteral("登 录"), this); + buttonLogin_->setMinimumSize(160, 46); + buttonLogin_->setStyleSheet( + QStringLiteral("QPushButton { background: #2f6feb; color: white; border: none; " + "border-radius: 6px; font-weight: bold; }" + "QPushButton:disabled { background: #3a4a63; }")); + buttonLogin_->setDefault(true); + buttonQuit_ = new QPushButton(QStringLiteral("退出"), this); + buttonQuit_->setMinimumSize(110, 46); + + buttonRow->addWidget(buttonLogin_); + buttonRow->addWidget(buttonQuit_); + buttonRow->addStretch(1); + root->addLayout(buttonRow); + + // ---- 状态条 ---- + labelStatus_ = new QLabel(QStringLiteral("请输入用户名和密码登录"), this); + labelStatus_->setAlignment(Qt::AlignCenter); + labelStatus_->setStyleSheet(QStringLiteral("color: #8a94a6;")); + root->addWidget(labelStatus_); + root->addStretch(1); + + connect(buttonLogin_, &QPushButton::clicked, this, &LoginPage::onLoginClicked); + connect(buttonQuit_, &QPushButton::clicked, this, [this]() { emit quitRequested(); }); + // 密码框回车直接登录 + connect(editPassword_, &QLineEdit::returnPressed, this, &LoginPage::onLoginClicked); + connect(editUser_, &QLineEdit::returnPressed, this, &LoginPage::onLoginClicked); +} + +void LoginPage::setLoginHandler(LoginHandler handler) { + loginHandler_ = std::move(handler); +} + +void LoginPage::setStatus(const QString& text, bool error) { + labelStatus_->setText(text); + labelStatus_->setStyleSheet( + QStringLiteral("color: %1;").arg(error ? QStringLiteral("#e05561") + : QStringLiteral("#8a94a6"))); +} + +void LoginPage::setBusy(bool busy) { + buttonLogin_->setEnabled(!busy); + buttonLogin_->setText(busy ? QStringLiteral("登录中…") : QStringLiteral("登 录")); + editUser_->setEnabled(!busy); + editPassword_->setEnabled(!busy); +} + +TerminalConfig LoginPage::deviceConfig() const { + return cfg_; +} + +void LoginPage::onLoginClicked() { + const QString username = editUser_->text().trimmed(); + const QString password = editPassword_->text(); + if (username.isEmpty() || password.isEmpty()) { + setStatus(QStringLiteral("请输入用户名和密码"), true); + return; + } + setBusy(true); + setStatus(QStringLiteral("正在认证…")); + + if (loginHandler_) { + loginHandler_(username, password); + } + emit loginRequested(username, password); +} + +} // namespace vdi diff --git a/terminal/kiosk-shell/src/login_page.h b/terminal/kiosk-shell/src/login_page.h new file mode 100644 index 0000000..f212ca4 --- /dev/null +++ b/terminal/kiosk-shell/src/login_page.h @@ -0,0 +1,61 @@ +// 登录页 +// +// 产品流程第一步:设备 SN/网络展示 + 用户认证。 +// 当前为本地演示认证(账号密码非空通过),由 DesktopService 承担; +// 页面只负责输入采集与状态展示,不直接连接云桌面。 + +#ifndef KIOSK_SHELL_LOGIN_PAGE_H +#define KIOSK_SHELL_LOGIN_PAGE_H + +#include +#include + +#include "config.h" + +class QLineEdit; +class QLabel; +class QPushButton; +class QGridLayout; +class QVBoxLayout; + +namespace vdi { + +class LoginPage : public QWidget { + Q_OBJECT + +public: + // 认证回调:由 MainWindow 接到 DesktopService + using LoginHandler = std::function; + + explicit LoginPage(const TerminalConfig& initial, QWidget* parent = nullptr); + + void setLoginHandler(LoginHandler handler); + void setStatus(const QString& text, bool error = false); + void setBusy(bool busy); + + // 保留设备首配信息(SN/服务器地址),登录不再承载 Windows 直连参数 + TerminalConfig deviceConfig() const; + +signals: + void loginRequested(const QString& username, const QString& password); + void quitRequested(); + +private slots: + void onLoginClicked(); + +private: + TerminalConfig cfg_; + LoginHandler loginHandler_; + + QLineEdit* editUser_ = nullptr; + QLineEdit* editPassword_ = nullptr; + QLabel* labelStatus_ = nullptr; + QLabel* labelSn_ = nullptr; + QLabel* labelNetwork_ = nullptr; + QPushButton* buttonLogin_ = nullptr; + QPushButton* buttonQuit_ = nullptr; +}; + +} // namespace vdi + +#endif // KIOSK_SHELL_LOGIN_PAGE_H diff --git a/terminal/kiosk-shell/src/main.cpp b/terminal/kiosk-shell/src/main.cpp new file mode 100644 index 0000000..b3fccb0 --- /dev/null +++ b/terminal/kiosk-shell/src/main.cpp @@ -0,0 +1,22 @@ +// kiosk-shell 入口 +// +// 对应《VDI 云桌面软件开发方案》3.2 节: +// Weston(Wayland) + 全屏 Qt 壳进程 +// 开机自动登录后由本进程接管显示,进入 Kiosk 状态 + +#include +#include + +#include "main_window.h" + +int main(int argc, char** argv) { + QApplication app(argc, argv); + app.setApplicationName(QStringLiteral("vdi-kiosk-shell")); + app.setApplicationVersion(QStringLiteral("0.1.0")); + app.setOrganizationName(QStringLiteral("vdi")); + + auto* window = new vdi::MainWindow(); + window->show(); + + return app.exec(); +} diff --git a/terminal/kiosk-shell/src/main_window.cpp b/terminal/kiosk-shell/src/main_window.cpp new file mode 100644 index 0000000..ac41589 --- /dev/null +++ b/terminal/kiosk-shell/src/main_window.cpp @@ -0,0 +1,647 @@ +#include "main_window.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "desktop_list_page.h" +#include "login_page.h" + +namespace vdi { +namespace { + +// FreeRDP 像素格式 -> QImage::Format +// 注意:FreeRDP 的像素格式值是计算宏(FREERDP_PIXEL_FORMAT), +// 不能硬编码数值,必须用宏直接比较。 +// FreeRDP 主缓冲是 4 字节/像素(BGRX / BGRA / RGBA), +// QImage 没有对应的 4 字节 BGRA 格式,因此统一转成 RGB888 输出。 +QImage::Format toQImageFormat(uint32_t freerdpFormat) { + (void)freerdpFormat; + return QImage::Format_RGB888; +} + +// FreeRDP BGRX32 在当前 ARM64 小端目标上与 QImage::Format_RGB32 的内存布局一致。 +QImage convertFrame(const uint8_t* data, uint32_t width, uint32_t height, uint32_t bytesPerLine, + uint32_t freerdpFormat) { + if (!data || width == 0 || height == 0) { + return QImage(); + } + if (freerdpFormat == PIXEL_FORMAT_BGRX32 || freerdpFormat == PIXEL_FORMAT_BGRA32) { + QImage out(static_cast(width), static_cast(height), QImage::Format_RGB32); + const size_t rowBytes = static_cast(width) * 4; + for (uint32_t y = 0; y < height; ++y) { + std::memcpy(out.scanLine(static_cast(y)), + data + static_cast(y) * bytesPerLine, rowBytes); + } + return out; + } + + QImage out(static_cast(width), static_cast(height), QImage::Format_RGB888); + for (uint32_t y = 0; y < height; ++y) { + const uint8_t* src = data + static_cast(y) * bytesPerLine; + uint8_t* dst = out.scanLine(static_cast(y)); + for (uint32_t x = 0; x < width; ++x) { + const uint8_t* p = src + static_cast(x) * 4; + dst[0] = p[0]; + dst[1] = p[1]; + dst[2] = p[2]; + dst += 3; + } + } + return out; +} + +constexpr int kReconnectDelayMs = 3000; +constexpr int kMaxReconnectAttempts = 5; + +} // namespace + +MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { + fsm_ = StateMachine([this](State from, State to, const QString& detail) { + onStateChanged(from, to, detail); + }); + setupUi(); + setupHotkey(); + setupHeartbeat(); + keyboardLayout_ = freerdp_keyboard_init(0); + + config_ = loadConfig(); + + // 开机永远进入登录页;不再根据本地配置自动连接 RDP, + // 会话只能由用户在桌面列表选择后启动。 + fsm_.transitionTo(State::kConfig, QStringLiteral("等待登录")); + showLogin(); +} + +MainWindow::~MainWindow() { + stopSessions(); +} + +void MainWindow::setupUi() { + setWindowTitle(QStringLiteral("VDI 云终端")); + setWindowState(Qt::WindowFullScreen); + setStyleSheet(QStringLiteral("QMainWindow { background: #0f1420; }")); + + stack_ = new QStackedWidget(this); + stack_->setStyleSheet(QStringLiteral("background: #0f1420;")); + setCentralWidget(stack_); + + // 登录页 + auto* login = new LoginPage(config_, this); + loginPage_ = login; + login->setStyleSheet( + QStringLiteral("QWidget { background: #0f1420; color: #e6e9ef; }" + "QLineEdit { background: #1a2233; border: 1px solid #2b3654; " + "border-radius: 4px; padding: 8px; color: #e6e9ef; }" + "QLineEdit:focus { border: 1px solid #2f6feb; }" + "QPushButton { background: #232d44; border: 1px solid #2b3654; " + "border-radius: 6px; color: #e6e9ef; }" + "QPushButton:hover { background: #2b3654; }")); + stack_->addWidget(login); + + connect(login, &LoginPage::loginRequested, this, &MainWindow::onLoginRequested); + 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* sessionLayout = new QVBoxLayout(sessionWidget); + sessionLayout->setContentsMargins(0, 0, 0, 0); + + sessionView_ = new QLabel(sessionWidget); + sessionView_->setFocusPolicy(Qt::StrongFocus); + sessionView_->setMouseTracking(true); + sessionView_->installEventFilter(this); + sessionView_->setAlignment(Qt::AlignCenter); + sessionView_->setStyleSheet(QStringLiteral("background: #000000; color: #e6e9ef;")); + sessionView_->setText(QStringLiteral("正在建立远程会话…")); + + sessionOverlay_ = new QLabel(sessionWidget); + sessionOverlay_->setAlignment(Qt::AlignCenter); + sessionOverlay_->setStyleSheet( + QStringLiteral("background: rgba(15,20,32,0.82); color: #e6e9ef; " + "font-size: 20px; padding: 40px;")); + sessionOverlay_->setText(QStringLiteral("正在连接…")); + sessionOverlay_->hide(); + + sessionLayout->addWidget(sessionView_); + sessionLayout->addWidget(sessionOverlay_); + stack_->addWidget(sessionWidget); + + // 状态条 + statusBar_ = new QStatusBar(this); + statusBar_->setStyleSheet(QStringLiteral("QStatusBar { background: #161d2e; color: #8a94a6; }")); + setStatusBar(statusBar_); + + // RDP 重连定时器 + reconnectTimer_ = new QTimer(this); + reconnectTimer_->setSingleShot(true); + connect(reconnectTimer_, &QTimer::timeout, this, [this]() { + if (fsm_.current() == State::kReconnect && reconnectAttempts_ < kMaxReconnectAttempts) { + ++reconnectAttempts_; + if (activeTicket_.protocol == Protocol::kMoonlight) { + startMoonlightSession(activeTicket_); + } else { + startRdpSession(activeTicket_); + } + } else if (fsm_.current() == State::kReconnect) { + 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() { + // 全局热键 Ctrl+Alt+Shift+M 呼出管理菜单(方案 3.2 节) + auto* shortcut = new QShortcut(QKeySequence(QStringLiteral("Ctrl+Alt+Shift+M")), this); + shortcut->setContext(Qt::ApplicationShortcut); + 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) { + Q_UNUSED(from); + const QString stateName = QString::fromLatin1(stateToString(to)); + updateStatusBar(detail.isEmpty() ? stateName : QStringLiteral("%1 %2").arg(stateName, detail)); + + switch (to) { + case State::kConfig: + showLogin(); + break; + case State::kAuthenticating: + break; + case State::kDesktopList: + showDesktopList(); + refreshDesktopList(); + break; + case State::kConnecting: + if (stack_->currentWidget() != sessionView_->parentWidget()) { + stack_->setCurrentWidget(sessionView_->parentWidget()); + } + sessionView_->setFocus(Qt::OtherFocusReason); + sessionOverlay_->setText(QStringLiteral("正在连接 %1 …").arg(activeTicket_.endpoint)); + sessionOverlay_->show(); + break; + case State::kSession: + sessionView_->setFocus(Qt::OtherFocusReason); + sessionOverlay_->hide(); + break; + case State::kReconnect: + sessionOverlay_->setText(QStringLiteral("连接已断开,正在重连…")); + sessionOverlay_->show(); + reconnectTimer_->start(kReconnectDelayMs); + break; + case State::kError: + stopSessions(); + fsm_.transitionTo(State::kDesktopList, QStringLiteral("连接失败")); + if (desktopListPage_) { + desktopListPage_->setBusy(false); + desktopListPage_->setStatus(QStringLiteral("连接失败:%1").arg(detail), true); + } + break; + case State::kBoot: + break; + } +} + +void MainWindow::updateStatusBar(const QString& text) { + statusBar_->showMessage(text); +} + +void MainWindow::showLogin() { + stack_->setCurrentWidget(loginPage_); + if (loginPage_) { + loginPage_->setBusy(false); + loginPage_->setStatus(QStringLiteral("请输入用户名和密码登录")); + } +} + +void MainWindow::showDesktopList() { + if (!desktopListPage_) { + return; + } + stack_->setCurrentWidget(desktopListPage_); + desktopListPage_->setCurrentUser(currentUser_); + desktopListPage_->setBusy(false); +} + +void MainWindow::refreshDesktopList() { + desktopService_.listDesktops([this](std::vector list, const QString& error) { + 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) { + return; + } + } + + RdpOptions opts; + opts.server = ticket.endpoint.toStdString(); + opts.username = ticket.username.toStdString(); + opts.password = ticket.password.toStdString(); + opts.keyboardLayout = keyboardLayout_; + opts.width = ticket.width; + opts.height = ticket.height; + opts.bpp = 32; + opts.fullscreen = true; + // 与 xfreerdp3 相同的 GFX 能力组合语义: + // FreeRDP 3.32.1 不接受单个管道名,必须成组。 + opts.gfx = "progressive|RFX|AVC420|AVC444"; + opts.ignoreCertificate = true; // POC 阶段;正式产品必须做证书校验 + + RdpCallbacks cb; + cb.onFrame = [this](const uint8_t* data, uint32_t width, uint32_t height, uint32_t stride, + uint32_t format) { + // 在 FreeRDP 线程中复制帧,不能把其内部缓冲区裸指针排队到 GUI 线程。 + const QImage image = convertFrame(data, width, height, stride, format); + if (image.isNull()) { + return; + } + 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) { + QMetaObject::invokeMethod( + this, + [this, connected, message]() { + onSessionState(connected, QString::fromStdString(message)); + }, + Qt::QueuedConnection); + }; + + rdpSession_ = std::make_unique(); + rdpSession_->start(opts, cb); +} + +void MainWindow::startMoonlightSession(const ConnectionTicket& ticket) { + if (!fsm_.transitionTo(State::kConnecting, ticket.endpoint)) { + if (fsm_.current() != State::kConnecting) { + 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(/*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); + } +} + +void MainWindow::onSessionState(bool connected, const QString& message) { + if (connected) { + reconnectAttempts_ = 0; + fsm_.transitionTo(State::kSession, message); + } else { + if (fsm_.current() == State::kConnecting) { + // 首次连接失败:回桌面列表并提示 + stopSessions(); + fsm_.transitionTo(State::kDesktopList, message); + if (desktopListPage_) { + desktopListPage_->setBusy(false); + desktopListPage_->setStatus(QStringLiteral("连接失败:%1").arg(message), true); + } + } else if (fsm_.current() == State::kSession) { + fsm_.transitionTo(State::kReconnect, message); + } + } +} + +void MainWindow::onFrameWakeup() { + QMutexLocker locker(&frameMutex_); + frameWakePending_ = false; +} + +void MainWindow::onFrameRefresh() { + QImage image; + uint64_t epoch = 0; + { + QMutexLocker locker(&frameMutex_); + image = latestFrame_; + epoch = frameEpoch_; + latestFrame_ = QImage(); + frameWakePending_ = false; + } + if (epoch == sessionEpoch_) { + onFrameImage(image); + } +} + +void MainWindow::onFrameImage(const QImage& image) { + if (image.isNull()) { + return; + } + lastFrame_ = image; + + if (sessionView_) { + const QSize targetSize = sessionView_->size(); + if (lastFrame_.size() == targetSize) { + sessionView_->setPixmap(QPixmap::fromImage(lastFrame_)); + } else { + sessionView_->setPixmap(QPixmap::fromImage(lastFrame_).scaled( + targetSize, Qt::KeepAspectRatio, Qt::FastTransformation)); + } + } +} + +void MainWindow::onQuitRequested() { + stopSessions(); + 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) { + switch (action) { + case 1: // 重新连接当前桌面 + if (fsm_.canConnect() && !activeTicket_.desktopId.isEmpty()) { + reconnectAttempts_ = 0; + stopSessions(); + if (activeTicket_.protocol == Protocol::kMoonlight) { + startMoonlightSession(activeTicket_); + } else { + startRdpSession(activeTicket_); + } + } + break; + case 2: // 断开回桌面列表 + stopSessions(); + if (fsm_.current() != State::kConfig) { + fsm_.transitionTo(State::kDesktopList, "已断开"); + } + break; + case 3: // 退出终端 + onQuitRequested(); + break; + default: { + QMenu menu(this); + QAction* reconnect = menu.addAction(QStringLiteral("重新连接当前桌面")); + QAction* disconnect = menu.addAction(QStringLiteral("断开并返回桌面列表")); + menu.addSeparator(); + QAction* quit = menu.addAction(QStringLiteral("退出终端")); + QAction* chosen = menu.exec(QCursor::pos()); + if (chosen == reconnect) { + onMenuAction(1); + } else if (chosen == disconnect) { + onMenuAction(2); + } else if (chosen == quit) { + onMenuAction(3); + } + if (stack_->currentWidget() == sessionView_->parentWidget()) { + sessionView_->setFocus(Qt::OtherFocusReason); + } + break; + } + } +} + +bool MainWindow::eventFilter(QObject* watched, QEvent* event) { + if (watched == sessionView_ && rdpSession_) { + if (event->type() == QEvent::KeyPress || event->type() == QEvent::KeyRelease) { + auto* keyEvent = static_cast(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(event)); + } + if (event->type() == QEvent::Wheel) { + return forwardMouseEvent(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(event); + pos = mouse->pos(); + flags = PTR_FLAGS_MOVE; + } else if (event->type() == QEvent::MouseButtonPress || + event->type() == QEvent::MouseButtonRelease) { + auto* mouse = static_cast(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(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(remoteX), + static_cast(remoteY)); +} + +} // namespace vdi diff --git a/terminal/kiosk-shell/src/main_window.h b/terminal/kiosk-shell/src/main_window.h new file mode 100644 index 0000000..45c0880 --- /dev/null +++ b/terminal/kiosk-shell/src/main_window.h @@ -0,0 +1,110 @@ +// Kiosk 主窗口 +// +// 产品流程编排: +// 开机 -> 登录页 -> 认证 -> 桌面列表 -> 选择桌面 +// -> 获取连接票据 -> RDP(内嵌)或 Moonlight(外部进程)会话 +// -> 会话结束回桌面列表;退出登录回登录页 +// +// 对应《VDI 云桌面软件开发方案》3.2 节的状态机, +// 并按产品流程扩展 AUTHENTICATING / DESKTOP_LIST 两个状态。 +// 全局热键 Ctrl+Alt+Shift+M 呼出管理菜单。 + +#ifndef KIOSK_SHELL_MAIN_WINDOW_H +#define KIOSK_SHELL_MAIN_WINDOW_H + +#include +#include +#include +#include +#include +#include + +#include "config.h" +#include "desktop_service.h" +#include "desktop_session.h" +#include "moonlight_session.h" +#include "rdp_session.h" +#include "state_machine.h" + +class QLabel; +class QStackedWidget; +class QStatusBar; +class QTimer; +class QEvent; +class QKeyEvent; +class QMouseEvent; + +namespace vdi { + +class LoginPage; +class DesktopListPage; + +class MainWindow : public QMainWindow { + Q_OBJECT + +public: + explicit MainWindow(QWidget* parent = nullptr); + ~MainWindow() override; + +protected: + bool eventFilter(QObject* watched, QEvent* event) override; + +private slots: + void onLoginRequested(const QString& username, const QString& password); + void onDesktopSelected(const QString& desktopId); + void onLogoutRequested(); + void onQuitRequested(); + void onFrameImage(const QImage& image); + void onFrameWakeup(); + void onFrameRefresh(); + void onSessionState(bool connected, const QString& message); + void onMoonlightState(bool running, const QString& message); + void onMenuAction(int action); + +private: + void setupUi(); + void setupHotkey(); + void setupHeartbeat(); + void onStateChanged(vdi::State from, vdi::State to, const QString& detail); + void refreshDesktopList(); + void startRdpSession(const ConnectionTicket& ticket); + void startMoonlightSession(const ConnectionTicket& ticket); + void stopSessions(); + void showLogin(); + void showDesktopList(); + void updateStatusBar(const QString& text); + bool forwardKeyEvent(QKeyEvent* event, bool pressed); + bool forwardMouseEvent(QEvent* event); + + StateMachine fsm_; + DesktopService desktopService_; + std::unique_ptr rdpSession_; + std::unique_ptr moonlightSession_; + ConnectionTicket activeTicket_; + QString currentUser_; + std::vector desktops_; + int reconnectAttempts_ = 0; + + QPointer loginPage_; + QPointer desktopListPage_; + QStackedWidget* stack_ = nullptr; + QLabel* sessionView_ = nullptr; + QLabel* sessionOverlay_ = nullptr; + QStatusBar* statusBar_ = nullptr; + QTimer* reconnectTimer_ = nullptr; + QTimer* heartbeatTimer_ = nullptr; + QTimer* frameRefreshTimer_ = nullptr; + TerminalConfig config_; + QImage lastFrame_; + QImage latestFrame_; + QMutex frameMutex_; + uint64_t frameEpoch_ = 0; + uint64_t sessionEpoch_ = 0; + bool frameWakePending_ = false; + QPoint lastMousePosition_; + uint32_t keyboardLayout_ = 0; +}; + +} // namespace vdi + +#endif // KIOSK_SHELL_MAIN_WINDOW_H diff --git a/terminal/kiosk-shell/src/monitor.cpp b/terminal/kiosk-shell/src/monitor.cpp new file mode 100644 index 0000000..d9a1f50 --- /dev/null +++ b/terminal/kiosk-shell/src/monitor.cpp @@ -0,0 +1,203 @@ +#include "monitor.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +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(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::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 diff --git a/terminal/kiosk-shell/src/monitor.h b/terminal/kiosk-shell/src/monitor.h new file mode 100644 index 0000000..a1751ac --- /dev/null +++ b/terminal/kiosk-shell/src/monitor.h @@ -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 +#include + +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 diff --git a/terminal/kiosk-shell/src/monitor_main.cpp b/terminal/kiosk-shell/src/monitor_main.cpp new file mode 100644 index 0000000..ada5b21 --- /dev/null +++ b/terminal/kiosk-shell/src/monitor_main.cpp @@ -0,0 +1,66 @@ +// 看门狗 Monitor 入口 +// +// 用法:vdi-kiosk-monitor -- <主进程命令行> +// 例: vdi-kiosk-monitor -- /home/armbian/vdi-terminal/bin/kiosk-shell +// +// 对应会议纪要要求: +// "必须部署独立的 Monitor(看门狗)程序监控主进程状态, +// 主进程假死或崩溃时由 Monitor 执行 kill 操作并重启" + +#include +#include + +#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); +} diff --git a/terminal/kiosk-shell/src/moonlight_session.cpp b/terminal/kiosk-shell/src/moonlight_session.cpp new file mode 100644 index 0000000..62fd103 --- /dev/null +++ b/terminal/kiosk-shell/src/moonlight_session.cpp @@ -0,0 +1,138 @@ +#include "moonlight_session.h" + +#include +#include +#include +#include +#include + +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(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 diff --git a/terminal/kiosk-shell/src/moonlight_session.h b/terminal/kiosk-shell/src/moonlight_session.h new file mode 100644 index 0000000..f49288e --- /dev/null +++ b/terminal/kiosk-shell/src/moonlight_session.h @@ -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 +#include +#include + +#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; + + 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 diff --git a/terminal/kiosk-shell/src/rdp_session.cpp b/terminal/kiosk-shell/src/rdp_session.cpp new file mode 100644 index 0000000..725919a --- /dev/null +++ b/terminal/kiosk-shell/src/rdp_session.cpp @@ -0,0 +1,368 @@ +#include + +#include "rdp_session.h" + +#include +#include +#include +#include + +namespace vdi { +namespace { + +// 从 freerdp 实例取回 C++ 会话对象。 +// 注意:不能用 instance->ContextSize 扩展 rdpContext —— +// gdi_init_ex() 会按 ContextSize 重新分配 context 缓冲区, +// 扩展出来的字段会失效(实测段错误)。 +// 因此改用 FreeRDP 官方提供的 custom 挂载点: +// rdpGdi->gfx->custom 用于 GFX 通道回调; +// 这里额外用一个静态注册表按 freerdp* 记录会话对象。 +class SessionRegistry { +public: + static void add(freerdp* instance, RdpSession* session) { + std::lock_guard lock(mutex()); + table()[instance] = session; + } + static void remove(freerdp* instance) { + std::lock_guard lock(mutex()); + table().erase(instance); + } + static RdpSession* get(freerdp* instance) { + if (!instance) return nullptr; + std::lock_guard lock(mutex()); + auto it = table().find(instance); + return it == table().end() ? nullptr : it->second; + } + +private: + static std::mutex& mutex() { + static std::mutex m; + return m; + } + static std::map& table() { + static std::map t; + return t; + } +}; + +RdpSession* sessionOf(freerdp* instance) { + return SessionRegistry::get(instance); +} + +} // namespace + +RdpSession::RdpSession() = default; + +RdpSession::~RdpSession() { + stop(); +} + +bool RdpSession::start(const RdpOptions& options, RdpCallbacks callbacks) { + if (running_.load()) { + return false; + } + options_ = std::move(options); + callbacks_ = std::move(callbacks); + running_.store(true); + worker_ = std::thread(&RdpSession::run, this); + return true; +} + +void RdpSession::stop() { + if (!running_.load()) { + return; + } + running_.store(false); + if (instance_) { + // 请求断开;连接线程会从事件循环返回 + freerdp_disconnect(instance_); + } + if (worker_.joinable()) { + worker_.join(); + } + instance_ = nullptr; + context_ = nullptr; +} + +bool RdpSession::sendKeyEvent(bool pressed, bool repeat, uint32_t rdpScanCode) { + std::lock_guard 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 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) { + // FreeRDP 3 的键名与 2.x 不同: + // FreeRDP_ServerHostname / FreeRDP_Username / FreeRDP_DesktopWidth ... + freerdp_settings_set_string(settings, FreeRDP_ServerHostname, options_.server.c_str()); + if (!options_.username.empty()) { + freerdp_settings_set_string(settings, FreeRDP_Username, options_.username.c_str()); + } + if (!options_.password.empty()) { + freerdp_settings_set_string(settings, FreeRDP_Password, options_.password.c_str()); + } + if (!options_.domain.empty()) { + 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, + static_cast(options_.width)); + freerdp_settings_set_uint32(settings, FreeRDP_DesktopHeight, + static_cast(options_.height)); + freerdp_settings_set_uint32(settings, FreeRDP_ColorDepth, static_cast(options_.bpp)); + freerdp_settings_set_bool(settings, FreeRDP_Fullscreen, options_.fullscreen ? TRUE : FALSE); + freerdp_settings_set_bool(settings, FreeRDP_IgnoreCertificate, + options_.ignoreCertificate ? TRUE : FALSE); + freerdp_settings_set_bool(settings, FreeRDP_NetworkAutoDetect, TRUE); + freerdp_settings_set_bool(settings, FreeRDP_UseRdpSecurityLayer, TRUE); + freerdp_settings_set_bool(settings, FreeRDP_SupportDynamicChannels, TRUE); + + // GFX 能力开关,对应命令行 /gfx 的能力组合语义。 + // FreeRDP 3.32.1 单独指定 AVC420 会解析失败,必须成组出现。 + freerdp_settings_set_bool(settings, FreeRDP_GfxProgressive, TRUE); + freerdp_settings_set_bool(settings, FreeRDP_GfxH264, TRUE); + freerdp_settings_set_bool(settings, FreeRDP_GfxAVC444, TRUE); + freerdp_settings_set_bool(settings, FreeRDP_GfxAVC444v2, TRUE); + freerdp_settings_set_bool(settings, FreeRDP_GfxThinClient, TRUE); + freerdp_settings_set_bool(settings, FreeRDP_GfxSmallCache, TRUE); +} + +void RdpSession::run() { + // 1. 创建设置 + rdpSettings* settings = freerdp_settings_new(0); + if (!settings) { + running_.store(false); + if (callbacks_.onState) { + callbacks_.onState(false, "无法创建设置"); + } + return; + } + applySettings(settings); + + // 2. 创建实例 + freerdp* instance = freerdp_new(); + if (!instance) { + freerdp_settings_free(settings); + running_.store(false); + if (callbacks_.onState) { + callbacks_.onState(false, "无法创建 FreeRDP 实例"); + } + return; + } + instance_ = instance; + + // 3. 注册会话对象(按 freerdp* 记录,供静态回调取回) + SessionRegistry::add(instance, this); + + // 4. 注册事件 + instance->PostConnect = onPostConnect; + instance->PostDisconnect = onPostDisconnect; + // NLA 认证:把配置中的账号密码交给 FreeRDP + instance->AuthenticateEx = onAuthenticateEx; + + // 5. 建立上下文 + if (!freerdp_context_new_ex(instance, settings)) { + if (callbacks_.onState) { + callbacks_.onState(false, "无法创建 RDP 上下文"); + } + SessionRegistry::remove(instance); + freerdp_free(instance); + freerdp_settings_free(settings); + instance_ = nullptr; + running_.store(false); + return; + } + context_ = instance->context; + + // 6. 注册输出回调 + // GDI 的初始化(gdi_init)必须放在 PostConnect 里, + // 因为它依赖连接协商后的桌面尺寸与颜色格式。 + if (context_->update) { + context_->update->BitmapUpdate = onBitmapUpdate; + context_->update->EndPaint = onEndPaint; + } + + // 8. 连接(阻塞) + if (!freerdp_connect(instance)) { + if (callbacks_.onState) { + callbacks_.onState(false, "连接失败,请检查地址与网络"); + } + SessionRegistry::remove(instance); + freerdp_context_free(instance); + freerdp_free(instance); + instance_ = nullptr; + context_ = nullptr; + running_.store(false); + return; + } + + // 9. 事件循环 + while (running_.load() && !freerdp_shall_disconnect_context(context_)) { + if (!freerdp_check_event_handles(context_)) { + continue; + } + } + + connected_.store(false); + if (callbacks_.onState) { + callbacks_.onState(false, "会话已结束"); + } + + SessionRegistry::remove(instance); + freerdp_context_free(instance); + freerdp_free(instance); + instance_ = nullptr; + context_ = nullptr; + running_.store(false); +} + +BOOL RdpSession::onAuthenticateEx(freerdp* instance, char** username, char** password, char** domain, + rdp_auth_reason reason) { + (void)reason; + RdpSession* self = sessionOf(instance); + if (!self) { + return FALSE; + } + // 注意:NLA/CredSSP 必须拿到非 nullptr 的凭据指针, + // 空密码也要传空字符串(传 nullptr 会导致 SEC_E_NO_CREDENTIALS)。 + // 文档要求终端不长期持有凭据;正式产品改为后端下发一次性票据 + // 或由 RDP 网关代理认证。 + if (username) { + *username = strdup(self->options_.username.c_str()); + } + if (password) { + *password = strdup(self->options_.password.c_str()); + } + if (domain) { + *domain = strdup(self->options_.domain.c_str()); + } + return TRUE; +} + +BOOL RdpSession::onPostConnect(freerdp* instance) { + RdpSession* self = sessionOf(instance); + if (!self) { + return FALSE; + } + // GDI 初始化必须在连接建立后(官方 x11 客户端同样放在 PostConnect) + if (!gdi_init(instance, PIXEL_FORMAT_BGRX32)) { + if (self->callbacks_.onState) { + self->callbacks_.onState(false, "GDI 初始化失败"); + } + return FALSE; + } + // 挂载 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) { + RdpgfxClientContext* gfx = instance->context->gdi->gfx; + if (!gdi_graphics_pipeline_init(instance->context->gdi, gfx)) { + 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); + if (self->callbacks_.onState) { + self->callbacks_.onState(true, "已连接"); + } + return TRUE; +} + +void RdpSession::onPostDisconnect(freerdp* instance) { + RdpSession* self = sessionOf(instance); + if (self) { + self->connected_.store(false); + } +} + +void RdpSession::emitFrame() { + if (!context_ || !context_->gdi) { + return; + } + rdpGdi* gdi = context_->gdi; + if (!gdi->primary_buffer) { + return; + } + frameWidth_ = static_cast(gdi->width); + frameHeight_ = static_cast(gdi->height); + frameStride_ = gdi->stride; + frameFormat_ = gdi->dstFormat; + + if (callbacks_.onFrame) { + callbacks_.onFrame(gdi->primary_buffer, frameWidth_, frameHeight_, frameStride_, + frameFormat_); + } +} + +BOOL RdpSession::onBitmapUpdate(rdpContext* context, const BITMAP_UPDATE* bitmap) { + (void)bitmap; + // GDI 层已经把位图画进 primary buffer,这里只标记有更新 + if (context && context->instance) { + RdpSession* self = sessionOf(context->instance); + if (self) { + self->frameDirty_ = true; + } + } + return TRUE; +} + +BOOL RdpSession::onEndPaint(rdpContext* context) { + if (context && context->instance) { + RdpSession* self = sessionOf(context->instance); + if (self && !self->gfxPipelineActive_) { + // 传统 Bitmap/RDP 路径没有 GFX EndFrame,在 EndPaint 提交。 + self->emitFrame(); + } + } + return TRUE; +} + +UINT RdpSession::onEndFrame(RdpgfxClientContext* gfxContext, + const RDPGFX_END_FRAME_PDU* endFrame) { + if (!gfxContext || !gfxContext->custom) { + return ERROR_INVALID_DATA; + } + auto* gdi = static_cast(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; + } + + const UINT status = self->originalEndFrame_(gfxContext, endFrame); + if (status == CHANNEL_RC_OK) { + // originalEndFrame_ 已完成 gdi_UpdateSurfaces,primary_buffer 此刻才是完整帧。 + self->emitFrame(); + } + return status; +} + +} // namespace vdi diff --git a/terminal/kiosk-shell/src/rdp_session.h b/terminal/kiosk-shell/src/rdp_session.h new file mode 100644 index 0000000..894fc9b --- /dev/null +++ b/terminal/kiosk-shell/src/rdp_session.h @@ -0,0 +1,116 @@ +// RDP 会话封装(内嵌 FreeRDP 3 客户端 API) +// +// 对应《VDI 云桌面软件开发方案》3.2 / 3.3 节: +// "以 libfreerdp3 API 内嵌(非拉起外部进程),保证会话内无缝切换与全局热键" +// 协议层 FreeRDP 3;图形管线走 GFX;4K 档切 H.265 解码管线 +// +// FreeRDP 3 API 结构(与 2.x 差异较大): +// freerdp* instance = freerdp_new(); // 主对象 +// instance->context = ; // 内部结构,公开 +// instance->context->gdi // GDI 渲染状态 +// instance->PostConnect / PostDisconnect // 事件回调 +// instance->pClientEntryPoints // 客户端入口点 +// +// 渲染:gdi_graphics_pipeline_init_ex 的 pcRdpgfxUpdateSurfaceArea 回调 +// 拿到解码后的帧缓冲,交由 Qt 绘制。 +// 后续 freerdp-rk 分支替换为 GStreamer + mppvideodec 零拷贝管线。 + +#ifndef KIOSK_SHELL_RDP_SESSION_H +#define KIOSK_SHELL_RDP_SESSION_H + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace vdi { + +struct RdpOptions { + std::string server; // 主机或 host:port + std::string username; + std::string password; + std::string domain; + uint32_t keyboardLayout = 0; + int width = 1920; + int height = 1440; + int bpp = 32; + bool fullscreen = true; + // 图形管线能力组合。FreeRDP 3.32.1 的 /gfx 不接受单个管道名, + // 必须是 "|" 分隔的组合(实测 AVC420 单独使用会解析失败)。 + std::string gfx = "progressive|RFX|AVC420|AVC444"; + bool ignoreCertificate = true; // POC 阶段;正式产品必须做证书校验 +}; + +struct RdpCallbacks { + // 每帧解码完成 + std::function + onFrame; + // 会话状态变化 + std::function onState; +}; + +class RdpSession { +public: + RdpSession(); + ~RdpSession(); + + RdpSession(const RdpSession&) = delete; + RdpSession& operator=(const RdpSession&) = delete; + + // 启动会话(内部线程执行阻塞式连接) + bool start(const RdpOptions& options, RdpCallbacks callbacks); + // 主动断开 + void stop(); + 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: + void run(); + void applySettings(rdpSettings* settings); + + // FreeRDP 回调(静态,通过 instance->context 扩展结构找回 C++ 对象) + static BOOL onPostConnect(freerdp* instance); + static void onPostDisconnect(freerdp* instance); + // NLA 认证回调:把配置中的账号/密码交给 FreeRDP + static BOOL onAuthenticateEx(freerdp* instance, char** username, char** password, char** domain, + rdp_auth_reason reason); + // GDI 输出回调:传统位图路径的更新完成。 + static BOOL onBitmapUpdate(rdpContext* context, const BITMAP_UPDATE* bitmap); + static BOOL onEndPaint(rdpContext* context); + // GFX 完整帧边界:先让 FreeRDP 完成所有 surface 合成,再提交一次。 + static UINT onEndFrame(RdpgfxClientContext* context, + const RDPGFX_END_FRAME_PDU* endFrame); + + // 把当前 GDI 帧缓冲交给上层 + void emitFrame(); + + RdpOptions options_; + RdpCallbacks callbacks_; + std::atomic running_{false}; + std::atomic connected_{false}; + freerdp* instance_ = nullptr; + rdpContext* context_ = nullptr; + std::thread worker_; + mutable std::mutex inputMutex_; + uint32_t frameWidth_ = 0; + uint32_t frameHeight_ = 0; + uint32_t frameStride_ = 0; + uint32_t frameFormat_ = 0; + bool frameDirty_ = false; + pcRdpgfxEndFrame originalEndFrame_ = nullptr; + bool gfxPipelineActive_ = false; +}; + +} // namespace vdi + +#endif // KIOSK_SHELL_RDP_SESSION_H diff --git a/terminal/kiosk-shell/src/state_machine.cpp b/terminal/kiosk-shell/src/state_machine.cpp new file mode 100644 index 0000000..a41ac2a --- /dev/null +++ b/terminal/kiosk-shell/src/state_machine.cpp @@ -0,0 +1,73 @@ +#include "state_machine.h" + +namespace vdi { + +const char* stateToString(State s) { + switch (s) { + case State::kBoot: return "BOOT"; + case State::kConfig: return "CONFIG"; + case State::kAuthenticating: return "AUTHENTICATING"; + case State::kDesktopList: return "DESKTOP_LIST"; + case State::kConnecting: return "CONNECTING"; + case State::kSession: return "SESSION"; + case State::kReconnect: return "RECONNECT"; + case State::kError: return "ERROR"; + } + return "UNKNOWN"; +} + +StateMachine::StateMachine(StateListener listener) : listener_(std::move(listener)) {} + +bool StateMachine::isLegalTransition(State from, State to) { + switch (from) { + case State::kBoot: + return to == State::kConfig || to == State::kError; + case State::kConfig: + return to == State::kAuthenticating || to == State::kConnecting || + to == State::kError; + case State::kAuthenticating: + return to == State::kDesktopList || to == State::kConfig || to == State::kError; + case State::kDesktopList: + 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: + return to == State::kConfig || to == State::kDesktopList || + to == State::kConnecting; + } + return false; +} + +bool StateMachine::transitionTo(State next, const QString& detail) { + if (!isLegalTransition(current_, next)) { + return false; + } + State from = current_; + current_ = next; + if (listener_) { + listener_(from, next, detail); + } + return true; +} + +bool StateMachine::canConnect() const { + return current_ == State::kDesktopList || current_ == State::kConfig || + current_ == State::kReconnect || current_ == State::kError; +} + +bool StateMachine::canDisconnect() const { + return current_ == State::kSession || current_ == State::kConnecting; +} + +bool StateMachine::canRetry() const { + return current_ == State::kReconnect || current_ == State::kError; +} + +} // namespace vdi diff --git a/terminal/kiosk-shell/src/state_machine.h b/terminal/kiosk-shell/src/state_machine.h new file mode 100644 index 0000000..9c8d1ba --- /dev/null +++ b/terminal/kiosk-shell/src/state_machine.h @@ -0,0 +1,64 @@ +// kiosk-shell 状态机 +// +// 对应《VDI 云桌面软件开发方案》3.2 节: +// 状态机:BOOT -> CONFIG(无配置)-> CONNECTING -> SESSION(嵌入 freerdp 窗口) +// -> RECONNECT -> ERROR +// +// 状态转换规则: +// BOOT 进程启动,读取本地配置 +// CONFIG 无可用配置或用户停留在登录页,等待输入 +// CONNECTING 正在建立 RDP 连接 +// SESSION 远程会话进行中 +// RECONNECT 会话意外断开,等待重连 +// ERROR 不可恢复错误,提示后回到 CONFIG + +#ifndef KIOSK_SHELL_STATE_MACHINE_H +#define KIOSK_SHELL_STATE_MACHINE_H + +#include +#include +#include + +namespace vdi { + +enum class State { + kBoot, // 启动中 + kConfig, // 首配 / 登录页 + kAuthenticating, // 正在认证 + kDesktopList, // 桌面列表(登录成功后) + kConnecting, // 连接中 + kSession, // 会话中 + kReconnect, // 断线等待重连 + kError, // 错误 +}; + +const char* stateToString(State s); + +class StateMachine { +public: + // 状态说明使用 QString,便于直接显示在状态条上 + using StateListener = std::function; + + explicit StateMachine(StateListener listener = nullptr); + + State current() const { return current_; } + bool isIn(State s) const { return current_ == s; } + + // 尝试切换到目标状态;非法转换会被拒绝并返回 false + bool transitionTo(State next, const QString& detail = QString()); + + // 当前状态下允许执行的动作 + bool canConnect() const; + bool canDisconnect() const; + bool canRetry() const; + +private: + static bool isLegalTransition(State from, State to); + + State current_ = State::kBoot; + StateListener listener_; +}; + +} // namespace vdi + +#endif // KIOSK_SHELL_STATE_MACHINE_H diff --git a/terminal/kiosk-shell/vdi-kiosk.desktop b/terminal/kiosk-shell/vdi-kiosk.desktop new file mode 100644 index 0000000..876212e --- /dev/null +++ b/terminal/kiosk-shell/vdi-kiosk.desktop @@ -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 diff --git a/terminal/kiosk-shell/vdi-moonlight.desktop b/terminal/kiosk-shell/vdi-moonlight.desktop new file mode 100644 index 0000000..dc2a70b --- /dev/null +++ b/terminal/kiosk-shell/vdi-moonlight.desktop @@ -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