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}
59
60#[derive(Debug, Serialize, Deserialize, Clone)]
61pub struct NotifyAction {
62 pub key: String,
63 pub label: String,
64}
65
66#[derive(Debug, Serialize, Deserialize)]
68#[serde(tag = "type", rename_all = "snake_case")]
69pub enum HostMessage {
70 HelloAck {
71 accepted: Vec<String>,
72 rejected: Vec<String>,
73 #[serde(default)]
74 idle_timeout_secs: u64,
75 #[serde(default)]
77 host_exec_shims: Vec<String>,
78 },
79 ClipboardData {
80 text: String,
81 },
82 HostExecStdout {
83 data: String,
84 },
85 HostExecStderr {
86 data: String,
87 },
88 HostExecDone {
89 exit_code: i32,
90 },
91 NotifyActionResult {
92 notification_id: u32,
93 action_key: String,
94 },
95 Ping,
96 Shutdown,
97 CheckIdle,
100 Error {
103 reason: String,
104 },
105}
106
107pub fn write_frame<W: Write>(w: &mut W, msg: &impl Serialize) -> io::Result<()> {
109 let json = serde_json::to_vec(msg)?;
110 let len = u32::try_from(json.len())
111 .expect("frame payload exceeds 4 GiB")
112 .to_be_bytes();
113 w.write_all(&len)?;
114 w.write_all(&json)?;
115 w.flush()?;
116 Ok(())
117}
118
119const MAX_FRAME_SIZE: usize = 16 * 1024 * 1024;
121
122pub fn read_frame<R: Read>(r: &mut R) -> io::Result<Option<Vec<u8>>> {
124 let mut len_buf = [0u8; 4];
125 match r.read_exact(&mut len_buf) {
126 Ok(()) => {}
127 Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
128 Err(e) => return Err(e),
129 }
130 let len = u32::from_be_bytes(len_buf) as usize;
131 if len > MAX_FRAME_SIZE {
132 return Err(io::Error::new(
133 io::ErrorKind::InvalidData,
134 format!("frame too large: {len} bytes (max {MAX_FRAME_SIZE})"),
135 ));
136 }
137 let mut buf = vec![0u8; len];
138 r.read_exact(&mut buf)?;
139 Ok(Some(buf))
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn hello_serializes_with_type_tag() {
148 let msg = GuestMessage::Hello {
149 protocol_version: 1,
150 guest_version: "0.2.0".into(),
151 container: "myenv".into(),
152 capabilities: vec!["notify".into()],
153 };
154 let json = serde_json::to_string(&msg).unwrap();
155 assert!(json.contains("\"type\":\"hello\""));
156 }
157
158 #[test]
159 fn frame_length_prefix_matches_payload() {
160 let msg = GuestMessage::ClipboardGet;
161 let mut buf = Vec::new();
162 write_frame(&mut buf, &msg).unwrap();
163 let len = u32::from_be_bytes(buf[..4].try_into().unwrap()) as usize;
164 assert_eq!(len, buf[4..].len());
165 }
166
167 #[test]
168 fn roundtrip_notify_message() {
169 let msg = GuestMessage::Notify {
170 summary: "hello".into(),
171 body: "world".into(),
172 urgency: "normal".into(),
173 actions: vec![],
174 app_name: String::new(),
175 };
176 let mut buf = Vec::new();
177 write_frame(&mut buf, &msg).unwrap();
178
179 let payload = read_frame(&mut &buf[..]).unwrap().unwrap();
180 let decoded: GuestMessage = serde_json::from_slice(&payload).unwrap();
181 match decoded {
182 GuestMessage::Notify {
183 summary,
184 body,
185 urgency,
186 actions,
187 app_name: _,
188 } => {
189 assert_eq!(summary, "hello");
190 assert_eq!(body, "world");
191 assert_eq!(urgency, "normal");
192 assert!(actions.is_empty());
193 }
194 _ => panic!("wrong message type"),
195 }
196 }
197
198 #[test]
199 fn roundtrip_clipboard_set() {
200 let msg = GuestMessage::ClipboardSet {
201 text: "clipboard content".into(),
202 };
203 let mut buf = Vec::new();
204 write_frame(&mut buf, &msg).unwrap();
205
206 let payload = read_frame(&mut &buf[..]).unwrap().unwrap();
207 let decoded: GuestMessage = serde_json::from_slice(&payload).unwrap();
208 match decoded {
209 GuestMessage::ClipboardSet { text } => {
210 assert_eq!(text, "clipboard content");
211 }
212 _ => panic!("wrong message type"),
213 }
214 }
215
216 #[test]
217 fn hello_ack_with_shims_round_trips() {
218 let msg = HostMessage::HelloAck {
219 accepted: vec!["host_exec".into()],
220 rejected: vec![],
221 idle_timeout_secs: 10,
222 host_exec_shims: vec!["git".into(), "code".into()],
223 };
224 let mut buf = Vec::new();
225 write_frame(&mut buf, &msg).unwrap();
226 let payload = read_frame(&mut &buf[..]).unwrap().unwrap();
227 let decoded: HostMessage = serde_json::from_slice(&payload).unwrap();
228 match decoded {
229 HostMessage::HelloAck {
230 accepted,
231 host_exec_shims,
232 ..
233 } => {
234 assert_eq!(accepted, vec!["host_exec"]);
235 assert_eq!(host_exec_shims, vec!["git", "code"]);
236 }
237 _ => panic!("wrong message type"),
238 }
239 }
240
241 #[test]
242 fn hello_ack_backward_compat_missing_shims() {
243 let json = r#"{"type":"hello_ack","accepted":[],"rejected":[],"idle_timeout_secs":0}"#;
244 let msg: HostMessage = serde_json::from_slice(json.as_bytes()).unwrap();
245 match msg {
246 HostMessage::HelloAck {
247 host_exec_shims, ..
248 } => assert!(host_exec_shims.is_empty()),
249 _ => panic!("wrong message type"),
250 }
251 }
252}