harness: add /screen page and /screen/ws WebSocket VNC relay
Reads /etc/hyperhive/gui.json at startup to get the VNC port written by the weston-vnc ExecStart script (issue #50). Adds: - gui_vnc_port: Option<u16> on AppState - gui_enabled: bool on StateSnapshot (for issue #52 screen link) - GET /screen: serves a minimal RFB-over-WebSocket viewer (screen.html) - GET /screen/ws: upgrades to WebSocket and byte-pumps to 127.0.0.1:<vnc_port> The relay is a pure two-task byte pump (WS→TCP and TCP→WS), transparent to any RFB variant including VeNCrypt. Returns 404 when gui is not enabled. screen.html is a self-contained RFB client: handshake, FramebufferUpdate (Raw encoding), pointer and keyboard forwarding — enough to display the desktop and interact with it. noVNC assets (issue #52) replace this. Closes #51
This commit is contained in:
parent
29df223650
commit
2027e94432
5 changed files with 651 additions and 1 deletions
|
|
@ -22,6 +22,7 @@ use axum::{
|
|||
routing::{get, post},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream};
|
||||
|
||||
use crate::client;
|
||||
|
|
@ -56,6 +57,9 @@ struct AppState {
|
|||
files: TurnFiles,
|
||||
/// Prevents `/api/compact` from racing with an in-flight normal turn.
|
||||
turn_lock: TurnLock,
|
||||
/// VNC port read from `/etc/hyperhive/gui.json` at startup.
|
||||
/// `None` when the file is absent (gui not enabled for this agent).
|
||||
gui_vnc_port: Option<u16>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
|
|
@ -80,6 +84,7 @@ pub async fn serve(
|
|||
files: TurnFiles,
|
||||
turn_lock: TurnLock,
|
||||
) -> Result<()> {
|
||||
let gui_vnc_port = read_gui_json();
|
||||
let state = AppState {
|
||||
label,
|
||||
login,
|
||||
|
|
@ -88,6 +93,7 @@ pub async fn serve(
|
|||
socket,
|
||||
files,
|
||||
turn_lock,
|
||||
gui_vnc_port,
|
||||
};
|
||||
let app = Router::new()
|
||||
.route("/", get(serve_index))
|
||||
|
|
@ -110,6 +116,8 @@ pub async fn serve(
|
|||
.route("/stats", get(serve_stats))
|
||||
.route("/static/stats.js", get(serve_stats_js))
|
||||
.route("/api/stats", get(api_stats))
|
||||
.route("/screen", get(serve_screen))
|
||||
.route("/screen/ws", get(screen_ws))
|
||||
.with_state(state);
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||
let listener = bind_with_retry(addr, "web UI").await?;
|
||||
|
|
@ -215,6 +223,91 @@ async fn serve_stats_js() -> impl IntoResponse {
|
|||
)
|
||||
}
|
||||
|
||||
async fn serve_screen() -> impl IntoResponse {
|
||||
(
|
||||
[("content-type", "text/html; charset=utf-8")],
|
||||
include_str!("../assets/screen.html"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Read `/etc/hyperhive/gui.json` and extract the `vnc_port` field.
|
||||
/// Returns `None` if the file is absent or unparseable — GUI not enabled.
|
||||
fn read_gui_json() -> Option<u16> {
|
||||
let text = std::fs::read_to_string("/etc/hyperhive/gui.json").ok()?;
|
||||
let val: serde_json::Value = serde_json::from_str(&text).ok()?;
|
||||
val["vnc_port"].as_u64().and_then(|p| u16::try_from(p).ok())
|
||||
}
|
||||
|
||||
/// WebSocket handler: upgrade then pump bytes between the WS client and
|
||||
/// the VNC server on `127.0.0.1:<vnc_port>`. Returns 404 when gui is not
|
||||
/// enabled for this agent.
|
||||
async fn screen_ws(
|
||||
ws: axum::extract::ws::WebSocketUpgrade,
|
||||
State(state): State<AppState>,
|
||||
) -> Response {
|
||||
let Some(vnc_port) = state.gui_vnc_port else {
|
||||
return (StatusCode::NOT_FOUND, "gui not enabled for this agent").into_response();
|
||||
};
|
||||
ws.on_upgrade(move |socket| relay_ws_vnc(socket, vnc_port))
|
||||
}
|
||||
|
||||
/// Pure byte pump: forwards raw bytes between the WebSocket client and
|
||||
/// the VNC TCP stream. Transparent to any RFB variant (plain, VeNCrypt).
|
||||
async fn relay_ws_vnc(socket: axum::extract::ws::WebSocket, vnc_port: u16) {
|
||||
// Import futures traits locally so they don't conflict with
|
||||
// tokio_stream::StreamExt used at module scope.
|
||||
use axum::extract::ws::Message;
|
||||
use futures_util::{SinkExt, StreamExt as _};
|
||||
|
||||
let addr = format!("127.0.0.1:{vnc_port}");
|
||||
let Ok(tcp) = tokio::net::TcpStream::connect(&addr).await else {
|
||||
tracing::warn!(%addr, "screen/ws: could not connect to VNC server");
|
||||
return;
|
||||
};
|
||||
let (mut tcp_rx, mut tcp_tx) = tcp.into_split();
|
||||
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||
|
||||
// WS → TCP
|
||||
let ws_to_tcp = tokio::spawn(async move {
|
||||
while let Some(Ok(msg)) = futures_util::StreamExt::next(&mut ws_rx).await {
|
||||
match msg {
|
||||
Message::Binary(data) => {
|
||||
if tcp_tx.write_all(&data).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => {} // ping/pong/text: ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// TCP → WS
|
||||
let tcp_to_ws = tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
loop {
|
||||
match tcp_rx.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
if ws_tx
|
||||
.send(Message::Binary(buf[..n].to_vec().into()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for either direction to close, then let both tasks drop.
|
||||
tokio::select! {
|
||||
_ = ws_to_tcp => {}
|
||||
_ = tcp_to_ws => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct StatsQuery {
|
||||
window: Option<String>,
|
||||
|
|
@ -271,6 +364,10 @@ struct StateSnapshot {
|
|||
/// Cumulative token usage across the most recent turn's inferences
|
||||
/// (cost signal). `null` until the first turn finishes.
|
||||
cost_usage: Option<crate::events::TokenUsage>,
|
||||
/// Whether the weston VNC compositor is configured for this agent
|
||||
/// (i.e. `/etc/hyperhive/gui.json` was present at harness startup).
|
||||
/// When true, the UI may render a `🖥 screen` link to `/screen`.
|
||||
gui_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -367,6 +464,7 @@ async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
|||
model,
|
||||
ctx_usage,
|
||||
cost_usage,
|
||||
gui_enabled: state.gui_vnc_port.is_some(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue