Skip to main content

netget/server/vnc/
actions.rs

1//! VNC protocol actions — RFB 3.8, LLM-controlled display.
2//!
3//! The division of labour follows netget's model: Rust owns RFB framing, the framebuffer and
4//! the pixel encoding; the LLM decides *what is on screen* and how to react to input. The model
5//! never sees or produces pixels. It answers an event with a structured description of the
6//! screen — rectangles, text, lines, circles, windows, buttons — and
7//! [`parse_display_commands`] turns that into [`DisplayCommand`]s that
8//! `crate::display::DisplayCanvas` rasterises.
9//!
10//! Four events, all of which fire (see `src/server/vnc/mod.rs`):
11//!
12//! - `vnc_framebuffer_update_request` — a client asked for a full (non-incremental) frame
13//! - `vnc_key_event` — a key went down or up
14//! - `vnc_pointer_event` — a mouse button changed state (bare movement is logged, not modelled)
15//! - `vnc_client_cut_text` — the client copied text to its clipboard
16//!
17//! Four sync actions, each reachable from at least one event: `vnc_render_display`,
18//! `vnc_no_change`, `vnc_set_clipboard`, `vnc_disconnect_client`.
19//!
20//! There are no async (user-triggered) actions: the server keeps no registry of live
21//! connections, so there is nothing for an async action to address. Declaring one would be a
22//! promise the code does not keep.
23
24use crate::display::{Color, DisplayCommand};
25use crate::llm::actions::{
26    protocol_trait::{ActionResult, Protocol, Server},
27    ActionDefinition, Parameter, ParameterDefinition,
28};
29use crate::protocol::log_template::LogTemplate;
30use crate::protocol::EventType;
31use crate::state::app_state::AppState;
32use anyhow::{anyhow, bail, Context, Result};
33use serde_json::{json, Value as JsonValue};
34use std::sync::LazyLock;
35
36// ============================================================================
37// Limits
38//
39// Startup parameters come from the LLM or an MCP client and the display commands come from a
40// model answering an event, so every one of these is untrusted input. Nothing below is
41// allocated or indexed before it has been bounds-checked.
42// ============================================================================
43
44/// Framebuffer size used when the caller does not pass `width` / `height`.
45pub const DEFAULT_FRAMEBUFFER_WIDTH: u16 = 800;
46/// Framebuffer size used when the caller does not pass `width` / `height`.
47pub const DEFAULT_FRAMEBUFFER_HEIGHT: u16 = 600;
48
49/// Smallest accepted framebuffer edge. `Pixmap::new` panics on a zero dimension, and a
50/// one-pixel desktop is never what anybody meant.
51pub const MIN_FRAMEBUFFER_DIMENSION: u32 = 16;
52/// Largest accepted framebuffer edge.
53pub const MAX_FRAMEBUFFER_DIMENSION: u32 = 4096;
54/// Largest accepted framebuffer area. A frame is `pixels * 4` bytes on the wire *and* in
55/// memory, uncompressed, so the area matters more than either edge.
56pub const MAX_FRAMEBUFFER_PIXELS: u64 = 3840 * 2160;
57
58/// Largest number of drawing commands accepted in one `vnc_render_display`.
59const MAX_DISPLAY_COMMANDS: usize = 256;
60/// Largest nesting depth for `window` command content.
61const MAX_NESTING_DEPTH: usize = 4;
62/// Largest string accepted in a `text` / `title` / `label` field.
63const MAX_TEXT_LEN: usize = 4096;
64/// Largest coordinate or size accepted in a drawing command.
65const MAX_COORDINATE: u64 = 65535;
66/// Largest clipboard payload the server will push to a client with ServerCutText.
67pub const MAX_CLIPBOARD_LEN: usize = 1 << 16;
68
69// ============================================================================
70// ActionResult::Custom names, shared with the connection loop in mod.rs
71// ============================================================================
72
73/// `Custom` result carrying the rendered screen description.
74pub const RESULT_DISPLAY: &str = "vnc_display";
75/// `Custom` result meaning "the screen is unchanged; send nothing new".
76pub const RESULT_NO_CHANGE: &str = "vnc_no_change";
77/// `Custom` result carrying text for the client's clipboard.
78pub const RESULT_CLIPBOARD: &str = "vnc_clipboard";
79
80/// VNC protocol implementation
81#[derive(Clone, Default)]
82pub struct VncProtocol;
83
84impl VncProtocol {
85    pub fn new() -> Self {
86        Self
87    }
88}
89
90// ============================================================================
91// Display command parsing
92// ============================================================================
93
94/// Short name for a JSON value's type, for error messages.
95fn json_type(value: &JsonValue) -> &'static str {
96    match value {
97        JsonValue::Null => "null",
98        JsonValue::Bool(_) => "boolean",
99        JsonValue::Number(_) => "number",
100        JsonValue::String(_) => "string",
101        JsonValue::Array(_) => "array",
102        JsonValue::Object(_) => "object",
103    }
104}
105
106/// Read a required non-negative integer field, bounded by [`MAX_COORDINATE`].
107fn u32_field(obj: &JsonValue, key: &str) -> Result<u32> {
108    let value = obj
109        .get(key)
110        .ok_or_else(|| anyhow!("missing required field '{key}'"))?;
111    let number = match value {
112        JsonValue::Number(n) => n
113            .as_u64()
114            // Models frequently emit 10.0 where 10 was meant; a negative or fractional
115            // coordinate is still rejected below.
116            .or_else(|| {
117                n.as_f64()
118                    .filter(|f| f.is_finite() && *f >= 0.0)
119                    .map(|f| f as u64)
120            })
121            .ok_or_else(|| {
122                anyhow!("field '{key}' must be a non-negative whole number, got {value}")
123            })?,
124        other => bail!("field '{key}' must be a number, got {}", json_type(other)),
125    };
126    if number > MAX_COORDINATE {
127        bail!("field '{key}' is {number}, which exceeds the maximum of {MAX_COORDINATE}");
128    }
129    Ok(number as u32)
130}
131
132/// Read an optional non-negative integer field.
133fn u32_field_or(obj: &JsonValue, key: &str, default: u32) -> Result<u32> {
134    if obj.get(key).is_none() || obj.get(key) == Some(&JsonValue::Null) {
135        return Ok(default);
136    }
137    u32_field(obj, key)
138}
139
140/// Read an optional boolean field.
141fn bool_field_or(obj: &JsonValue, key: &str, default: bool) -> Result<bool> {
142    match obj.get(key) {
143        None | Some(JsonValue::Null) => Ok(default),
144        Some(JsonValue::Bool(b)) => Ok(*b),
145        Some(other) => bail!(
146            "field '{key}' must be true or false, got {}",
147            json_type(other)
148        ),
149    }
150}
151
152/// Read a required string field, bounded by [`MAX_TEXT_LEN`] bytes.
153fn string_field(obj: &JsonValue, key: &str) -> Result<String> {
154    let value = obj
155        .get(key)
156        .ok_or_else(|| anyhow!("missing required field '{key}'"))?;
157    let text = value
158        .as_str()
159        .ok_or_else(|| anyhow!("field '{key}' must be a string, got {}", json_type(value)))?;
160    if text.len() > MAX_TEXT_LEN {
161        bail!(
162            "field '{key}' is {} bytes, which exceeds the maximum of {MAX_TEXT_LEN}",
163            text.len()
164        );
165    }
166    Ok(text.to_string())
167}
168
169/// Read an optional string field.
170fn optional_string_field(obj: &JsonValue, key: &str) -> Result<Option<String>> {
171    match obj.get(key) {
172        None | Some(JsonValue::Null) => Ok(None),
173        Some(_) => Ok(Some(string_field(obj, key)?)),
174    }
175}
176
177/// Named colours a model can use instead of a hex string.
178fn named_color(name: &str) -> Option<Color> {
179    Some(match name {
180        "black" => Color::BLACK,
181        "white" => Color::WHITE,
182        "red" => Color::RED,
183        "green" => Color::GREEN,
184        "blue" => Color::BLUE,
185        "yellow" => Color::rgb(255, 255, 0),
186        "cyan" => Color::rgb(0, 255, 255),
187        "magenta" => Color::rgb(255, 0, 255),
188        "orange" => Color::rgb(255, 165, 0),
189        "purple" => Color::rgb(128, 0, 128),
190        "navy" => Color::rgb(0, 0, 128),
191        "teal" => Color::rgb(0, 128, 128),
192        "olive" => Color::rgb(128, 128, 0),
193        "maroon" => Color::rgb(128, 0, 0),
194        "silver" => Color::rgb(192, 192, 192),
195        "gray" | "grey" => Color::GRAY,
196        "light_gray" | "lightgray" | "light_grey" | "lightgrey" => Color::LIGHT_GRAY,
197        "dark_gray" | "darkgray" | "dark_grey" | "darkgrey" => Color::DARK_GRAY,
198        _ => return None,
199    })
200}
201
202/// Parse a colour written as `"#rgb"`, `"#rrggbb"`, `"#rrggbbaa"`, a colour name, or an
203/// object `{"r":…, "g":…, "b":…, "a":…}`.
204pub fn parse_color(value: &JsonValue) -> Result<Color> {
205    match value {
206        JsonValue::String(text) => {
207            let trimmed = text.trim();
208            if let Some(hex) = trimmed.strip_prefix('#') {
209                let bytes = hex.as_bytes();
210                let nibble = |b: u8| -> Result<u8> {
211                    (b as char)
212                        .to_digit(16)
213                        .map(|d| d as u8)
214                        .ok_or_else(|| anyhow!("'{trimmed}' is not a valid hex colour"))
215                };
216                return match bytes.len() {
217                    3 => {
218                        let (r, g, b) = (nibble(bytes[0])?, nibble(bytes[1])?, nibble(bytes[2])?);
219                        Ok(Color::rgb(r * 17, g * 17, b * 17))
220                    }
221                    6 | 8 => {
222                        let mut channels = [255u8; 4];
223                        for (i, chunk) in bytes.chunks_exact(2).enumerate() {
224                            channels[i] = nibble(chunk[0])? * 16 + nibble(chunk[1])?;
225                        }
226                        Ok(Color::rgba(
227                            channels[0],
228                            channels[1],
229                            channels[2],
230                            channels[3],
231                        ))
232                    }
233                    other => bail!(
234                        "hex colour '{trimmed}' has {other} digits; use #rgb, #rrggbb or #rrggbbaa"
235                    ),
236                };
237            }
238            named_color(&trimmed.to_ascii_lowercase()).ok_or_else(|| {
239                anyhow!(
240                    "unknown colour '{trimmed}'. Use a hex string such as \"#1e293b\" or one of: \
241                     black, white, red, green, blue, yellow, cyan, magenta, orange, purple, navy, \
242                     teal, olive, maroon, silver, gray, light_gray, dark_gray"
243                )
244            })
245        }
246        JsonValue::Object(_) => {
247            let channel = |key: &str, default: u32| -> Result<u8> {
248                let v = u32_field_or(value, key, default)?;
249                if v > 255 {
250                    bail!("colour channel '{key}' is {v}; each channel is 0-255");
251                }
252                Ok(v as u8)
253            };
254            Ok(Color::rgba(
255                channel("r", 0)?,
256                channel("g", 0)?,
257                channel("b", 0)?,
258                channel("a", 255)?,
259            ))
260        }
261        other => bail!(
262            "a colour must be a hex string (\"#1e293b\"), a colour name (\"blue\") or an object \
263             {{\"r\":30,\"g\":41,\"b\":59}}, got {}",
264            json_type(other)
265        ),
266    }
267}
268
269/// Optional colour field with a fallback.
270fn color_field_or(obj: &JsonValue, key: &str, default: Color) -> Result<Color> {
271    match obj.get(key) {
272        None | Some(JsonValue::Null) => Ok(default),
273        Some(value) => parse_color(value).with_context(|| format!("field '{key}'")),
274    }
275}
276
277/// Turn the model's `commands` array into renderable [`DisplayCommand`]s.
278///
279/// Every failure is reported rather than skipped: a screen half-drawn because one command was
280/// silently dropped is indistinguishable from one the model meant to draw.
281pub fn parse_display_commands(value: &JsonValue) -> Result<Vec<DisplayCommand>> {
282    parse_command_array(value, 0)
283}
284
285fn parse_command_array(value: &JsonValue, depth: usize) -> Result<Vec<DisplayCommand>> {
286    let array = value.as_array().ok_or_else(|| {
287        anyhow!(
288            "'commands' must be an array of drawing commands, got {}",
289            json_type(value)
290        )
291    })?;
292    if array.len() > MAX_DISPLAY_COMMANDS {
293        bail!(
294            "{} drawing commands were given; at most {MAX_DISPLAY_COMMANDS} are accepted",
295            array.len()
296        );
297    }
298    array
299        .iter()
300        .enumerate()
301        .map(|(index, command)| {
302            parse_display_command(command, depth).with_context(|| format!("command #{index}"))
303        })
304        .collect()
305}
306
307fn parse_display_command(value: &JsonValue, depth: usize) -> Result<DisplayCommand> {
308    if depth > MAX_NESTING_DEPTH {
309        bail!("window content is nested more than {MAX_NESTING_DEPTH} levels deep");
310    }
311    if !value.is_object() {
312        bail!(
313            "a drawing command must be an object such as \
314             {{\"type\":\"text\",\"x\":10,\"y\":20,\"text\":\"hi\"}}, got {}",
315            json_type(value)
316        );
317    }
318    let command_type = value
319        .get("type")
320        .and_then(|v| v.as_str())
321        .ok_or_else(|| anyhow!("drawing command has no \"type\" field"))?;
322
323    Ok(match command_type {
324        "background" => DisplayCommand::SetBackground {
325            color: color_field_or(value, "color", Color::BLACK)?,
326        },
327        "clear" => DisplayCommand::Clear,
328        "rectangle" | "rect" => DisplayCommand::DrawRectangle {
329            x: u32_field(value, "x")?,
330            y: u32_field(value, "y")?,
331            width: u32_field(value, "width")?,
332            height: u32_field(value, "height")?,
333            color: color_field_or(value, "color", Color::WHITE)?,
334            filled: bool_field_or(value, "filled", true)?,
335        },
336        "text" => DisplayCommand::DrawText {
337            x: u32_field(value, "x")?,
338            y: u32_field(value, "y")?,
339            text: string_field(value, "text")?,
340            font_size: u32_field_or(value, "size", 16)?.clamp(6, 256),
341            color: color_field_or(value, "color", Color::WHITE)?,
342        },
343        "line" => DisplayCommand::DrawLine {
344            x1: u32_field(value, "x1")?,
345            y1: u32_field(value, "y1")?,
346            x2: u32_field(value, "x2")?,
347            y2: u32_field(value, "y2")?,
348            color: color_field_or(value, "color", Color::WHITE)?,
349            width: u32_field_or(value, "width", 1)?.clamp(1, 64),
350        },
351        "circle" => DisplayCommand::DrawCircle {
352            x: u32_field(value, "x")?,
353            y: u32_field(value, "y")?,
354            radius: u32_field(value, "radius")?,
355            color: color_field_or(value, "color", Color::WHITE)?,
356            filled: bool_field_or(value, "filled", true)?,
357        },
358        "window" => DisplayCommand::DrawWindow {
359            x: u32_field(value, "x")?,
360            y: u32_field(value, "y")?,
361            width: u32_field(value, "width")?,
362            height: u32_field(value, "height")?,
363            title: string_field(value, "title")?,
364            content: match value.get("content") {
365                None | Some(JsonValue::Null) => Vec::new(),
366                Some(content) => parse_command_array(content, depth + 1)?,
367            },
368        },
369        "button" => DisplayCommand::DrawButton {
370            x: u32_field(value, "x")?,
371            y: u32_field(value, "y")?,
372            width: u32_field(value, "width")?,
373            height: u32_field(value, "height")?,
374            label: string_field(value, "label")?,
375        },
376        "textbox" => DisplayCommand::DrawTextBox {
377            x: u32_field(value, "x")?,
378            y: u32_field(value, "y")?,
379            width: u32_field(value, "width")?,
380            height: u32_field(value, "height")?,
381            text: optional_string_field(value, "text")?.unwrap_or_default(),
382            placeholder: optional_string_field(value, "placeholder")?,
383        },
384        "ascii_art" => DisplayCommand::RenderAsciiArt {
385            text: string_field(value, "text")?,
386            font_size: u32_field_or(value, "size", 14)?.clamp(6, 256),
387            fg_color: color_field_or(value, "color", Color::WHITE)?,
388            bg_color: color_field_or(value, "background", Color::BLACK)?,
389        },
390        other => bail!(
391            "unknown drawing command \"{other}\". Valid types are: background, clear, rectangle, \
392             text, line, circle, window, button, textbox, ascii_art"
393        ),
394    })
395}
396
397/// Validate a framebuffer dimension pair supplied at startup.
398pub fn validate_framebuffer_size(width: u32, height: u32) -> Result<(u16, u16)> {
399    for (name, value) in [("width", width), ("height", height)] {
400        if !(MIN_FRAMEBUFFER_DIMENSION..=MAX_FRAMEBUFFER_DIMENSION).contains(&value) {
401            bail!(
402                "{name} must be between {MIN_FRAMEBUFFER_DIMENSION} and \
403                 {MAX_FRAMEBUFFER_DIMENSION}, got {value}"
404            );
405        }
406    }
407    let pixels = width as u64 * height as u64;
408    if pixels > MAX_FRAMEBUFFER_PIXELS {
409        bail!(
410            "{width}x{height} is {pixels} pixels, above the {MAX_FRAMEBUFFER_PIXELS} pixel limit \
411             (each frame is 4 bytes per pixel, uncompressed, on every update)"
412        );
413    }
414    // Both are <= MAX_FRAMEBUFFER_DIMENSION (4096), so the casts cannot truncate.
415    Ok((width as u16, height as u16))
416}
417
418// ============================================================================
419// Action definitions
420// ============================================================================
421
422fn render_display_action() -> ActionDefinition {
423    ActionDefinition {
424        name: "vnc_render_display".to_string(),
425        description:
426            "Draw the whole screen and send it to the client. Describe the screen structurally \
427             with drawing commands - never pixels. Each command is an object with a \"type\": \
428             \"background\" {color}, \"clear\", \"rectangle\" {x,y,width,height,color,filled}, \
429             \"text\" {x,y,text,size,color}, \"line\" {x1,y1,x2,y2,color,width}, \"circle\" \
430             {x,y,radius,color,filled}, \"window\" {x,y,width,height,title,content:[...]}, \
431             \"button\" {x,y,width,height,label}, \"textbox\" \
432             {x,y,width,height,text,placeholder}, \"ascii_art\" {text,size,color,background}. \
433             Commands are drawn in order, so start with a \"background\". Colours are hex \
434             strings (\"#1e293b\"), colour names (\"blue\") or {\"r\":30,\"g\":41,\"b\":59}. \
435             Coordinates are pixels from the top-left of the framebuffer"
436                .to_string(),
437        parameters: vec![Parameter {
438            name: "commands".to_string(),
439            type_hint: "array".to_string(),
440            description:
441                "Ordered list of drawing commands making up the entire screen. The framebuffer \
442                 is redrawn from scratch, so include everything that should be visible"
443                    .to_string(),
444            required: true,
445        }],
446        example: json!({
447            "type": "vnc_render_display",
448            "commands": [
449                {"type": "background", "color": "#1e293b"},
450                {"type": "text", "x": 40, "y": 60, "text": "Hello from NetGet", "size": 28, "color": "white"},
451                {"type": "rectangle", "x": 40, "y": 100, "width": 240, "height": 80, "color": "#38bdf8", "filled": true}
452            ]
453        }),
454        log_template: Some(
455            LogTemplate::new()
456                .with_info("VNC screen redrawn")
457                .with_debug("VNC render: {commands_len} command(s)")
458                .with_trace("VNC render: {json_pretty(.)}"),
459        ),
460    }
461}
462
463fn no_change_action() -> ActionDefinition {
464    ActionDefinition {
465        name: "vnc_no_change".to_string(),
466        description:
467            "The screen is unchanged - send no update. Use this for input the display does not \
468             react to (a key release, a click on empty space) so the client keeps showing what \
469             it already has instead of receiving a redundant full frame"
470                .to_string(),
471        parameters: Vec::new(),
472        example: json!({"type": "vnc_no_change"}),
473        log_template: Some(LogTemplate::new().with_debug("VNC: screen unchanged")),
474    }
475}
476
477fn set_clipboard_action() -> ActionDefinition {
478    ActionDefinition {
479        name: "vnc_set_clipboard".to_string(),
480        description:
481            "Put text on the client's clipboard (RFB ServerCutText). Latin-1 only per the RFB \
482             specification; characters outside it are replaced with '?'"
483                .to_string(),
484        parameters: vec![Parameter {
485            name: "text".to_string(),
486            type_hint: "string".to_string(),
487            description: "Text to copy to the client's clipboard".to_string(),
488            required: true,
489        }],
490        example: json!({"type": "vnc_set_clipboard", "text": "copied from NetGet"}),
491        log_template: Some(
492            LogTemplate::new()
493                .with_debug("VNC clipboard set")
494                .with_trace("VNC clipboard: {preview(text,200)}"),
495        ),
496    }
497}
498
499fn disconnect_client_action() -> ActionDefinition {
500    ActionDefinition {
501        name: "vnc_disconnect_client".to_string(),
502        description: "Close this VNC connection immediately".to_string(),
503        parameters: Vec::new(),
504        example: json!({"type": "vnc_disconnect_client"}),
505        log_template: Some(LogTemplate::new().with_info("VNC connection closed by decision")),
506    }
507}
508
509pub static VNC_RENDER_DISPLAY_ACTION: LazyLock<ActionDefinition> =
510    LazyLock::new(render_display_action);
511pub static VNC_NO_CHANGE_ACTION: LazyLock<ActionDefinition> = LazyLock::new(no_change_action);
512pub static VNC_SET_CLIPBOARD_ACTION: LazyLock<ActionDefinition> =
513    LazyLock::new(set_clipboard_action);
514pub static VNC_DISCONNECT_CLIENT_ACTION: LazyLock<ActionDefinition> =
515    LazyLock::new(disconnect_client_action);
516
517// ============================================================================
518// Event types
519// ============================================================================
520
521/// A client asked for a full repaint of the framebuffer.
522///
523/// Only *non-incremental* requests raise this. An incremental request is the client saying
524/// "tell me when something changes", and RFB lets the server hold it open until something
525/// does; answering every one of them with a model call would spend one LLM round-trip per
526/// poll on a screen nobody touched. Held requests are answered by the next event that
527/// redraws the screen.
528pub static VNC_FRAMEBUFFER_UPDATE_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
529    EventType::new(
530        "vnc_framebuffer_update_request",
531        "A VNC client asked for a full framebuffer update - draw the whole screen",
532        json!({
533            "type": "vnc_render_display",
534            "commands": [
535                {"type": "background", "color": "#1e293b"},
536                {"type": "text", "x": 40, "y": 60, "text": "NetGet", "size": 32, "color": "white"}
537            ]
538        }),
539    )
540    .with_parameters(vec![
541        Parameter {
542            name: "width".to_string(),
543            type_hint: "number".to_string(),
544            description: "Framebuffer width in pixels - draw within this".to_string(),
545            required: true,
546        },
547        Parameter {
548            name: "height".to_string(),
549            type_hint: "number".to_string(),
550            description: "Framebuffer height in pixels - draw within this".to_string(),
551            required: true,
552        },
553        Parameter {
554            name: "first_request".to_string(),
555            type_hint: "boolean".to_string(),
556            description: "True when this is the first frame this client has ever asked for"
557                .to_string(),
558            required: true,
559        },
560    ])
561    .with_actions(vec![
562        VNC_RENDER_DISPLAY_ACTION.clone(),
563        VNC_NO_CHANGE_ACTION.clone(),
564        VNC_DISCONNECT_CLIENT_ACTION.clone(),
565    ])
566    .with_log_template(
567        LogTemplate::new()
568            .with_info("VNC full framebuffer requested ({width}x{height})")
569            .with_debug("VNC framebuffer update request, first={first_request}")
570            .with_trace("VNC framebuffer update request: {json_pretty(.)}"),
571    )
572});
573
574/// A key went down or up on the client keyboard.
575pub static VNC_KEY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
576    EventType::new(
577        "vnc_key_event",
578        "A VNC client pressed or released a key - redraw the screen if it should react",
579        json!({
580            "type": "vnc_render_display",
581            "commands": [
582                {"type": "background", "color": "#1e293b"},
583                {"type": "text", "x": 40, "y": 60, "text": "You typed: a", "size": 24, "color": "white"}
584            ]
585        }),
586    )
587    .with_parameters(vec![
588        Parameter {
589            name: "down".to_string(),
590            type_hint: "boolean".to_string(),
591            description: "True for a key press, false for a key release".to_string(),
592            required: true,
593        },
594        Parameter {
595            name: "keysym".to_string(),
596            type_hint: "number".to_string(),
597            description: "X11 keysym code of the key".to_string(),
598            required: true,
599        },
600        Parameter {
601            name: "key".to_string(),
602            type_hint: "string".to_string(),
603            description:
604                "Human readable key name: the character for printable keys, otherwise a name \
605                 such as Return, BackSpace, Tab, Escape, Left, F1"
606                    .to_string(),
607            required: true,
608        },
609    ])
610    .with_actions(vec![
611        VNC_RENDER_DISPLAY_ACTION.clone(),
612        VNC_NO_CHANGE_ACTION.clone(),
613        VNC_SET_CLIPBOARD_ACTION.clone(),
614        VNC_DISCONNECT_CLIENT_ACTION.clone(),
615    ])
616    .with_alternative_example(json!({"type": "vnc_no_change"}))
617    .with_log_template(
618        LogTemplate::new()
619            .with_info("VNC key {key} ({down})")
620            .with_debug("VNC KeyEvent: down={down}, keysym={keysym}, key={key}")
621            .with_trace("VNC KeyEvent: {json_pretty(.)}"),
622    )
623});
624
625/// A mouse button changed state on the client.
626///
627/// Bare movement does not raise this: a viewer emits a PointerEvent for every pixel the mouse
628/// travels, and one model round-trip per pixel is not a feature. Movement is dual-logged, and
629/// the coordinates of the press/release that *does* raise the event are carried in it.
630pub static VNC_POINTER_EVENT: LazyLock<EventType> = LazyLock::new(|| {
631    EventType::new(
632        "vnc_pointer_event",
633        "A VNC client pressed or released a mouse button - redraw the screen if it should react",
634        json!({
635            "type": "vnc_render_display",
636            "commands": [
637                {"type": "background", "color": "#1e293b"},
638                {"type": "circle", "x": 100, "y": 100, "radius": 20, "color": "#f97316", "filled": true}
639            ]
640        }),
641    )
642    .with_parameters(vec![
643        Parameter {
644            name: "x".to_string(),
645            type_hint: "number".to_string(),
646            description: "Pointer X position in framebuffer pixels".to_string(),
647            required: true,
648        },
649        Parameter {
650            name: "y".to_string(),
651            type_hint: "number".to_string(),
652            description: "Pointer Y position in framebuffer pixels".to_string(),
653            required: true,
654        },
655        Parameter {
656            name: "pressed".to_string(),
657            type_hint: "boolean".to_string(),
658            description: "True when a button went down, false when one came up".to_string(),
659            required: true,
660        },
661        Parameter {
662            name: "buttons".to_string(),
663            type_hint: "array".to_string(),
664            description:
665                "Buttons currently held, by name: left, middle, right, scroll_up, scroll_down"
666                    .to_string(),
667            required: true,
668        },
669        Parameter {
670            name: "button_mask".to_string(),
671            type_hint: "number".to_string(),
672            description: "Raw RFB button mask, bit 0 = left, bit 1 = middle, bit 2 = right"
673                .to_string(),
674            required: true,
675        },
676    ])
677    .with_actions(vec![
678        VNC_RENDER_DISPLAY_ACTION.clone(),
679        VNC_NO_CHANGE_ACTION.clone(),
680        VNC_DISCONNECT_CLIENT_ACTION.clone(),
681    ])
682    .with_alternative_example(json!({"type": "vnc_no_change"}))
683    .with_log_template(
684        LogTemplate::new()
685            .with_info("VNC pointer at {x},{y} pressed={pressed}")
686            .with_debug("VNC PointerEvent: x={x}, y={y}, mask={button_mask}")
687            .with_trace("VNC PointerEvent: {json_pretty(.)}"),
688    )
689});
690
691/// The client copied text to its clipboard and sent it to us.
692pub static VNC_CLIENT_CUT_TEXT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
693    EventType::new(
694        "vnc_client_cut_text",
695        "A VNC client copied text to its clipboard and sent it to the server",
696        json!({"type": "vnc_set_clipboard", "text": "NetGet received your clipboard"}),
697    )
698    .with_parameters(vec![Parameter {
699        name: "text".to_string(),
700        type_hint: "string".to_string(),
701        description: "Text the client copied".to_string(),
702        required: true,
703    }])
704    .with_actions(vec![
705        VNC_SET_CLIPBOARD_ACTION.clone(),
706        VNC_RENDER_DISPLAY_ACTION.clone(),
707        VNC_NO_CHANGE_ACTION.clone(),
708        VNC_DISCONNECT_CLIENT_ACTION.clone(),
709    ])
710    .with_alternative_example(json!({"type": "vnc_no_change"}))
711    .with_log_template(
712        LogTemplate::new()
713            .with_info("VNC clipboard received from client")
714            .with_debug("VNC ClientCutText: {text_len} bytes")
715            .with_trace("VNC ClientCutText: {preview(text,200)}"),
716    )
717});
718
719/// Every event type this protocol can raise. All four are raised by
720/// `VncServer::message_loop`.
721pub fn get_vnc_event_types() -> Vec<EventType> {
722    vec![
723        VNC_FRAMEBUFFER_UPDATE_REQUEST_EVENT.clone(),
724        VNC_KEY_EVENT.clone(),
725        VNC_POINTER_EVENT.clone(),
726        VNC_CLIENT_CUT_TEXT_EVENT.clone(),
727    ]
728}
729
730// ============================================================================
731// Protocol trait
732// ============================================================================
733
734impl Protocol for VncProtocol {
735    fn protocol_name(&self) -> &'static str {
736        "VNC"
737    }
738    fn stack_name(&self) -> &'static str {
739        "ETH>IP>TCP>VNC"
740    }
741    fn description(&self) -> &'static str {
742        "VNC remote desktop server (RFB 3.8) whose screen contents are drawn by the LLM"
743    }
744    fn example_prompt(&self) -> &'static str {
745        "Start a VNC server on port 5900 showing a blue desktop with a welcome message"
746    }
747    fn keywords(&self) -> Vec<&'static str> {
748        vec!["vnc", "rfb", "remote desktop", "framebuffer"]
749    }
750    fn group_name(&self) -> &'static str {
751        "Network Services"
752    }
753
754    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
755        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
756
757        ProtocolMetadataV2::builder()
758            .state(DevelopmentState::Experimental)
759            .well_known_port(5900)
760            // ClientCutText is the one client message a peer sizes; its u32 length is refused
761            // against this before the buffer is allocated. Tested in
762            // tests/server/vnc/inbound_limit_test.rs.
763            .max_inbound_bytes(crate::server::vnc::MAX_CUT_TEXT_LEN as usize)
764            .implementation(
765                "Hand-written RFB 3.8 over TCP (no crate), Raw encoding, tiny-skia display canvas",
766            )
767            .llm_control(
768                "Screen contents and reactions to input: the model answers \
769                 vnc_framebuffer_update_request, vnc_key_event, vnc_pointer_event and \
770                 vnc_client_cut_text with structured drawing commands, clipboard text or a \
771                 disconnect",
772            )
773            .e2e_testing("custom RFB 3.8 client in tests/server/vnc/test.rs, decoding real pixels")
774            .notes(
775                "Security type 'None' only - any client is admitted, there is no VNC-Auth and no \
776                 password. Raw encoding only (no CopyRect/Hextile/ZRLE/Tight), so every update is \
777                 width*height*4 bytes uncompressed. SetPixelFormat is parsed and ignored: the \
778                 server always sends 32bpp BGRX, so a client that negotiates 8- or 16-bit colour \
779                 renders garbage. Framebuffer size is fixed at startup (width/height parameters, \
780                 default 800x600); there is no DesktopSize pseudo-encoding, so it cannot change \
781                 afterwards. Incremental update requests are held open until an event redraws the \
782                 screen rather than triggering a model call each. Pointer movement without a \
783                 button change is logged, not sent to the model. Each connection has its own \
784                 framebuffer; the ClientInit shared flag is read and ignored.",
785            )
786            .build()
787    }
788
789    /// Declared because both are read in `spawn`. Nothing else is accepted.
790    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
791        vec![
792            ParameterDefinition {
793                name: "width".to_string(),
794                type_hint: "number".to_string(),
795                description: format!(
796                    "Framebuffer width in pixels ({MIN_FRAMEBUFFER_DIMENSION}-\
797                     {MAX_FRAMEBUFFER_DIMENSION}, default {DEFAULT_FRAMEBUFFER_WIDTH}). Fixed for \
798                     the life of the server"
799                ),
800                required: false,
801                example: json!(1024),
802                default: None,
803            },
804            ParameterDefinition {
805                name: "height".to_string(),
806                type_hint: "number".to_string(),
807                description: format!(
808                    "Framebuffer height in pixels ({MIN_FRAMEBUFFER_DIMENSION}-\
809                     {MAX_FRAMEBUFFER_DIMENSION}, default {DEFAULT_FRAMEBUFFER_HEIGHT}). Fixed \
810                     for the life of the server"
811                ),
812                required: false,
813                example: json!(768),
814                default: None,
815            },
816            ParameterDefinition {
817                name: "desktop_name".to_string(),
818                type_hint: "string".to_string(),
819                description:
820                    "Desktop name announced in ServerInit and shown in the viewer's title bar"
821                        .to_string(),
822                required: false,
823                example: json!("NetGet Desktop"),
824                default: None,
825            },
826        ]
827    }
828
829    /// None: the server keeps no registry of live connections, so there is nothing a
830    /// user-triggered action could address. Everything happens in response to an event.
831    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
832        Vec::new()
833    }
834
835    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
836        vec![
837            render_display_action(),
838            no_change_action(),
839            set_clipboard_action(),
840            disconnect_client_action(),
841        ]
842    }
843
844    fn get_event_types(&self) -> Vec<EventType> {
845        get_vnc_event_types()
846    }
847
848    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
849        use crate::llm::actions::StartupExamples;
850
851        StartupExamples::new(
852            // LLM mode: the model draws every frame.
853            json!({
854                "type": "open_server",
855                "port": 5900,
856                "base_stack": "vnc",
857                "startup_params": {
858                    "width": 1024,
859                    "height": 768,
860                    "desktop_name": "NetGet Desktop"
861                },
862                "instruction": "Show a dark blue desktop with a window titled 'Terminal' \
863                                containing the text 'NetGet VNC'. React to typed keys by \
864                                appending the character to the window."
865            }),
866            // Script mode: deterministic, no LLM call per frame.
867            json!({
868                "type": "open_server",
869                "port": 5900,
870                "base_stack": "vnc",
871                "event_handlers": [{
872                    "event_pattern": "vnc_framebuffer_update_request",
873                    "handler": {
874                        "type": "script",
875                        "language": "python",
876                        "code": "respond([{'type': 'vnc_render_display', 'commands': [\
877            {'type': 'background', 'color': '#1e293b'}, \
878            {'type': 'text', 'x': 40, 'y': 60, 'text': 'NetGet %dx%d' % (event['width'], event['height']), \
879            'size': 28, 'color': 'white'}]}])"
880                    }
881                }]
882            }),
883            // Static mode: one fixed screen, no LLM call ever.
884            json!({
885                "type": "open_server",
886                "port": 5900,
887                "base_stack": "vnc",
888                "event_handlers": [{
889                    "event_pattern": "vnc_framebuffer_update_request",
890                    "handler": {
891                        "type": "static",
892                        "actions": [{
893                            "type": "vnc_render_display",
894                            "commands": [
895                                {"type": "background", "color": "#1e293b"},
896                                {"type": "text", "x": 40, "y": 60, "text": "NetGet VNC",
897                                 "size": 28, "color": "white"}
898                            ]
899                        }]
900                    }
901                }]
902            }),
903        )
904    }
905}
906
907// ============================================================================
908// Server trait
909// ============================================================================
910
911impl Server for VncProtocol {
912    fn spawn(
913        &self,
914        ctx: crate::protocol::SpawnContext,
915    ) -> std::pin::Pin<
916        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
917    > {
918        Box::pin(async move {
919            use crate::server::vnc::VncServer;
920
921            let (width, height, desktop_name) = match ctx.startup_params.as_ref() {
922                Some(params) => {
923                    let width = params
924                        .get_optional_u32("width")?
925                        .unwrap_or(DEFAULT_FRAMEBUFFER_WIDTH as u32);
926                    let height = params
927                        .get_optional_u32("height")?
928                        .unwrap_or(DEFAULT_FRAMEBUFFER_HEIGHT as u32);
929                    (width, height, params.get_optional_string("desktop_name")?)
930                }
931                None => (
932                    DEFAULT_FRAMEBUFFER_WIDTH as u32,
933                    DEFAULT_FRAMEBUFFER_HEIGHT as u32,
934                    None,
935                ),
936            };
937            let (width, height) = validate_framebuffer_size(width, height)?;
938
939            VncServer::spawn_with_llm_actions(
940                ctx.legacy_listen_addr(),
941                ctx.llm_client,
942                ctx.state,
943                ctx.status_tx,
944                ctx.server_id,
945                width,
946                height,
947                desktop_name,
948            )
949            .await
950        })
951    }
952
953    fn execute_action(&self, action: JsonValue) -> Result<ActionResult> {
954        let action_type = action
955            .get("type")
956            .and_then(|v| v.as_str())
957            .ok_or_else(|| anyhow!("Missing 'type' field in action"))?;
958
959        match action_type {
960            "vnc_render_display" => {
961                let commands = action.get("commands").ok_or_else(|| {
962                    anyhow!(
963                        "vnc_render_display requires a 'commands' array describing the screen, \
964                         e.g. [{{\"type\":\"background\",\"color\":\"#1e293b\"}}]"
965                    )
966                })?;
967                let parsed = parse_display_commands(commands)
968                    .context("invalid 'commands' in vnc_render_display")?;
969                let count = parsed.len();
970                Ok(ActionResult::Custom {
971                    name: RESULT_DISPLAY.to_string(),
972                    data: json!({
973                        "commands": serde_json::to_value(parsed)
974                            .context("failed to serialise display commands")?,
975                        "count": count,
976                    }),
977                })
978            }
979            "vnc_no_change" => Ok(ActionResult::Custom {
980                name: RESULT_NO_CHANGE.to_string(),
981                data: json!({}),
982            }),
983            "vnc_set_clipboard" => {
984                let text = action
985                    .get("text")
986                    .and_then(|v| v.as_str())
987                    .ok_or_else(|| anyhow!("vnc_set_clipboard requires a 'text' string"))?;
988                if text.len() > MAX_CLIPBOARD_LEN {
989                    bail!(
990                        "vnc_set_clipboard text is {} bytes, above the {MAX_CLIPBOARD_LEN} byte \
991                         limit",
992                        text.len()
993                    );
994                }
995                Ok(ActionResult::Custom {
996                    name: RESULT_CLIPBOARD.to_string(),
997                    data: json!({ "text": text }),
998                })
999            }
1000            "vnc_disconnect_client" => Ok(ActionResult::CloseConnection),
1001            // The dashboard's "[ disconnect this peer ]" row injects `close_connection`, the
1002            // generic verb the peer task understands; map it to the same result as the VNC-named
1003            // disconnect so the operator can drop a connection from the rail.
1004            "close_connection" => Ok(ActionResult::CloseConnection),
1005            other => Err(anyhow!(
1006                "Unknown VNC action '{other}'. Valid actions: vnc_render_display, \
1007                 vnc_no_change, vnc_set_clipboard, vnc_disconnect_client"
1008            )),
1009        }
1010    }
1011}