use std::ffi::OsString;
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>,
}
impl Request {
pub fn to_cli_args(&self) -> Vec<OsString> {
let mut a: Vec<OsString> = vec![
"--kernel".into(),
self.kernel.clone().into_os_string(),
"--initramfs".into(),
self.initramfs.clone().into_os_string(),
"--rootfs".into(),
self.rootfs.clone().into(),
];
if self.rootfs_ro {
a.push("--rootfs-ro".into());
}
if self.offline {
a.push("--offline".into());
}
for s in &self.shares {
a.push("--share".into());
a.push(format!("{}={}", s.tag, s.path.display()).into());
}
for d in &self.disks {
a.push("--disk".into());
a.push(d.clone().into_os_string());
}
a.push("--exec".into());
a.push(self.exec.clone().into());
if self.net {
a.push("--net".into());
}
if self.switch_root {
a.push("--switch-root".into());
}
if let Some(p) = self.vsock_port {
a.push("--vsock-port".into());
a.push(p.to_string().into());
}
if let Some(p) = self.guest_vsock_port {
a.push("--guest-vsock-port".into());
a.push(p.to_string().into());
}
if let Some(t) = self.timeout_seconds {
a.push("--timeout".into());
a.push(t.to_string().into());
}
if let Some(v) = self.vcpus {
a.push("--vcpus".into());
a.push(v.to_string().into());
}
if let Some(m) = self.mem_mib {
a.push("--mem-mib".into());
a.push(m.to_string().into());
}
a
}
}
#[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),
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 = "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 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),
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>,
}
#[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 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 to_cli_args_omits_unset_optionals() {
let req: Request =
serde_json::from_str(r#"{"kernel":"/k","initramfs":"/i","rootfs":"/r","exec":"echo"}"#)
.unwrap();
let args: Vec<String> = req
.to_cli_args()
.into_iter()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(
args,
vec![
"--kernel",
"/k",
"--initramfs",
"/i",
"--rootfs",
"/r",
"--exec",
"echo"
]
);
assert!(!args
.iter()
.any(|a| a == "--vcpus" || a == "--mem-mib" || a == "--vsock-port"));
}
#[test]
fn to_cli_args_renders_set_fields() {
let req = Request {
kernel: "/k".into(),
initramfs: "/i".into(),
rootfs: "/r".into(),
rootfs_ro: true,
offline: true,
shares: vec![ShareMount {
tag: "work".into(),
path: "/tmp/x".into(),
}],
disks: vec!["/d.img".into()],
exec: "echo hi".into(),
net: true,
switch_root: true,
vsock_port: Some(0),
guest_vsock_port: Some(1025),
timeout_seconds: Some(30),
vcpus: Some(2),
mem_mib: Some(1024),
};
let args: Vec<String> = req
.to_cli_args()
.into_iter()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(args
.windows(2)
.any(|w| w[0] == "--share" && w[1] == "work=/tmp/x"));
assert!(args
.windows(2)
.any(|w| w[0] == "--disk" && w[1] == "/d.img"));
assert!(args.contains(&"--rootfs-ro".to_string()));
assert!(args.contains(&"--net".to_string()));
assert!(args.contains(&"--switch-root".to_string()));
assert!(args.windows(2).any(|w| w[0] == "--vcpus" && w[1] == "2"));
assert!(args
.windows(2)
.any(|w| w[0] == "--mem-mib" && w[1] == "1024"));
}
#[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,
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,
}))
.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 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:?}"),
}
}
}