smcp_computer/desktop/
window_uri.rs1use std::collections::HashMap;
12use url::Url;
13
14#[derive(Debug, Clone)]
17pub struct WindowURI {
18 url: Url,
20 windows: Vec<String>,
22 params: HashMap<String, String>,
24}
25
26impl WindowURI {
27 pub fn new(uri: &str) -> Result<Self, WindowURIError> {
29 let url = Url::parse(uri)
30 .map_err(|e| WindowURIError::InvalidURI(format!("Failed to parse URI: {}", e)))?;
31
32 if url.scheme() != "window" {
33 return Err(WindowURIError::InvalidScheme(url.scheme().to_string()));
34 }
35
36 if url.host().is_none() || url.host_str().unwrap().is_empty() {
37 return Err(WindowURIError::MissingHost);
38 }
39
40 let windows = url
42 .path()
43 .trim_start_matches('/')
44 .split('/')
45 .filter(|s| !s.is_empty())
46 .map(|s| {
47 percent_encoding::percent_decode_str(s)
48 .decode_utf8()
49 .map(|s| s.to_string())
50 .map_err(|e| {
51 WindowURIError::InvalidPath(format!("Failed to decode path segment: {}", e))
52 })
53 })
54 .collect::<Result<Vec<_>, _>>()?;
55
56 let params: HashMap<String, String> = url.query_pairs().into_owned().collect();
58
59 let uri = Self {
61 url,
62 windows,
63 params,
64 };
65
66 if let Some(priority) = uri.priority() {
68 if !(0..=100).contains(&priority) {
69 return Err(WindowURIError::InvalidPriority(priority));
70 }
71 }
72
73 let _ = uri.fullscreen(); Ok(uri)
77 }
78
79 pub fn mcp_id(&self) -> &str {
81 self.url.host_str().unwrap()
82 }
83
84 pub fn windows(&self) -> &[String] {
86 &self.windows
87 }
88
89 pub fn priority(&self) -> Option<i32> {
91 self.params.get("priority").and_then(|s| s.parse().ok())
92 }
93
94 pub fn fullscreen(&self) -> Option<bool> {
96 self.params
97 .get("fullscreen")
98 .and_then(|s| match s.to_lowercase().as_str() {
99 "1" | "true" | "yes" | "on" => Some(true),
100 "0" | "false" | "no" | "off" => Some(false),
101 _ => None,
102 })
103 }
104
105 pub fn build(
107 host: &str,
108 windows: &[String],
109 priority: Option<i32>,
110 fullscreen: Option<bool>,
111 ) -> Result<String, WindowURIError> {
112 if host.is_empty() {
113 return Err(WindowURIError::MissingHost);
114 }
115
116 let mut url = Url::parse(&format!("window://{}", host))
117 .map_err(|e| WindowURIError::InvalidURI(format!("Failed to build URI: {}", e)))?;
118
119 if !windows.is_empty() {
121 let encoded_path: Vec<String> = windows
122 .iter()
123 .map(|w| {
124 percent_encoding::utf8_percent_encode(w, percent_encoding::NON_ALPHANUMERIC)
125 .to_string()
126 })
127 .collect();
128 url.set_path(&encoded_path.join("/"));
129 }
130
131 let mut query_pairs = Vec::new();
133
134 if let Some(p) = priority {
135 if !(0..=100).contains(&p) {
136 return Err(WindowURIError::InvalidPriority(p));
137 }
138 query_pairs.push(("priority", p.to_string()));
139 }
140
141 if let Some(f) = fullscreen {
142 query_pairs.push(("fullscreen", if f { "true" } else { "false" }.to_string()));
143 }
144
145 if !query_pairs.is_empty() {
146 url.set_query(Some(
147 &query_pairs
148 .iter()
149 .map(|(k, v)| format!("{}={}", k, v))
150 .collect::<Vec<_>>()
151 .join("&"),
152 ));
153 }
154
155 Ok(url.to_string())
156 }
157}
158
159impl std::fmt::Display for WindowURI {
160 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 write!(f, "{}", self.url)
162 }
163}
164
165#[derive(Debug, thiserror::Error)]
167pub enum WindowURIError {
168 #[error("Invalid URI: {0}")]
169 InvalidURI(String),
170
171 #[error("Invalid scheme: {0}, expected 'window'")]
172 InvalidScheme(String),
173
174 #[error("Missing host (MCP ID)")]
175 MissingHost,
176
177 #[error("Invalid path: {0}")]
178 InvalidPath(String),
179
180 #[error("Invalid priority: {0}, must be between 0 and 100")]
181 InvalidPriority(i32),
182
183 #[error("Invalid fullscreen value")]
184 InvalidFullscreen,
185}
186
187pub fn is_window_uri(uri: &str) -> bool {
189 WindowURI::new(uri).is_ok()
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn test_parse_minimal() {
198 let uri = WindowURI::new("window://com.example.mcp").unwrap();
199 assert_eq!(uri.mcp_id(), "com.example.mcp");
200 assert!(uri.windows().is_empty());
201 assert_eq!(uri.priority(), None);
202 assert_eq!(uri.fullscreen(), None);
203 }
204
205 #[test]
206 fn test_parse_with_paths() {
207 let uri = WindowURI::new("window://com.example.mcp/dashboard/main").unwrap();
208 assert_eq!(uri.mcp_id(), "com.example.mcp");
209 assert_eq!(uri.windows(), &["dashboard", "main"]);
210 }
211
212 #[test]
213 fn test_parse_with_query_params() {
214 let uri =
215 WindowURI::new("window://com.example.mcp/page?priority=90&fullscreen=true").unwrap();
216 assert_eq!(uri.windows(), &["page"]);
217 assert_eq!(uri.priority(), Some(90));
218 assert_eq!(uri.fullscreen(), Some(true));
219 }
220
221 #[test]
222 fn test_priority_bounds() {
223 assert!(WindowURI::new("window://x?priority=0").is_ok());
224 assert!(WindowURI::new("window://x?priority=100").is_ok());
225 assert!(WindowURI::new("window://x?priority=-1").is_err());
226 assert!(WindowURI::new("window://x?priority=101").is_err());
227 }
228
229 #[test]
230 fn test_fullscreen_variants() {
231 let test_cases = vec![
232 ("true", true),
233 ("1", true),
234 ("yes", true),
235 ("on", true),
236 ("false", false),
237 ("0", false),
238 ("no", false),
239 ("off", false),
240 ];
241
242 for (val, expected) in test_cases {
243 let uri = WindowURI::new(&format!("window://x?fullscreen={}", val)).unwrap();
244 assert_eq!(uri.fullscreen(), Some(expected));
245 }
246 }
247
248 #[test]
249 fn test_build_uri() {
250 let uri = WindowURI::build(
251 "com.example.mcp",
252 &["dashboard".to_string(), "main".to_string()],
253 Some(80),
254 Some(false),
255 )
256 .unwrap();
257
258 assert!(uri.starts_with("window://com.example.mcp/dashboard/main"));
259 assert!(uri.contains("priority=80"));
260 assert!(uri.contains("fullscreen=false"));
261 }
262
263 #[test]
264 fn test_is_window_uri() {
265 assert!(is_window_uri("window://com.example.mcp"));
266 assert!(is_window_uri("window://host/path?priority=50"));
267 assert!(!is_window_uri("http://example.com"));
268 assert!(!is_window_uri("window://"));
269 }
270}