Skip to main content

netget/server/spice/
actions.rs

1//! SPICE server actions: who is admitted, what the screen shows, and how it reacts to input.
2//!
3//! Rust owns the SPICE link (RSA ticket, capabilities, channel connections), the framing and
4//! every pixel; the model decides admission and content. It never sees a pixel or the
5//! password: it is told whether the password was verified, and it answers with the same
6//! structured drawing commands VNC's model uses (`crate::server::vnc::actions::
7//! parse_display_commands`), which Rust rasterises and sends as uncompressed bitmaps.
8//!
9//! Four events, all raised in `src/server/spice/mod.rs`:
10//!
11//! - `spice_connection_request` — a client linked a main channel and its ticket checked out
12//! - `spice_display_ready` — a display channel attached and nothing has been drawn yet
13//! - `spice_key_event` — a key went down on the inputs channel
14//! - `spice_mouse_event` — a mouse button went down or came up
15use crate::llm::actions::{
16    protocol_trait::{ActionResult, Protocol, Server},
17    ActionDefinition, Parameter, ParameterDefinition,
18};
19use crate::protocol::log_template::LogTemplate;
20use crate::protocol::EventType;
21use crate::server::vnc::actions::parse_display_commands;
22use crate::state::app_state::AppState;
23use anyhow::{anyhow, bail, Context, Result};
24use serde_json::{json, Value as JsonValue};
25use std::sync::LazyLock;
26
27pub const DEFAULT_WIDTH: u32 = 800;
28pub const DEFAULT_HEIGHT: u32 = 600;
29pub const MIN_SIDE: u32 = 16;
30pub const MAX_SIDE: u32 = 4096;
31/// The surface is `width * height * 4` bytes in memory and on the wire per full frame.
32pub const MAX_PIXELS: u64 = 3840 * 2160;
33pub const DEFAULT_NAME: &str = "NetGet SPICE";
34/// The longest notification the model may send.
35pub const MAX_NOTIFY_LEN: usize = 1024;
36
37/// `Custom` result names shared with `mod.rs`.
38pub const RESULT_ACCEPT: &str = "spice_accept";
39pub const RESULT_REJECT: &str = "spice_reject";
40pub const RESULT_DISPLAY: &str = "spice_display";
41pub const RESULT_NO_CHANGE: &str = "spice_no_change";
42pub const RESULT_NOTIFY: &str = "spice_notify";
43
44#[derive(Clone, Default)]
45pub struct SpiceProtocol;
46
47impl SpiceProtocol {
48    pub fn new() -> Self {
49        Self
50    }
51}
52
53fn p(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
54    Parameter {
55        name: name.into(),
56        type_hint: type_hint.into(),
57        description: description.into(),
58        required,
59    }
60}
61
62fn accept_action() -> ActionDefinition {
63    ActionDefinition {
64        name: "spice_accept_connection".into(),
65        description: "Admit this SPICE client: the link completes and its main channel opens. \
66                      Without this the client is refused (SPICE_LINK_ERR_PERMISSION_DENIED)."
67            .into(),
68        parameters: vec![],
69        example: json!({"type": "spice_accept_connection"}),
70        log_template: Some(LogTemplate::new().with_info("SPICE client admitted")),
71    }
72}
73
74fn reject_action() -> ActionDefinition {
75    ActionDefinition {
76        name: "spice_reject_connection".into(),
77        description: "Refuse this SPICE client: the link ends with \
78                      SPICE_LINK_ERR_PERMISSION_DENIED, which the client reports as an \
79                      authentication failure."
80            .into(),
81        parameters: vec![],
82        example: json!({"type": "spice_reject_connection"}),
83        log_template: Some(LogTemplate::new().with_info("SPICE client refused")),
84    }
85}
86
87fn render_action() -> ActionDefinition {
88    ActionDefinition {
89        name: "spice_render_display".into(),
90        description:
91            "Draw the whole screen and send it to the client. Describe it with drawing commands, \
92             never pixels. Each command is an object with a \"type\": \"background\" {color}, \
93             \"clear\", \"rectangle\" {x,y,width,height,color,filled}, \"text\" \
94             {x,y,text,size,color}, \"line\" {x1,y1,x2,y2,color,width}, \"circle\" \
95             {x,y,radius,color,filled}, \"window\" {x,y,width,height,title,content:[...]}, \
96             \"button\" {x,y,width,height,label}, \"textbox\" {x,y,width,height,text,placeholder}, \
97             \"ascii_art\" {text,size,color,background}. Commands are drawn in order, so start \
98             with a \"background\". Colours are hex (\"#1e293b\"), names (\"blue\") or \
99             {\"r\":30,\"g\":41,\"b\":59}. Coordinates are pixels from the top left"
100                .into(),
101        parameters: vec![p(
102            "commands",
103            "array",
104            "Ordered drawing commands making up the entire screen; it is redrawn from scratch",
105            true,
106        )],
107        example: json!({
108            "type": "spice_render_display",
109            "commands": [
110                {"type": "background", "color": "#1e293b"},
111                {"type": "text", "x": 40, "y": 60, "text": "Hello from NetGet", "size": 28, "color": "white"},
112                {"type": "rectangle", "x": 40, "y": 100, "width": 240, "height": 80, "color": "#38bdf8", "filled": true}
113            ]
114        }),
115        log_template: Some(
116            LogTemplate::new()
117                .with_info("SPICE screen redrawn")
118                .with_trace("SPICE render: {json_pretty(.)}"),
119        ),
120    }
121}
122
123fn no_change_action() -> ActionDefinition {
124    ActionDefinition {
125        name: "spice_no_change".into(),
126        description: "Leave the screen as it is: nothing is sent. Use it for input the screen \
127                      does not react to."
128            .into(),
129        parameters: vec![],
130        example: json!({"type": "spice_no_change"}),
131        log_template: Some(LogTemplate::new().with_debug("SPICE: screen unchanged")),
132    }
133}
134
135fn notify_action() -> ActionDefinition {
136    ActionDefinition {
137        name: "spice_notify".into(),
138        description: "Send a notification on the main channel (SPICE_MSG_NOTIFY). Viewers log \
139                      it; they do not draw it, so put anything the user must see on the screen."
140            .into(),
141        parameters: vec![
142            p(
143                "message",
144                "string",
145                &format!("The notification text, at most {MAX_NOTIFY_LEN} bytes"),
146                true,
147            ),
148            p("severity", "string", "info (default), warn or error", false),
149        ],
150        example: json!({"type": "spice_notify", "message": "Welcome", "severity": "info"}),
151        log_template: Some(
152            LogTemplate::new()
153                .with_info("SPICE notification sent")
154                .with_debug("SPICE notify: {preview(message,200)}"),
155        ),
156    }
157}
158
159fn disconnect_action() -> ActionDefinition {
160    ActionDefinition {
161        name: "spice_disconnect_client".into(),
162        description: "End this client's whole SPICE session: every channel is closed.".into(),
163        parameters: vec![],
164        example: json!({"type": "spice_disconnect_client"}),
165        log_template: Some(LogTemplate::new().with_info("SPICE session closed by decision")),
166    }
167}
168
169pub static ACCEPT_ACTION: LazyLock<ActionDefinition> = LazyLock::new(accept_action);
170pub static REJECT_ACTION: LazyLock<ActionDefinition> = LazyLock::new(reject_action);
171pub static RENDER_ACTION: LazyLock<ActionDefinition> = LazyLock::new(render_action);
172pub static NO_CHANGE_ACTION: LazyLock<ActionDefinition> = LazyLock::new(no_change_action);
173pub static NOTIFY_ACTION: LazyLock<ActionDefinition> = LazyLock::new(notify_action);
174pub static DISCONNECT_ACTION: LazyLock<ActionDefinition> = LazyLock::new(disconnect_action);
175
176fn screen_actions() -> Vec<ActionDefinition> {
177    vec![
178        RENDER_ACTION.clone(),
179        NO_CHANGE_ACTION.clone(),
180        NOTIFY_ACTION.clone(),
181        DISCONNECT_ACTION.clone(),
182    ]
183}
184
185pub static CONNECTION_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
186    EventType::new(
187        "spice_connection_request",
188        "A SPICE client linked its main channel. Admit it (spice_accept_connection, optionally \
189         with a spice_render_display for its first screen) or refuse it",
190        json!({"type": "spice_accept_connection"}),
191    )
192    .with_parameters(vec![
193        p("client", "string", "The client's address", true),
194        p(
195            "password_configured",
196            "boolean",
197            "True when the server requires a password; the client's ticket has then already \
198             been checked against it, and only a correct one reaches you",
199            true,
200        ),
201        p(
202            "width",
203            "number",
204            "Width in pixels of the screen this client will see",
205            true,
206        ),
207        p("height", "number", "Its height in pixels", true),
208        p(
209            "client_capabilities",
210            "array",
211            "Common capabilities the client offered (auth_selection, auth_spice, auth_sasl, \
212             mini_header)",
213            true,
214        ),
215    ])
216    .with_actions(vec![
217        ACCEPT_ACTION.clone(),
218        REJECT_ACTION.clone(),
219        RENDER_ACTION.clone(),
220        NOTIFY_ACTION.clone(),
221    ])
222    .with_alternative_example(json!({"type": "spice_reject_connection"}))
223    .with_log_template(
224        LogTemplate::new()
225            .with_info("SPICE client {client} asks to connect")
226            .with_trace("SPICE connection request: {json_pretty(.)}"),
227    )
228});
229
230pub static DISPLAY_READY_EVENT: LazyLock<EventType> =
231    LazyLock::new(|| {
232        EventType::new(
233        "spice_display_ready",
234        "A SPICE client opened its display channel and nothing has been drawn yet: draw the \
235         whole screen",
236        json!({
237            "type": "spice_render_display",
238            "commands": [
239                {"type": "background", "color": "#1e293b"},
240                {"type": "text", "x": 40, "y": 60, "text": "NetGet", "size": 32, "color": "white"}
241            ]
242        }),
243    )
244    .with_parameters(vec![
245        p("width", "number", "Surface width in pixels - draw within this", true),
246        p("height", "number", "Surface height in pixels", true),
247    ])
248    .with_actions(screen_actions())
249    .with_log_template(
250        LogTemplate::new()
251            .with_info("SPICE display ready ({width}x{height})")
252            .with_trace("SPICE display ready: {json_pretty(.)}"),
253    )
254    });
255
256pub static KEY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
257    EventType::new(
258        "spice_key_event",
259        "A key was pressed on the SPICE client's keyboard - redraw the screen if it should react",
260        json!({
261            "type": "spice_render_display",
262            "commands": [
263                {"type": "background", "color": "#1e293b"},
264                {"type": "text", "x": 40, "y": 60, "text": "You typed: a", "size": 24, "color": "white"}
265            ]
266        }),
267    )
268    .with_parameters(vec![
269        p(
270            "key",
271            "string",
272            "The key: its character for a printable key (shift applied), otherwise a name such \
273             as Return, BackSpace, Tab, Escape, Left, F1",
274            true,
275        ),
276        p(
277            "text",
278            "string",
279            "The character the key types (US layout), or null for a key that types nothing",
280            false,
281        ),
282        p("scancode", "number", "PC/AT set-1 scancode", true),
283        p("extended", "boolean", "True for an 0xE0-prefixed key", true),
284        p("shift", "boolean", "True when a Shift key was held as this key went down", true),
285        p("ctrl", "boolean", "True when a Control key was held as this key went down", true),
286        p("alt", "boolean", "True when an Alt key was held as this key went down", true),
287    ])
288    .with_actions(screen_actions())
289    .with_alternative_example(json!({"type": "spice_no_change"}))
290    .with_log_template(
291        LogTemplate::new()
292            .with_info("SPICE key {key}")
293            .with_debug("SPICE key: {json(.)}"),
294    )
295});
296
297pub static MOUSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
298    EventType::new(
299        "spice_mouse_event",
300        "A mouse button went down on the SPICE client - redraw the screen if it should react",
301        json!({
302            "type": "spice_render_display",
303            "commands": [
304                {"type": "background", "color": "#1e293b"},
305                {"type": "circle", "x": 100, "y": 100, "radius": 20, "color": "#f97316", "filled": true}
306            ]
307        }),
308    )
309    .with_parameters(vec![
310        p("x", "number", "Pointer X in surface pixels", true),
311        p("y", "number", "Pointer Y in surface pixels", true),
312        p(
313            "button",
314            "string",
315            "left, middle, right, scroll_up or scroll_down",
316            true,
317        ),
318        p(
319            "buttons",
320            "array",
321            "Buttons held after this press: left, middle, right",
322            true,
323        ),
324        p(
325            "mouse_mode",
326            "string",
327            "client (absolute positions) or server (relative motion, so x and y are where \
328             NetGet has tracked the pointer to)",
329            true,
330        ),
331    ])
332    .with_actions(screen_actions())
333    .with_alternative_example(json!({"type": "spice_no_change"}))
334    .with_log_template(
335        LogTemplate::new()
336            .with_info("SPICE {button} button at {x},{y}")
337            .with_debug("SPICE mouse: {json(.)}"),
338    )
339});
340
341pub fn event_types() -> Vec<EventType> {
342    vec![
343        CONNECTION_REQUEST_EVENT.clone(),
344        DISPLAY_READY_EVENT.clone(),
345        KEY_EVENT.clone(),
346        MOUSE_EVENT.clone(),
347    ]
348}
349
350/// Validate a surface size supplied at startup.
351pub fn validate_size(width: u32, height: u32) -> Result<(u32, u32)> {
352    for (name, v) in [("width", width), ("height", height)] {
353        if !(MIN_SIDE..=MAX_SIDE).contains(&v) {
354            bail!("{name} must be between {MIN_SIDE} and {MAX_SIDE}, got {v}");
355        }
356    }
357    if u64::from(width) * u64::from(height) > MAX_PIXELS {
358        bail!("{width}x{height} is above the {MAX_PIXELS} pixel limit");
359    }
360    Ok((width, height))
361}
362
363pub fn parse_severity(v: Option<&str>) -> Result<u32> {
364    use crate::server::spice::wire::*;
365    Ok(match v.unwrap_or("info") {
366        "info" => NOTIFY_SEVERITY_INFO,
367        "warn" | "warning" => NOTIFY_SEVERITY_WARN,
368        "error" => NOTIFY_SEVERITY_ERROR,
369        other => bail!("severity is info, warn or error, not {other:?}"),
370    })
371}
372
373impl Protocol for SpiceProtocol {
374    fn protocol_name(&self) -> &'static str {
375        "SPICE"
376    }
377    fn stack_name(&self) -> &'static str {
378        "ETH>IP>TCP>SPICE"
379    }
380    fn description(&self) -> &'static str {
381        "SPICE remote display server (main, display and inputs channels) whose screen the LLM draws and whose admission the LLM decides"
382    }
383    fn example_prompt(&self) -> &'static str {
384        "Start a SPICE server on port 5930 with password 'pw' showing a login banner, and greet each key the user types"
385    }
386    fn keywords(&self) -> Vec<&'static str> {
387        vec![
388            "spice",
389            "spice server",
390            "remote display",
391            "qxl",
392            "virt-viewer",
393            "remote-viewer",
394        ]
395    }
396    fn group_name(&self) -> &'static str {
397        "Remote Access"
398    }
399
400    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
401        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
402        ProtocolMetadataV2::builder()
403            .state(DevelopmentState::Experimental)
404            .answers_on_failure()
405            .max_inbound_bytes(crate::server::spice::MAX_CLIENT_MESSAGE_BYTES as usize)
406            .implementation(
407                "Hand-written SPICE 2.2 (no crate): the link stage with an RSA-1024 key per server \
408                 and the RSA-OAEP (SHA-1) ticket checked in Rust, mini and full data headers, a \
409                 main channel (INIT, NAME, CHANNELS_LIST, MOUSE_MODE, NOTIFY), a display channel \
410                 with one primary 32-bit xRGB surface drawn as uncompressed top-down bitmaps \
411                 (DRAW_COPY), and an inputs channel decoding PC/AT scancodes and mouse \
412                 position, motion and buttons. tiny-skia + cosmic-text render the model's \
413                 drawing commands",
414            )
415            .llm_control(
416                "Admission (spice_connection_request, after Rust has checked the password), the \
417                 screen (spice_render_display on admission, spice_display_ready, and in reply to \
418                 spice_key_event and spice_mouse_event), notifications and disconnects",
419            )
420            .e2e_testing(
421                "tests/server/spice: spice-gtk's spicy-screenshot reads back the rendered \
422                 surface pixel by pixel and refuses a wrong password; spicy under Xvfb with \
423                 xdotool typing into it delivers keys to the model; spice-html5 in headless \
424                 Chrome through websockify renders the same screen; tshark's spice dissector \
425                 reads both directions",
426            )
427            .notes(
428                "Experimental: two independent clients drive it (spice-gtk and spice-html5) but \
429                 nobody has reviewed it against the spice-protocol headers line by line, no \
430                 fuzz target exists, and its pair job had not run in CI when it landed. No TLS channel (NEED_SECURED is never sent; tls-port is not \
431                 offered), no SASL, no cursor, audio, agent, USB or smartcard channels, no image \
432                 compression (every update is width*height*4 bytes), no streams, one display \
433                 and one surface per session. The screen is redrawn whole on every change. \
434                 Without a password any ticket is accepted and admission is the model's alone. \
435                 Key releases and pointer movement are logged, not sent to the model.",
436            )
437            .build()
438    }
439
440    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
441        vec![
442            ParameterDefinition {
443                name: "password".into(),
444                type_hint: "string".into(),
445                description: format!(
446                    "Password every channel's ticket must carry (at most \
447                     {} bytes). Checked in Rust; never shown to the model. Without one any \
448                     ticket is accepted, as QEMU's disable-ticketing, and admission is the \
449                     model's decision alone",
450                    crate::server::spice::wire::MAX_PASSWORD_LENGTH
451                ),
452                required: false,
453                example: json!("s3cret"),
454                default: None,
455            },
456            ParameterDefinition {
457                name: "width".into(),
458                type_hint: "number".into(),
459                description: format!("Surface width in pixels ({MIN_SIDE}-{MAX_SIDE})"),
460                required: false,
461                example: json!(1024),
462                default: Some(json!(DEFAULT_WIDTH)),
463            },
464            ParameterDefinition {
465                name: "height".into(),
466                type_hint: "number".into(),
467                description: format!("Surface height in pixels ({MIN_SIDE}-{MAX_SIDE})"),
468                required: false,
469                example: json!(768),
470                default: Some(json!(DEFAULT_HEIGHT)),
471            },
472            ParameterDefinition {
473                name: "name".into(),
474                type_hint: "string".into(),
475                description: "Guest name sent in SPICE_MSG_MAIN_NAME; viewers put it in their \
476                              title bar"
477                    .into(),
478                required: false,
479                example: json!("NetGet Desktop"),
480                default: Some(json!(DEFAULT_NAME)),
481            },
482        ]
483    }
484
485    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
486        Vec::new()
487    }
488
489    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
490        vec![
491            accept_action(),
492            reject_action(),
493            render_action(),
494            no_change_action(),
495            notify_action(),
496            disconnect_action(),
497        ]
498    }
499
500    fn get_event_types(&self) -> Vec<EventType> {
501        event_types()
502    }
503
504    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
505        use crate::llm::actions::StartupExamples;
506        StartupExamples::new(
507            json!({
508                "type": "open_server",
509                "port": 5930,
510                "base_stack": "spice",
511                "startup_params": {"password": "s3cret", "width": 1024, "height": 768},
512                "instruction": "Admit every client. Show a dark blue desktop with a window \
513                                titled 'Terminal'; append each typed character to it."
514            }),
515            json!({
516                "type": "open_server",
517                "port": 5930,
518                "base_stack": "spice",
519                "event_handlers": [{
520                    "event_pattern": "*",
521                    "handler": {
522                        "type": "script",
523                        "language": "python",
524                        "code": "import json,sys\ni=json.load(sys.stdin); t=i['event_type_id']; e=i['event']\nscreen=lambda s:{'type':'spice_render_display','commands':[{'type':'background','color':'#1e293b'},{'type':'text','x':40,'y':60,'text':s,'size':28,'color':'white'}]}\na=[]\nif t=='spice_connection_request': a=[{'type':'spice_accept_connection'},screen('Welcome')]\nelif t=='spice_key_event': a=[screen('You typed '+e['key'])]\nelse: a=[screen('NetGet')]\nprint(json.dumps({'actions':a}))"
525                    }
526                }]
527            }),
528            json!({
529                "type": "open_server",
530                "port": 5930,
531                "base_stack": "spice",
532                "event_handlers": [
533                    {"event_pattern": "spice_connection_request", "handler": {"type": "static", "actions": [
534                        {"type": "spice_accept_connection"},
535                        {"type": "spice_render_display", "commands": [
536                            {"type": "background", "color": "#1e293b"},
537                            {"type": "text", "x": 40, "y": 60, "text": "NetGet SPICE", "size": 28, "color": "white"}
538                        ]}
539                    ]}},
540                    {"event_pattern": "*", "handler": {"type": "static", "actions": [{"type": "spice_no_change"}]}}
541                ]
542            }),
543        )
544    }
545}
546
547impl Server for SpiceProtocol {
548    fn spawn(
549        &self,
550        ctx: crate::protocol::SpawnContext,
551    ) -> std::pin::Pin<
552        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
553    > {
554        Box::pin(async move {
555            let params = ctx.startup_params.as_ref();
556            let width = params
557                .map(|p| p.get_optional_u32("width"))
558                .transpose()?
559                .flatten()
560                .unwrap_or(DEFAULT_WIDTH);
561            let height = params
562                .map(|p| p.get_optional_u32("height"))
563                .transpose()?
564                .flatten()
565                .unwrap_or(DEFAULT_HEIGHT);
566            let (width, height) = validate_size(width, height)?;
567            let password = params
568                .map(|p| p.get_optional_string("password"))
569                .transpose()?
570                .flatten();
571            if let Some(pw) = &password {
572                if pw.len() > crate::server::spice::wire::MAX_PASSWORD_LENGTH {
573                    bail!(
574                        "password is {} bytes; SPICE tickets carry at most {}",
575                        pw.len(),
576                        crate::server::spice::wire::MAX_PASSWORD_LENGTH
577                    );
578                }
579            }
580            let name = params
581                .map(|p| p.get_optional_string("name"))
582                .transpose()?
583                .flatten()
584                .unwrap_or_else(|| DEFAULT_NAME.to_string());
585            crate::server::spice::SpiceServer::spawn(
586                ctx.legacy_listen_addr(),
587                ctx.llm_client,
588                ctx.state,
589                ctx.status_tx,
590                ctx.server_id,
591                crate::server::spice::Config {
592                    width,
593                    height,
594                    password,
595                    name,
596                },
597            )
598            .await
599        })
600    }
601
602    fn execute_action(&self, action: JsonValue) -> Result<ActionResult> {
603        let kind = action
604            .get("type")
605            .and_then(|v| v.as_str())
606            .ok_or_else(|| anyhow!("Missing 'type' field in action"))?;
607        let custom = |name: &str, data: JsonValue| ActionResult::Custom {
608            name: name.to_string(),
609            data,
610        };
611        match kind {
612            "spice_accept_connection" => Ok(custom(RESULT_ACCEPT, json!({}))),
613            "spice_reject_connection" => Ok(custom(RESULT_REJECT, json!({}))),
614            "spice_render_display" => {
615                let commands = action.get("commands").ok_or_else(|| {
616                    anyhow!(
617                        "spice_render_display requires a 'commands' array describing the screen"
618                    )
619                })?;
620                let parsed = parse_display_commands(commands)
621                    .context("invalid 'commands' in spice_render_display")?;
622                Ok(custom(
623                    RESULT_DISPLAY,
624                    json!({"commands": serde_json::to_value(parsed)?}),
625                ))
626            }
627            "spice_no_change" => Ok(custom(RESULT_NO_CHANGE, json!({}))),
628            "spice_notify" => {
629                let message = action
630                    .get("message")
631                    .and_then(|v| v.as_str())
632                    .ok_or_else(|| anyhow!("spice_notify requires a 'message' string"))?;
633                if message.len() > MAX_NOTIFY_LEN {
634                    bail!(
635                        "spice_notify message is {} bytes, above {MAX_NOTIFY_LEN}",
636                        message.len()
637                    );
638                }
639                let severity = parse_severity(action.get("severity").and_then(|v| v.as_str()))?;
640                Ok(custom(
641                    RESULT_NOTIFY,
642                    json!({"message": message, "severity": severity}),
643                ))
644            }
645            "spice_disconnect_client" | "close_connection" => Ok(ActionResult::CloseConnection),
646            other => Err(anyhow!(
647                "Unknown SPICE action '{other}'. Valid actions: spice_accept_connection, \
648                 spice_reject_connection, spice_render_display, spice_no_change, spice_notify, \
649                 spice_disconnect_client"
650            )),
651        }
652    }
653}