use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::agent::Action;
use crate::geom::Rect;
use crate::mount::ShareMount;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Request {
pub kernel: PathBuf,
pub initramfs: PathBuf,
pub rootfs: String,
#[serde(default)]
pub rootfs_ro: bool,
#[serde(default)]
pub offline: bool,
#[serde(default)]
pub shares: Vec<ShareMount>,
#[serde(default)]
pub disks: Vec<PathBuf>,
pub exec: String,
#[serde(default)]
pub net: bool,
#[serde(default)]
pub switch_root: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vsock_port: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub guest_vsock_port: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_seconds: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vcpus: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mem_mib: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scratch_mib: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum Frame {
Stdout { data: String },
Stderr { data: String },
Exit { code: i32 },
Error { message: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum DesktopRequest {
DesktopStart(DesktopStart),
DesktopAction(DesktopAction),
DesktopScreenshotSettled(DesktopScreenshotSettled),
DesktopWhatChanged(DesktopWhatChanged),
DesktopView(DesktopView),
DesktopStop(DesktopStop),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DesktopStart {
pub kernel: PathBuf,
pub initramfs: PathBuf,
pub image: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<String>,
#[serde(default)]
pub net: bool,
#[serde(default)]
pub offline: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub shares: Vec<ShareMount>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vcpus: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mem_mib: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DesktopAction {
pub session_id: String,
pub action: Action,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DesktopScreenshotSettled {
pub session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stable_hold_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DesktopWhatChanged {
pub session_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DesktopView {
pub session_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DesktopStop {
pub session_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum DesktopReply {
Session(SessionReply),
ActionResult(ActionReply),
Settled(SettleReply),
Changed(ChangedReply),
View(ViewReply),
Stopped,
Error(ErrorReply),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionReply {
pub session_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionReply {
pub ok: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub x: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub y: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub png_base64: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SettleReply {
pub settled: bool,
pub moving: Vec<Rect>,
pub png_base64: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangedReply {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub changed: Option<Rect>,
pub png_base64: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ViewReply {
pub addr: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorReply {
pub message: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_request_leaves_unset_optionals_none() {
let req: Request =
serde_json::from_str(r#"{"kernel":"/k","initramfs":"/i","rootfs":"/r","exec":"echo"}"#)
.unwrap();
assert_eq!(req.vsock_port, None);
assert_eq!(req.guest_vsock_port, None);
assert_eq!(req.vcpus, None);
assert_eq!(req.mem_mib, None);
assert!(!req.net);
}
#[test]
fn frame_tags_are_lowercase() {
let j = serde_json::to_string(&Frame::Exit { code: 0 }).unwrap();
assert_eq!(j, r#"{"kind":"exit","code":0}"#);
}
#[test]
fn desktop_request_deserializes_by_kind() {
let r: DesktopRequest =
serde_json::from_str(r#"{"kind":"desktop_stop","session_id":"abc"}"#).unwrap();
match r {
DesktopRequest::DesktopStop(s) => assert_eq!(s.session_id, "abc"),
other => panic!("wrong variant: {other:?}"),
}
}
#[test]
fn desktop_action_carries_typed_action() {
let r: DesktopRequest = serde_json::from_str(
r#"{"kind":"desktop_action","session_id":"s","action":{"action":"left_click"}}"#,
)
.unwrap();
match r {
DesktopRequest::DesktopAction(a) => {
assert_eq!(a.session_id, "s");
assert_eq!(a.action, Action::LeftClick);
}
other => panic!("wrong variant: {other:?}"),
}
}
#[test]
fn desktop_start_omits_unset_optionals() {
let j = serde_json::to_string(&DesktopRequest::DesktopStart(DesktopStart {
kernel: "/k".into(),
initramfs: "/i".into(),
image: "alpine:3.20".into(),
size: None,
net: true,
offline: false,
shares: Vec::new(),
vcpus: None,
mem_mib: None,
}))
.unwrap();
assert_eq!(
j,
r#"{"kind":"desktop_start","kernel":"/k","initramfs":"/i","image":"alpine:3.20","net":true,"offline":false}"#
);
}
#[test]
fn reply_session_flattens_under_kind() {
let j = serde_json::to_string(&DesktopReply::Session(SessionReply {
session_id: "deadbeef".into(),
}))
.unwrap();
assert_eq!(j, r#"{"kind":"session","session_id":"deadbeef"}"#);
}
#[test]
fn reply_action_omits_none_fields() {
let j = serde_json::to_string(&DesktopReply::ActionResult(ActionReply {
ok: true,
error: None,
x: None,
y: None,
png_base64: None,
text: None,
exit_code: None,
}))
.unwrap();
assert_eq!(j, r#"{"kind":"action_result","ok":true}"#);
}
#[test]
fn reply_settled_carries_moving_rects() {
let j = serde_json::to_string(&DesktopReply::Settled(SettleReply {
settled: true,
moving: vec![Rect {
x: 1,
y: 2,
w: 3,
h: 4,
}],
png_base64: "AA".into(),
}))
.unwrap();
assert_eq!(
j,
r#"{"kind":"settled","settled":true,"moving":[{"x":1,"y":2,"w":3,"h":4}],"png_base64":"AA"}"#
);
}
#[test]
fn reply_changed_omits_absent_damage() {
let j = serde_json::to_string(&DesktopReply::Changed(ChangedReply {
changed: None,
png_base64: "AA".into(),
}))
.unwrap();
assert_eq!(j, r#"{"kind":"changed","png_base64":"AA"}"#);
}
#[test]
fn desktop_view_request_round_trips() {
let r: DesktopRequest =
serde_json::from_str(r#"{"kind":"desktop_view","session_id":"s"}"#).unwrap();
match r {
DesktopRequest::DesktopView(v) => assert_eq!(v.session_id, "s"),
other => panic!("wrong variant: {other:?}"),
}
}
#[test]
fn reply_view_flattens_under_kind() {
let j = serde_json::to_string(&DesktopReply::View(ViewReply {
addr: "127.0.0.1:5901".into(),
}))
.unwrap();
assert_eq!(j, r#"{"kind":"view","addr":"127.0.0.1:5901"}"#);
let back: DesktopReply = serde_json::from_str(&j).unwrap();
match back {
DesktopReply::View(v) => assert_eq!(v.addr, "127.0.0.1:5901"),
other => panic!("wrong variant: {other:?}"),
}
}
#[test]
fn reply_error_round_trips() {
let j = serde_json::to_string(&DesktopReply::Error(ErrorReply {
message: "boom".into(),
}))
.unwrap();
assert_eq!(j, r#"{"kind":"error","message":"boom"}"#);
let back: DesktopReply = serde_json::from_str(&j).unwrap();
match back {
DesktopReply::Error(e) => assert_eq!(e.message, "boom"),
other => panic!("wrong variant: {other:?}"),
}
}
}