1use std::io::{self, Read, Write};
2
3use serde::{Deserialize, Serialize};
4
5pub const PROTOCOL_VERSION: u32 = 1;
8
9pub const CAP_NOTIFY: &str = "notify";
14pub const CAP_XDG_OPEN: &str = "xdg_open";
15pub const CAP_CLIPBOARD: &str = "clipboard";
16pub const CAP_HOST_EXEC: &str = "host_exec";
17
18pub const ALL_CAPABILITIES: &[&str] = &[CAP_NOTIFY, CAP_XDG_OPEN, CAP_CLIPBOARD, CAP_HOST_EXEC];
20
21#[derive(Debug, Serialize, Deserialize)]
23#[serde(tag = "type", rename_all = "snake_case")]
24pub enum GuestMessage {
25 Hello {
26 protocol_version: u32,
27 guest_version: String,
28 container: String,
29 capabilities: Vec<String>,
30 },
31 Notify {
32 summary: String,
33 body: String,
34 urgency: String,
35 #[serde(default)]
36 actions: Vec<NotifyAction>,
37 #[serde(default)]
38 app_name: String,
39 },
40 XdgOpen {
41 uri: String,
42 },
43 ClipboardSet {
44 text: String,
45 },
46 ClipboardGet,
47 HostExec {
48 cmd: String,
49 args: Vec<String>,
50 },
51 RegisterSession,
54 Busy,
56 IdleTimeout,
58 Info {
60 guest_version: String,
61 protocol_version: u32,
62 },
63}
64
65#[derive(Debug, Serialize, Deserialize, Clone)]
66pub struct NotifyAction {
67 pub key: String,
68 pub label: String,
69}
70
71#[derive(Debug, Serialize, Deserialize)]
73#[serde(tag = "type", rename_all = "snake_case")]
74pub enum HostMessage {
75 HelloAck {
76 accepted: Vec<String>,
77 rejected: Vec<String>,
78 #[serde(default)]
79 idle_timeout_secs: u64,
80 #[serde(default)]
82 host_exec_shims: Vec<String>,
83 },
84 ClipboardData {
85 text: String,
86 },
87 HostExecStdout {
88 data: String,
89 },
90 HostExecStderr {
91 data: String,
92 },
93 HostExecDone {
94 exit_code: i32,
95 },
96 NotifyActionResult {
97 notification_id: u32,
98 action_key: String,
99 },
100 Ping,
101 Shutdown,
102 CheckIdle,
105 Error {
108 reason: String,
109 },
110 GetInfo,
112}
113
114pub fn write_frame<W: Write>(w: &mut W, msg: &impl Serialize) -> io::Result<()> {
116 let json = serde_json::to_vec(msg)?;
117 let len = u32::try_from(json.len())
118 .expect("frame payload exceeds 4 GiB")
119 .to_be_bytes();
120 w.write_all(&len)?;
121 w.write_all(&json)?;
122 w.flush()?;
123 Ok(())
124}
125
126const MAX_FRAME_SIZE: usize = 16 * 1024 * 1024;
128
129pub fn read_frame<R: Read>(r: &mut R) -> io::Result<Option<Vec<u8>>> {
131 let mut len_buf = [0u8; 4];
132 match r.read_exact(&mut len_buf) {
133 Ok(()) => {}
134 Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
135 Err(e) => return Err(e),
136 }
137 let len = u32::from_be_bytes(len_buf) as usize;
138 if len > MAX_FRAME_SIZE {
139 return Err(io::Error::new(
140 io::ErrorKind::InvalidData,
141 format!("frame too large: {len} bytes (max {MAX_FRAME_SIZE})"),
142 ));
143 }
144 let mut buf = vec![0u8; len];
145 r.read_exact(&mut buf)?;
146 Ok(Some(buf))
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
154 fn hello_serializes_with_type_tag() {
155 let msg = GuestMessage::Hello {
156 protocol_version: 1,
157 guest_version: "0.2.0".into(),
158 container: "myenv".into(),
159 capabilities: vec!["notify".into()],
160 };
161 let json = serde_json::to_string(&msg).unwrap();
162 assert!(json.contains("\"type\":\"hello\""));
163 }
164
165 #[test]
166 fn frame_length_prefix_matches_payload() {
167 let msg = GuestMessage::ClipboardGet;
168 let mut buf = Vec::new();
169 write_frame(&mut buf, &msg).unwrap();
170 let len = u32::from_be_bytes(buf[..4].try_into().unwrap()) as usize;
171 assert_eq!(len, buf[4..].len());
172 }
173
174 #[test]
175 fn roundtrip_notify_message() {
176 let msg = GuestMessage::Notify {
177 summary: "hello".into(),
178 body: "world".into(),
179 urgency: "normal".into(),
180 actions: vec![],
181 app_name: String::new(),
182 };
183 let mut buf = Vec::new();
184 write_frame(&mut buf, &msg).unwrap();
185
186 let payload = read_frame(&mut &buf[..]).unwrap().unwrap();
187 let decoded: GuestMessage = serde_json::from_slice(&payload).unwrap();
188 match decoded {
189 GuestMessage::Notify {
190 summary,
191 body,
192 urgency,
193 actions,
194 app_name: _,
195 } => {
196 assert_eq!(summary, "hello");
197 assert_eq!(body, "world");
198 assert_eq!(urgency, "normal");
199 assert!(actions.is_empty());
200 }
201 _ => panic!("wrong message type"),
202 }
203 }
204
205 #[test]
206 fn roundtrip_clipboard_set() {
207 let msg = GuestMessage::ClipboardSet {
208 text: "clipboard content".into(),
209 };
210 let mut buf = Vec::new();
211 write_frame(&mut buf, &msg).unwrap();
212
213 let payload = read_frame(&mut &buf[..]).unwrap().unwrap();
214 let decoded: GuestMessage = serde_json::from_slice(&payload).unwrap();
215 match decoded {
216 GuestMessage::ClipboardSet { text } => {
217 assert_eq!(text, "clipboard content");
218 }
219 _ => panic!("wrong message type"),
220 }
221 }
222
223 #[test]
224 fn hello_ack_with_shims_round_trips() {
225 let msg = HostMessage::HelloAck {
226 accepted: vec!["host_exec".into()],
227 rejected: vec![],
228 idle_timeout_secs: 10,
229 host_exec_shims: vec!["git".into(), "code".into()],
230 };
231 let mut buf = Vec::new();
232 write_frame(&mut buf, &msg).unwrap();
233 let payload = read_frame(&mut &buf[..]).unwrap().unwrap();
234 let decoded: HostMessage = serde_json::from_slice(&payload).unwrap();
235 match decoded {
236 HostMessage::HelloAck {
237 accepted,
238 host_exec_shims,
239 ..
240 } => {
241 assert_eq!(accepted, vec!["host_exec"]);
242 assert_eq!(host_exec_shims, vec!["git", "code"]);
243 }
244 _ => panic!("wrong message type"),
245 }
246 }
247
248 #[test]
249 fn hello_ack_backward_compat_missing_shims() {
250 let json = r#"{"type":"hello_ack","accepted":[],"rejected":[],"idle_timeout_secs":0}"#;
251 let msg: HostMessage = serde_json::from_slice(json.as_bytes()).unwrap();
252 match msg {
253 HostMessage::HelloAck {
254 host_exec_shims, ..
255 } => assert!(host_exec_shims.is_empty()),
256 _ => panic!("wrong message type"),
257 }
258 }
259}