1use crate::llm::actions::{
4 client_trait::{Client, ClientActionResult},
5 protocol_trait::Protocol,
6 ActionDefinition, Parameter, ParameterDefinition, StartupExamples,
7};
8use crate::protocol::log_template::LogTemplate;
9use crate::protocol::{ConnectContext, EventType};
10use crate::server::spice::keys;
11use crate::state::app_state::AppState;
12use anyhow::{bail, ensure, Context, Result};
13use serde_json::{json, Value};
14use std::sync::LazyLock;
15
16pub const MAX_TEXT_CHARS: usize = 4096;
18pub const MAX_KEYS: usize = 64;
20pub const DEFAULT_GRID: u64 = 4;
21pub const MAX_GRID: u64 = 16;
22pub const MAX_POINTS: usize = 32;
24pub const DEFAULT_SETTLE_MS: u64 = 500;
25pub const MAX_SETTLE_WAIT_MS: u64 = 10_000;
27pub const MAX_COORD: u64 = 8192;
29
30#[derive(Default)]
31pub struct SpiceClientProtocol;
32
33impl SpiceClientProtocol {
34 pub fn new() -> Self {
35 Self
36 }
37}
38
39fn p(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
40 Parameter {
41 name: name.into(),
42 type_hint: type_hint.into(),
43 description: description.into(),
44 required,
45 }
46}
47
48fn action(
49 name: &str,
50 description: &str,
51 parameters: Vec<Parameter>,
52 example: Value,
53) -> ActionDefinition {
54 ActionDefinition {
55 name: name.into(),
56 description: description.into(),
57 parameters,
58 example,
59 log_template: Some(
60 LogTemplate::new().with_info(format!("-> spice {}", name.trim_start_matches("spice_"))),
61 ),
62 }
63}
64
65fn type_action() -> ActionDefinition {
66 action(
67 "spice_type",
68 "Type text on the remote display as key presses (US keyboard layout; a newline presses Enter, a tab Tab). Characters a US keyboard cannot type are refused.",
69 vec![p("text", "string", "The text to type", true)],
70 json!({"type": "spice_type", "text": "hello world"}),
71 )
72}
73
74fn keys_action() -> ActionDefinition {
75 action(
76 "spice_keys",
77 "Press keys or key combinations in order, e.g. enter, esc, tab, ctrl+alt+delete, f12, up, a.",
78 vec![p("keys", "array", "Key names; join modifiers with + (ctrl, shift, alt, win)", true)],
79 json!({"type": "spice_keys", "keys": ["esc", "ctrl+alt+delete"]}),
80 )
81}
82
83fn mouse_action() -> ActionDefinition {
84 action(
85 "spice_mouse",
86 "Move the mouse to a point on the display and act there. In the server's client mouse mode the position is absolute; in server mode it is sent as relative motion from where this client last put the pointer.",
87 vec![
88 p("x", "number", "Pixels from the left edge", true),
89 p("y", "number", "Pixels from the top edge", true),
90 p(
91 "action",
92 "string",
93 "move, click (default), double_click, press, release, scroll_up or scroll_down",
94 false,
95 ),
96 p("button", "string", "left (default), right or middle", false),
97 ],
98 json!({"type": "spice_mouse", "x": 100, "y": 200, "action": "click", "button": "left"}),
99 )
100}
101
102fn read_screen_action() -> ActionDefinition {
103 action(
104 "spice_read_screen",
105 "Look at the display: once no draw has arrived for settle_ms, its size, most common colour, a grid of each cell's most common colour, a checksum, the colours at any points asked for, and counts of every draw command and image type the server sent arrive as spice_screen. exact is false when some draw could not be applied (a compressed image, a stream).",
106 vec![
107 p("grid", "number", "Cells per side of the colour grid (1-16, default 4)", false),
108 p("settle_ms", "number", "How long the display must go without draws first (default 500)", false),
109 p("points", "array", "Up to 32 [x, y] pixels whose colours to report", false),
110 ],
111 json!({"type": "spice_read_screen", "grid": 4}),
112 )
113}
114
115fn disconnect_action() -> ActionDefinition {
116 action(
117 "disconnect",
118 "Close every channel of the session.",
119 vec![],
120 json!({"type": "disconnect"}),
121 )
122}
123
124pub fn actions() -> Vec<ActionDefinition> {
125 vec![
126 type_action(),
127 keys_action(),
128 mouse_action(),
129 read_screen_action(),
130 disconnect_action(),
131 ]
132}
133
134fn ev(id: &str, description: &str, params: Vec<Parameter>) -> EventType {
135 EventType::new(id, description, read_screen_action().example.clone())
136 .with_parameters(params)
137 .with_actions(actions())
138}
139
140pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
141 ev(
142 "spice_connected",
143 "The SPICE session is up: the main channel linked and the display and inputs channels opened.",
144 vec![
145 p("server", "string", "The server's address", true),
146 p("session_id", "number", "The session id the server assigned", true),
147 p("channels", "array", "Channels the server offered, as type names", true),
148 p("mouse_mode", "string", "client (absolute) or server (relative)", true),
149 p("name", "string", "The guest name the server sent, if any", false),
150 p("agent_connected", "boolean", "Whether a guest agent is running", true),
151 ],
152 )
153});
154
155pub static SURFACE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
156 ev(
157 "spice_surface_created",
158 "The server created (or re-created) the primary display surface.",
159 vec![
160 p(
161 "width",
162 "number",
163 "Surface width in pixels; a spice_mouse x must be below it",
164 true,
165 ),
166 p("height", "number", "Surface height in pixels", true),
167 p(
168 "format",
169 "number",
170 "SPICE surface format (32 is 32-bit xRGB)",
171 true,
172 ),
173 ],
174 )
175});
176
177pub static SCREEN_EVENT: LazyLock<EventType> = LazyLock::new(|| {
178 ev(
179 "spice_screen",
180 "What the display looks like now, as colours, and what the server drew to get there.",
181 vec![
182 p("width", "number", "Width of the primary surface", true),
183 p("height", "number", "Height of the primary surface", true),
184 p(
185 "dominant_color",
186 "string",
187 "The most common colour, #rrggbb",
188 true,
189 ),
190 p(
191 "dominant_share",
192 "number",
193 "Fraction of the screen in that colour (0-1)",
194 true,
195 ),
196 p(
197 "grid",
198 "array",
199 "Rows top to bottom, each cell's most common colour",
200 true,
201 ),
202 p(
203 "checksum",
204 "string",
205 "FNV-1a of the surface's RGB bytes; it changes when the picture does",
206 true,
207 ),
208 p(
209 "points",
210 "array",
211 "The colours at the points asked for",
212 false,
213 ),
214 p(
215 "draw_ops",
216 "object",
217 "Count of each display message the server sent",
218 true,
219 ),
220 p(
221 "images",
222 "object",
223 "Count of each image type the server sent",
224 true,
225 ),
226 p(
227 "exact",
228 "boolean",
229 "False when some draw could not be applied to the picture",
230 true,
231 ),
232 p(
233 "settled",
234 "boolean",
235 "False when draws kept arriving past the wait",
236 true,
237 ),
238 ],
239 )
240});
241
242pub static NOTIFY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
243 ev(
244 "spice_notify",
245 "The server sent a notification on the main channel.",
246 vec![
247 p("severity", "string", "info, warn or error", true),
248 p(
249 "message",
250 "string",
251 "The text, with control characters removed",
252 true,
253 ),
254 ],
255 )
256});
257
258pub static ERROR_EVENT: LazyLock<EventType> = LazyLock::new(|| {
259 ev(
260 "spice_error",
261 "An action could not be carried out.",
262 vec![
263 p("action", "string", "The action's type", true),
264 p("message", "string", "What went wrong", true),
265 ],
266 )
267});
268
269pub static DISCONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
270 ev(
271 "spice_disconnected",
272 "The session ended.",
273 vec![p(
274 "reason",
275 "string",
276 "Why the session ended: the server closed a channel, a bound was broken, or the client disconnected",
277 true,
278 )],
279 )
280});
281
282#[derive(Debug, Clone, PartialEq)]
284pub enum Input {
285 Keys(Vec<((u8, bool), bool)>),
287 Mouse {
288 x: u32,
289 y: u32,
290 action: String,
291 button: String,
292 },
293 ReadScreen {
294 grid: u64,
295 settle_ms: u64,
296 points: Vec<(u32, u32)>,
297 },
298}
299
300const MOUSE_ACTIONS: [&str; 7] = [
301 "move",
302 "click",
303 "double_click",
304 "press",
305 "release",
306 "scroll_up",
307 "scroll_down",
308];
309
310const SHIFT: (u8, bool) = (0x2A, false);
311
312pub fn strokes_for_text(text: &str) -> Result<Vec<((u8, bool), bool)>> {
314 let mut out = Vec::new();
315 for ch in text.chars() {
316 let (sc, shift) = keys::scancode_for_char(ch)
317 .with_context(|| format!("{ch:?} cannot be typed on a US keyboard"))?;
318 if shift {
319 out.push((SHIFT, false));
320 }
321 out.push(((sc, false), false));
322 out.push(((sc, false), true));
323 if shift {
324 out.push((SHIFT, true));
325 }
326 }
327 Ok(out)
328}
329
330pub fn strokes_for_combo(combo: &str) -> Result<Vec<((u8, bool), bool)>> {
333 let parts: Vec<&str> = if combo.trim() == "+" {
334 vec!["+"]
335 } else {
336 combo.split('+').map(str::trim).collect()
337 };
338 ensure!(
339 !parts.is_empty() && parts.iter().all(|p| !p.is_empty()),
340 "{combo:?} is not a key combination"
341 );
342 let mut keys_down = Vec::new();
343 for part in &parts {
344 let key = keys::named_key(part).with_context(|| format!("unknown key {part:?}"))?;
345 keys_down.push(key);
346 }
347 let mut out: Vec<((u8, bool), bool)> = keys_down.iter().map(|k| (*k, false)).collect();
348 out.extend(keys_down.iter().rev().map(|k| (*k, true)));
349 Ok(out)
350}
351
352pub fn check(v: &Value) -> Result<Input> {
353 match v["type"].as_str().unwrap_or_default() {
354 "spice_type" => {
355 let text = v["text"].as_str().context("text is required")?;
356 ensure!(
357 text.chars().count() <= MAX_TEXT_CHARS,
358 "text is at most {MAX_TEXT_CHARS} characters"
359 );
360 Ok(Input::Keys(strokes_for_text(text)?))
361 }
362 "spice_keys" => {
363 let list = v["keys"]
364 .as_array()
365 .context("keys is an array of key names")?;
366 ensure!(
367 !list.is_empty() && list.len() <= MAX_KEYS,
368 "keys holds 1 to {MAX_KEYS} entries"
369 );
370 let mut out = Vec::new();
371 for k in list {
372 out.extend(strokes_for_combo(
373 k.as_str().context("each key is a string")?,
374 )?);
375 }
376 Ok(Input::Keys(out))
377 }
378 "spice_mouse" => {
379 let coord = |k: &str| -> Result<u32> {
380 let n = v[k]
381 .as_u64()
382 .with_context(|| format!("{k} is a number of pixels"))?;
383 ensure!(n < MAX_COORD, "{k} is off the display");
384 Ok(n as u32)
385 };
386 let action = v["action"].as_str().unwrap_or("click").to_string();
387 ensure!(
388 MOUSE_ACTIONS.contains(&action.as_str()),
389 "action is one of {}",
390 MOUSE_ACTIONS.join(", ")
391 );
392 let button = v["button"].as_str().unwrap_or("left").to_string();
393 ensure!(
394 ["left", "right", "middle"].contains(&button.as_str()),
395 "button is left, right or middle"
396 );
397 Ok(Input::Mouse {
398 x: coord("x")?,
399 y: coord("y")?,
400 action,
401 button,
402 })
403 }
404 "spice_read_screen" => {
405 let grid = v["grid"].as_u64().unwrap_or(DEFAULT_GRID);
406 ensure!((1..=MAX_GRID).contains(&grid), "grid is 1 to {MAX_GRID}");
407 let settle_ms = v["settle_ms"].as_u64().unwrap_or(DEFAULT_SETTLE_MS);
408 ensure!(
409 settle_ms <= MAX_SETTLE_WAIT_MS,
410 "settle_ms is at most {MAX_SETTLE_WAIT_MS}"
411 );
412 let mut points = Vec::new();
413 if let Some(list) = v.get("points").filter(|p| !p.is_null()) {
414 let list = list.as_array().context("points is an array of [x, y]")?;
415 ensure!(list.len() <= MAX_POINTS, "at most {MAX_POINTS} points");
416 for pt in list {
417 let xy = pt
418 .as_array()
419 .filter(|a| a.len() == 2)
420 .context("each point is [x, y]")?;
421 let n = |i: usize| -> Result<u32> {
422 let n = xy[i]
423 .as_u64()
424 .context("a point's coordinates are numbers")?;
425 ensure!(n < MAX_COORD, "a point is off the display");
426 Ok(n as u32)
427 };
428 points.push((n(0)?, n(1)?));
429 }
430 }
431 Ok(Input::ReadScreen {
432 grid,
433 settle_ms,
434 points,
435 })
436 }
437 other => bail!("unknown SPICE action {other:?}"),
438 }
439}
440
441impl Protocol for SpiceClientProtocol {
442 fn protocol_name(&self) -> &'static str {
443 "SPICE"
444 }
445 fn stack_name(&self) -> &'static str {
446 "ETH>IP>TCP>SPICE"
447 }
448 fn keywords(&self) -> Vec<&'static str> {
449 vec![
450 "spice",
451 "spice client",
452 "remote display",
453 "qemu",
454 "virt-viewer",
455 "remote-viewer",
456 ]
457 }
458 fn description(&self) -> &'static str {
459 "SPICE client: links main, display and inputs channels with a password, reads the display back as colours and draw counts, and types and clicks"
460 }
461 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
462 actions()
463 }
464 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
465 vec![]
466 }
467 fn get_event_types(&self) -> Vec<EventType> {
468 vec![
469 CONNECTED_EVENT.clone(),
470 SURFACE_EVENT.clone(),
471 SCREEN_EVENT.clone(),
472 NOTIFY_EVENT.clone(),
473 ERROR_EVENT.clone(),
474 DISCONNECTED_EVENT.clone(),
475 ]
476 }
477 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
478 vec![ParameterDefinition {
479 name: "password".into(),
480 type_hint: "string".into(),
481 description: "The server's SPICE password (at most 60 bytes), sent as the RSA-OAEP ticket every channel carries. Leave it out for a server without one".into(),
482 required: false,
483 example: json!("s3cret"),
484 default: None,
485 }]
486 }
487 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
488 use crate::protocol::metadata::*;
489 ProtocolMetadataV2::builder()
490 .state(DevelopmentState::Experimental)
491 .privilege_requirement(PrivilegeRequirement::None)
492 .implementation("Hand-written SPICE 2.2 client: link stage with the RSA-OAEP (SHA-1) ticket, mini and full headers, SET_ACK/ACK flow control and PING/PONG on every channel, main (INIT, CHANNELS_LIST, MOUSE_MODE, NAME, NOTIFY), display (primary surface, uncompressed and cached bitmaps through DRAW_COPY, solid DRAW_FILL, BLACKNESS/WHITENESS/INVERS, COPY_BITS, clip rectangles; asks for uncompressed images) and inputs (PC/AT scancodes, absolute or relative mouse)")
493 .llm_control("What to type, which keys to press, where to click, and when to look; the display comes back as size, colours, a checksum, sampled points and counts of what the server drew")
494 .e2e_testing("tests/client/spice: QEMU's spice-server (qemu-system-x86_64 -spice, no disk, SeaBIOS on the VGA console) — the surface size, the firmware screen appearing after the VM is resumed, keystrokes and mouse buttons read back from QEMU's input trace events, and a wrong password refused; tshark's spice dissector over both directions")
495 .notes("Experimental: one independent server (spice-server through QEMU). No TLS (tls-port), no SASL, no cursor, audio, agent, USB or smartcard channels. Compressed images (QUIC, LZ, GLZ, JPEG, LZ4) and video streams are counted but not decoded, so a reading says exact: false when they were drawn; the client asks for uncompressed images, which spice-server honours. One primary surface; off-screen surfaces are counted, not kept. Typing is US layout only; SPICE has no Unicode key event.")
496 .build()
497 }
498 fn example_prompt(&self) -> &'static str {
499 "Connect to the SPICE display at 127.0.0.1:5930 with password s3cret, press Escape and tell me what colour the screen is"
500 }
501 fn get_startup_examples(&self) -> StartupExamples {
502 let llm = json!({"type":"open_client","protocol":"SPICE","remote_addr":"127.0.0.1:5930",
503 "startup_params":{"password":"s3cret"},
504 "instruction":"Once connected, read the screen and report its main colour"});
505 let mut static_example = llm.clone();
506 static_example["event_handlers"] = json!([
507 {"event_pattern":"spice_connected","handler":{"type":"static","actions":[read_screen_action().example]}},
508 {"event_pattern":"*","handler":{"type":"static","actions":[]}}
509 ]);
510 let mut scripted = static_example.clone();
511 scripted["event_handlers"][0] = json!({"event_pattern":"spice_surface_created","handler":{"type":"script","language":"python",
512 "code":"import json,sys\ne=json.load(sys.stdin)['event']\nprint(json.dumps({'actions':[{'type':'spice_mouse','x':e['width']//2,'y':e['height']//2}]}))"}});
513 StartupExamples::new(llm, scripted, static_example)
514 }
515 fn group_name(&self) -> &'static str {
516 "Remote Access"
517 }
518}
519
520impl Client for SpiceClientProtocol {
521 fn connect(
522 &self,
523 ctx: ConnectContext,
524 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
525 {
526 Box::pin(super::connect(ctx))
527 }
528 fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
529 if v["type"] == "disconnect" {
530 return Ok(ClientActionResult::Disconnect);
531 }
532 check(&v)?;
533 Ok(ClientActionResult::Custom {
534 name: v["type"].as_str().unwrap_or_default().to_string(),
535 data: v,
536 })
537 }
538}