Skip to main content

netget/client/vnc/
actions.rs

1//! VNC client protocol actions implementation
2
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, Parameter, ParameterDefinition,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14/// VNC client connected event
15pub static VNC_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
16    EventType::new(
17        "vnc_connected",
18        "VNC client successfully connected and authenticated",
19        json!({
20            "type": "request_framebuffer_update",
21            "incremental": true
22        }),
23    )
24    .with_parameters(vec![
25        Parameter {
26            name: "remote_addr".to_string(),
27            type_hint: "string".to_string(),
28            description: "Remote VNC server address".to_string(),
29            required: true,
30        },
31        Parameter {
32            name: "width".to_string(),
33            type_hint: "number".to_string(),
34            description: "Framebuffer width in pixels".to_string(),
35            required: true,
36        },
37        Parameter {
38            name: "height".to_string(),
39            type_hint: "number".to_string(),
40            description: "Framebuffer height in pixels".to_string(),
41            required: true,
42        },
43        Parameter {
44            name: "server_name".to_string(),
45            type_hint: "string".to_string(),
46            description: "VNC server name/description".to_string(),
47            required: true,
48        },
49    ])
50});
51
52/// VNC framebuffer update received event
53pub static VNC_CLIENT_FRAMEBUFFER_UPDATE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
54    EventType::new(
55        "vnc_framebuffer_update",
56        "Framebuffer update received from VNC server",
57        json!({
58            "type": "send_pointer_event",
59            "x": 100,
60            "y": 200,
61            "button_mask": 1
62        }),
63    )
64    .with_parameters(vec![
65        Parameter {
66            name: "rectangles".to_string(),
67            type_hint: "number".to_string(),
68            description: "Number of rectangles in this update".to_string(),
69            required: true,
70        },
71        Parameter {
72            name: "update_summary".to_string(),
73            type_hint: "string".to_string(),
74            description: "Human-readable summary of the update".to_string(),
75            required: true,
76        },
77    ])
78});
79
80/// VNC server clipboard text event
81pub static VNC_CLIENT_SERVER_CUT_TEXT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
82    EventType::new(
83        "vnc_server_cut_text",
84        "Server sent clipboard text",
85        json!({"type": "placeholder", "event_id": "vnc_server_cut_text"}),
86    )
87    .with_parameters(vec![Parameter {
88        name: "text".to_string(),
89        type_hint: "string".to_string(),
90        description: "Clipboard text from server".to_string(),
91        required: true,
92    }])
93});
94
95/// VNC client protocol action handler
96pub struct VncClientProtocol;
97
98impl VncClientProtocol {
99    pub fn new() -> Self {
100        Self
101    }
102}
103
104// Implement Protocol trait (common functionality)
105impl Protocol for VncClientProtocol {
106    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
107        vec![
108            ActionDefinition {
109                name: "request_framebuffer_update".to_string(),
110                description: "Request a framebuffer update from the VNC server".to_string(),
111                parameters: vec![
112                    Parameter {
113                        name: "incremental".to_string(),
114                        type_hint: "boolean".to_string(),
115                        description: "If true, only send changes since last update".to_string(),
116                        required: false,
117                    },
118                    Parameter {
119                        name: "x".to_string(),
120                        type_hint: "number".to_string(),
121                        description: "X coordinate of update region (default: 0)".to_string(),
122                        required: false,
123                    },
124                    Parameter {
125                        name: "y".to_string(),
126                        type_hint: "number".to_string(),
127                        description: "Y coordinate of update region (default: 0)".to_string(),
128                        required: false,
129                    },
130                    Parameter {
131                        name: "width".to_string(),
132                        type_hint: "number".to_string(),
133                        description: "Width of update region (default: full width)".to_string(),
134                        required: false,
135                    },
136                    Parameter {
137                        name: "height".to_string(),
138                        type_hint: "number".to_string(),
139                        description: "Height of update region (default: full height)".to_string(),
140                        required: false,
141                    },
142                ],
143                example: json!({
144                    "type": "request_framebuffer_update",
145                    "incremental": true
146                }),
147                log_template: None,
148            },
149            ActionDefinition {
150                name: "send_pointer_event".to_string(),
151                description: "Send a mouse pointer event (move, click, release)".to_string(),
152                parameters: vec![
153                    Parameter {
154                        name: "x".to_string(),
155                        type_hint: "number".to_string(),
156                        description: "X coordinate of pointer".to_string(),
157                        required: true,
158                    },
159                    Parameter {
160                        name: "y".to_string(),
161                        type_hint: "number".to_string(),
162                        description: "Y coordinate of pointer".to_string(),
163                        required: true,
164                    },
165                    Parameter {
166                        name: "button_mask".to_string(),
167                        type_hint: "number".to_string(),
168                        description: "Button mask (0=no buttons, 1=left, 2=middle, 4=right)"
169                            .to_string(),
170                        required: true,
171                    },
172                ],
173                example: json!({
174                    "type": "send_pointer_event",
175                    "x": 100,
176                    "y": 200,
177                    "button_mask": 1
178                }),
179                log_template: None,
180            },
181            ActionDefinition {
182                name: "send_key_event".to_string(),
183                description: "Send a keyboard event (key press or release)".to_string(),
184                parameters: vec![
185                    Parameter {
186                        name: "key".to_string(),
187                        type_hint: "string".to_string(),
188                        description: "The key to send: a single character such as \"a\" or \"A\",                             a named key such as \"Enter\", \"Escape\", \"Tab\", \"Backspace\", an                             arrow (\"Left\"/\"Up\"/\"Right\"/\"Down\"), a modifier (\"Shift\",                             \"Control\", \"Alt\", \"Meta\") or a function key (\"F1\"..\"F35\"). An                             X11 keysym number is also accepted. An unrecognised value is an                             error rather than a silently-sent keysym 0"
189                            .to_string(),
190                        required: true,
191                    },
192                    Parameter {
193                        name: "down".to_string(),
194                        type_hint: "boolean".to_string(),
195                        description: "True for key press, false for key release".to_string(),
196                        required: true,
197                    },
198                ],
199                example: json!({
200                    "type": "send_key_event",
201                    "key": "a",
202                    "down": true
203                }),
204                log_template: None,
205            },
206            ActionDefinition {
207                name: "send_client_cut_text".to_string(),
208                description: "Send clipboard text to the server".to_string(),
209                parameters: vec![Parameter {
210                    name: "text".to_string(),
211                    type_hint: "string".to_string(),
212                    description: "Text to send to clipboard".to_string(),
213                    required: true,
214                }],
215                example: json!({
216                    "type": "send_client_cut_text",
217                    "text": "Hello, VNC!"
218                }),
219                log_template: None,
220            },
221            ActionDefinition {
222                name: "disconnect".to_string(),
223                description: "Disconnect from the VNC server".to_string(),
224                parameters: vec![],
225                example: json!({
226                    "type": "disconnect"
227                }),
228                log_template: None,
229            },
230        ]
231    }
232    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
233        vec![
234            ActionDefinition {
235                name: "request_framebuffer_update".to_string(),
236                description: "Request a framebuffer update in response to server event".to_string(),
237                parameters: vec![Parameter {
238                    name: "incremental".to_string(),
239                    type_hint: "boolean".to_string(),
240                    description: "If true, only send changes since last update".to_string(),
241                    required: false,
242                }],
243                example: json!({
244                    "type": "request_framebuffer_update",
245                    "incremental": true
246                }),
247                log_template: None,
248            },
249            ActionDefinition {
250                name: "send_pointer_event".to_string(),
251                description: "Send pointer event in response to framebuffer update".to_string(),
252                parameters: vec![
253                    Parameter {
254                        name: "x".to_string(),
255                        type_hint: "number".to_string(),
256                        description: "X coordinate".to_string(),
257                        required: true,
258                    },
259                    Parameter {
260                        name: "y".to_string(),
261                        type_hint: "number".to_string(),
262                        description: "Y coordinate".to_string(),
263                        required: true,
264                    },
265                    Parameter {
266                        name: "button_mask".to_string(),
267                        type_hint: "number".to_string(),
268                        description: "Button mask".to_string(),
269                        required: true,
270                    },
271                ],
272                example: json!({
273                    "type": "send_pointer_event",
274                    "x": 100,
275                    "y": 200,
276                    "button_mask": 1
277                }),
278                log_template: None,
279            },
280            // send_key_event and send_client_cut_text were declared ONLY as async actions, so
281            // answering a network event the model could move the pointer but could not press a
282            // key or set the clipboard — the executor rejects an action the event never
283            // advertised. Both have working sync executors, so the gap was purely in what was
284            // offered. Found by a test whose key press never reached the server.
285            ActionDefinition {
286                name: "send_key_event".to_string(),
287                description: "Send a key press or release to the VNC server".to_string(),
288                parameters: vec![
289                    Parameter {
290                        name: "key".to_string(),
291                        type_hint: "string".to_string(),
292                        description: "The key: a single character such as \"a\", a named key                             such as \"Enter\"/\"Escape\"/\"Tab\", an arrow, a modifier, or                             \"F1\"..\"F35\". An X11 keysym number is also accepted"
293                            .to_string(),
294                        required: true,
295                    },
296                    Parameter {
297                        name: "down".to_string(),
298                        type_hint: "boolean".to_string(),
299                        description: "True for key press, false for key release".to_string(),
300                        required: true,
301                    },
302                ],
303                example: json!({"type": "send_key_event", "key": "a", "down": true}),
304                log_template: None,
305            },
306            ActionDefinition {
307                name: "send_client_cut_text".to_string(),
308                description: "Set the VNC server's clipboard to this text".to_string(),
309                parameters: vec![Parameter {
310                    name: "text".to_string(),
311                    type_hint: "string".to_string(),
312                    description: "Clipboard contents to send".to_string(),
313                    required: true,
314                }],
315                example: json!({"type": "send_client_cut_text", "text": "hello"}),
316                log_template: None,
317            },
318            ActionDefinition {
319                name: "wait_for_more".to_string(),
320                description: "Wait for more updates before taking action".to_string(),
321                parameters: vec![],
322                example: json!({
323                    "type": "wait_for_more"
324                }),
325                log_template: None,
326            },
327        ]
328    }
329    fn protocol_name(&self) -> &'static str {
330        "VNC"
331    }
332    fn get_event_types(&self) -> Vec<EventType> {
333        vec![
334            EventType::new(
335                "vnc_connected",
336                "Triggered when VNC client connects and authenticates",
337                json!({"type": "placeholder", "event_id": "vnc_connected"}),
338            ),
339            EventType::new(
340                "vnc_framebuffer_update",
341                "Triggered when framebuffer update is received",
342                json!({"type": "placeholder", "event_id": "vnc_framebuffer_update"}),
343            ),
344            EventType::new(
345                "vnc_server_cut_text",
346                "Triggered when server sends clipboard text",
347                json!({"type": "placeholder", "event_id": "vnc_server_cut_text"}),
348            ),
349        ]
350    }
351    fn stack_name(&self) -> &'static str {
352        "ETH>IP>TCP>RFB"
353    }
354    fn keywords(&self) -> Vec<&'static str> {
355        vec!["vnc", "vnc client", "remote desktop", "rfb"]
356    }
357    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
358        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
359
360        ProtocolMetadataV2::builder()
361            .state(DevelopmentState::Experimental)
362            .implementation("Custom RFB protocol implementation")
363            .llm_control("Control mouse, keyboard, and screen updates")
364            .e2e_testing("x11vnc or TigerVNC server")
365            .build()
366    }
367    fn description(&self) -> &'static str {
368        "VNC (Remote Framebuffer) client for remote desktop control"
369    }
370    fn example_prompt(&self) -> &'static str {
371        "Connect to VNC at localhost:5900 and click at position (100, 200)"
372    }
373    fn group_name(&self) -> &'static str {
374        "Specialized"
375    }
376    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
377        vec![ParameterDefinition {
378            name: "password".to_string(),
379            type_hint: "string".to_string(),
380            description: "VNC password (optional, for VNC authentication)".to_string(),
381            required: false,
382            example: json!("mypassword"),
383            default: None,
384        }]
385    }
386    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
387        use crate::llm::actions::StartupExamples;
388        use serde_json::json;
389
390        StartupExamples::new(
391            // LLM mode: LLM controls VNC interaction
392            json!({
393                "type": "open_client",
394                "remote_addr": "localhost:5900",
395                "base_stack": "vnc",
396                "instruction": "Click at position (100, 200) and type 'Hello'"
397            }),
398            // Script mode: Code-based VNC control
399            json!({
400                "type": "open_client",
401                "remote_addr": "localhost:5900",
402                "base_stack": "vnc",
403                "event_handlers": [{
404                    "event_pattern": "vnc_framebuffer_update",
405                    "handler": {
406                        "type": "script",
407                        "language": "python",
408                        "code": "<vnc_client_handler>"
409                    }
410                }]
411            }),
412            // Static mode: Fixed pointer event
413            json!({
414                "type": "open_client",
415                "remote_addr": "localhost:5900",
416                "base_stack": "vnc",
417                "event_handlers": [
418                    {
419                        "event_pattern": "vnc_connected",
420                        "handler": {
421                            "type": "static",
422                            "actions": [{
423                                "type": "request_framebuffer_update",
424                                "incremental": true
425                            }]
426                        }
427                    },
428                    {
429                        "event_pattern": "vnc_framebuffer_update",
430                        "handler": {
431                            "type": "static",
432                            "actions": [{
433                                "type": "send_pointer_event",
434                                "x": 100,
435                                "y": 200,
436                                "button_mask": 1
437                            }]
438                        }
439                    }
440                ]
441            }),
442        )
443    }
444}
445
446// Implement Client trait (client-specific functionality)
447impl Client for VncClientProtocol {
448    fn connect(
449        &self,
450        ctx: crate::protocol::ConnectContext,
451    ) -> std::pin::Pin<
452        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
453    > {
454        Box::pin(async move {
455            use crate::client::vnc::VncClient;
456            VncClient::connect_with_llm_actions(
457                ctx.remote_addr,
458                ctx.llm_client,
459                ctx.state,
460                ctx.status_tx,
461                ctx.client_id,
462                ctx.startup_params,
463            )
464            .await
465        })
466    }
467    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
468        let action_type = action
469            .get("type")
470            .and_then(|v| v.as_str())
471            .context("Missing 'type' field in action")?;
472
473        match action_type {
474            "request_framebuffer_update" => {
475                let incremental = action
476                    .get("incremental")
477                    .and_then(|v| v.as_bool())
478                    .unwrap_or(true);
479                let x = super::rfb_u16(&action, "x", 0)?;
480                let y = super::rfb_u16(&action, "y", 0)?;
481                let width = super::rfb_u16(&action, "width", u16::MAX)?;
482                let height = super::rfb_u16(&action, "height", u16::MAX)?;
483
484                Ok(ClientActionResult::Custom {
485                    name: "request_framebuffer_update".to_string(),
486                    data: json!({
487                        "incremental": incremental,
488                        "x": x,
489                        "y": y,
490                        "width": width,
491                        "height": height,
492                    }),
493                })
494            }
495            "send_pointer_event" => {
496                for key in ["x", "y", "button_mask"] {
497                    if action.get(key).is_none_or(serde_json::Value::is_null) {
498                        anyhow::bail!("Missing '{key}' field");
499                    }
500                }
501                let x = super::rfb_u16(&action, "x", 0)?;
502                let y = super::rfb_u16(&action, "y", 0)?;
503                let button_mask = super::rfb_u8(&action, "button_mask", 0)?;
504
505                Ok(ClientActionResult::Custom {
506                    name: "send_pointer_event".to_string(),
507                    data: json!({
508                        "x": x,
509                        "y": y,
510                        "button_mask": button_mask,
511                    }),
512                })
513            }
514            "send_key_event" => {
515                // Accepts a name ("a", "Enter", "F1") or a keysym number. This is where the
516                // action is validated before dispatch, so requiring as_u64() here rejected
517                // every named key regardless of what the executor could handle.
518                let key = crate::client::vnc::resolve_keysym(
519                    action.get("key").unwrap_or(&serde_json::Value::Null),
520                )
521                .context(
522                    "Missing or invalid 'key' field: use a single character like \"a\", a name                      like \"Enter\"/\"Escape\"/\"F1\", or an X11 keysym number",
523                )?;
524                let down = action
525                    .get("down")
526                    .and_then(|v| v.as_bool())
527                    .context("Missing or invalid 'down' field")?;
528
529                Ok(ClientActionResult::Custom {
530                    name: "send_key_event".to_string(),
531                    data: json!({
532                        "key": key,
533                        "down": down,
534                    }),
535                })
536            }
537            "send_client_cut_text" => {
538                let text = action
539                    .get("text")
540                    .and_then(|v| v.as_str())
541                    .context("Missing or invalid 'text' field")?;
542
543                Ok(ClientActionResult::Custom {
544                    name: "send_client_cut_text".to_string(),
545                    data: json!({
546                        "text": text,
547                    }),
548                })
549            }
550            "disconnect" => Ok(ClientActionResult::Disconnect),
551            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
552            _ => Err(anyhow::anyhow!(
553                "Unknown VNC client action: {}",
554                action_type
555            )),
556        }
557    }
558}