1use 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
36pub const DEFAULT_FRAMEBUFFER_WIDTH: u16 = 800;
46pub const DEFAULT_FRAMEBUFFER_HEIGHT: u16 = 600;
48
49pub const MIN_FRAMEBUFFER_DIMENSION: u32 = 16;
52pub const MAX_FRAMEBUFFER_DIMENSION: u32 = 4096;
54pub const MAX_FRAMEBUFFER_PIXELS: u64 = 3840 * 2160;
57
58const MAX_DISPLAY_COMMANDS: usize = 256;
60const MAX_NESTING_DEPTH: usize = 4;
62const MAX_TEXT_LEN: usize = 4096;
64const MAX_COORDINATE: u64 = 65535;
66pub const MAX_CLIPBOARD_LEN: usize = 1 << 16;
68
69pub const RESULT_DISPLAY: &str = "vnc_display";
75pub const RESULT_NO_CHANGE: &str = "vnc_no_change";
77pub const RESULT_CLIPBOARD: &str = "vnc_clipboard";
79
80#[derive(Clone, Default)]
82pub struct VncProtocol;
83
84impl VncProtocol {
85 pub fn new() -> Self {
86 Self
87 }
88}
89
90fn 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
106fn 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 .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
132fn 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
140fn 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
152fn 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
169fn 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
177fn 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
202pub 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
269fn 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
277pub 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
397pub 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 Ok((width as u16, height as u16))
416}
417
418fn 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
517pub 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
574pub 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
625pub 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
691pub 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
719pub 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
730impl 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 .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 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 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 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 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 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
907impl 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 "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}