wire request intake path
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
1e77d9b040ed891dbaf7db60d023764accaa9b1b- Parents
-
6166dd7 - Tree
60c2b8a
1e77d9b
1e77d9b040ed891dbaf7db60d023764accaa9b1b6166dd7
60c2b8a| Status | File | + | - |
|---|---|---|---|
| M |
README.md
|
2 | 0 |
| M |
garwarp-ipc/src/lib.rs
|
183 | 13 |
| M |
garwarp/src/daemon.rs
|
181 | 4 |
| M |
garwarp/src/request.rs
|
12 | 0 |
| M |
garwarpctl/src/main.rs
|
257 | 16 |
README.mdmodified@@ -21,3 +21,5 @@ Current scaffold includes: | ||
| 21 | 21 | 2. Check health: `cargo run -p garwarpctl -- status` |
| 22 | 22 | 3. Stop daemon: `cargo run -p garwarpctl -- stop` |
| 23 | 23 | 4. Verify D-Bus activation: `./scripts/test-dbus-activation.sh` |
| 24 | +5. Create mock request: `cargo run -p garwarpctl -- begin req-1 :1.2 - x11:0x2a` | |
| 25 | +6. Transition mock request: `cargo run -p garwarpctl -- transition req-1 :1.2 awaiting_user` | |
garwarp-ipc/src/lib.rsmodified@@ -38,22 +38,133 @@ impl HealthStatus { | ||
| 38 | 38 | pub enum ControlRequest { |
| 39 | 39 | Status, |
| 40 | 40 | Stop, |
| 41 | + BeginRequest { | |
| 42 | + id: String, | |
| 43 | + sender: String, | |
| 44 | + app_id: Option<String>, | |
| 45 | + parent_window: Option<String>, | |
| 46 | + }, | |
| 47 | + TransitionRequest { | |
| 48 | + id: String, | |
| 49 | + sender: String, | |
| 50 | + app_id: Option<String>, | |
| 51 | + target: RequestTransitionTarget, | |
| 52 | + }, | |
| 53 | +} | |
| 54 | + | |
| 55 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 56 | +pub enum RequestTransitionTarget { | |
| 57 | + AwaitingUser, | |
| 58 | + Fulfilled, | |
| 59 | + Cancelled, | |
| 60 | + Failed, | |
| 61 | +} | |
| 62 | + | |
| 63 | +impl RequestTransitionTarget { | |
| 64 | + #[must_use] | |
| 65 | + pub fn as_str(self) -> &'static str { | |
| 66 | + match self { | |
| 67 | + Self::AwaitingUser => "awaiting_user", | |
| 68 | + Self::Fulfilled => "fulfilled", | |
| 69 | + Self::Cancelled => "cancelled", | |
| 70 | + Self::Failed => "failed", | |
| 71 | + } | |
| 72 | + } | |
| 73 | + | |
| 74 | + fn parse(input: &str) -> Option<Self> { | |
| 75 | + match input { | |
| 76 | + "awaiting_user" => Some(Self::AwaitingUser), | |
| 77 | + "fulfilled" => Some(Self::Fulfilled), | |
| 78 | + "cancelled" => Some(Self::Cancelled), | |
| 79 | + "failed" => Some(Self::Failed), | |
| 80 | + _ => None, | |
| 81 | + } | |
| 82 | + } | |
| 41 | 83 | } |
| 42 | 84 | |
| 43 | 85 | impl ControlRequest { |
| 44 | 86 | #[must_use] |
| 45 | - pub fn as_line(&self) -> &'static str { | |
| 87 | + pub fn as_line(&self) -> String { | |
| 46 | 88 | match self { |
| 47 | - Self::Status => "status", | |
| 48 | - Self::Stop => "stop", | |
| 89 | + Self::Status => "status".to_string(), | |
| 90 | + Self::Stop => "stop".to_string(), | |
| 91 | + Self::BeginRequest { | |
| 92 | + id, | |
| 93 | + sender, | |
| 94 | + app_id, | |
| 95 | + parent_window, | |
| 96 | + } => { | |
| 97 | + let mut parts = vec![ | |
| 98 | + "begin".to_string(), | |
| 99 | + format!("id={id}"), | |
| 100 | + format!("sender={sender}"), | |
| 101 | + ]; | |
| 102 | + if let Some(app_id) = app_id { | |
| 103 | + parts.push(format!("app_id={app_id}")); | |
| 104 | + } | |
| 105 | + if let Some(parent_window) = parent_window { | |
| 106 | + parts.push(format!("parent={parent_window}")); | |
| 107 | + } | |
| 108 | + parts.join(" ") | |
| 109 | + } | |
| 110 | + Self::TransitionRequest { | |
| 111 | + id, | |
| 112 | + sender, | |
| 113 | + app_id, | |
| 114 | + target, | |
| 115 | + } => { | |
| 116 | + let mut parts = vec![ | |
| 117 | + "transition".to_string(), | |
| 118 | + format!("id={id}"), | |
| 119 | + format!("sender={sender}"), | |
| 120 | + format!("state={}", target.as_str()), | |
| 121 | + ]; | |
| 122 | + if let Some(app_id) = app_id { | |
| 123 | + parts.push(format!("app_id={app_id}")); | |
| 124 | + } | |
| 125 | + parts.join(" ") | |
| 126 | + } | |
| 49 | 127 | } |
| 50 | 128 | } |
| 51 | 129 | |
| 52 | 130 | #[must_use] |
| 53 | 131 | pub fn parse_line(input: &str) -> Option<Self> { |
| 54 | - match input.trim() { | |
| 55 | - "status" => Some(Self::Status), | |
| 56 | - "stop" => Some(Self::Stop), | |
| 132 | + let trimmed = input.trim(); | |
| 133 | + if trimmed == "status" { | |
| 134 | + return Some(Self::Status); | |
| 135 | + } | |
| 136 | + if trimmed == "stop" { | |
| 137 | + return Some(Self::Stop); | |
| 138 | + } | |
| 139 | + | |
| 140 | + let mut parts = trimmed.split_whitespace(); | |
| 141 | + match parts.next() { | |
| 142 | + Some("begin") => { | |
| 143 | + let fields = parse_fields(parts)?; | |
| 144 | + let id = fields.get("id")?.clone(); | |
| 145 | + let sender = fields.get("sender")?.clone(); | |
| 146 | + let app_id = fields.get("app_id").cloned(); | |
| 147 | + let parent_window = fields.get("parent").cloned(); | |
| 148 | + Some(Self::BeginRequest { | |
| 149 | + id, | |
| 150 | + sender, | |
| 151 | + app_id, | |
| 152 | + parent_window, | |
| 153 | + }) | |
| 154 | + } | |
| 155 | + Some("transition") => { | |
| 156 | + let fields = parse_fields(parts)?; | |
| 157 | + let id = fields.get("id")?.clone(); | |
| 158 | + let sender = fields.get("sender")?.clone(); | |
| 159 | + let app_id = fields.get("app_id").cloned(); | |
| 160 | + let target = RequestTransitionTarget::parse(fields.get("state")?)?; | |
| 161 | + Some(Self::TransitionRequest { | |
| 162 | + id, | |
| 163 | + sender, | |
| 164 | + app_id, | |
| 165 | + target, | |
| 166 | + }) | |
| 167 | + } | |
| 57 | 168 | _ => None, |
| 58 | 169 | } |
| 59 | 170 | } |
@@ -81,6 +192,7 @@ impl StatusResponse { | ||
| 81 | 192 | pub enum ControlResponse { |
| 82 | 193 | Status(StatusResponse), |
| 83 | 194 | AckStopping, |
| 195 | + AckRequest { id: String, state: String }, | |
| 84 | 196 | Error { reason: String }, |
| 85 | 197 | } |
| 86 | 198 | |
@@ -95,6 +207,9 @@ impl ControlResponse { | ||
| 95 | 207 | status.in_flight_requests |
| 96 | 208 | ), |
| 97 | 209 | Self::AckStopping => "ack stopping\n".to_string(), |
| 210 | + Self::AckRequest { id, state } => { | |
| 211 | + format!("ack request id={} state={}\n", id, state) | |
| 212 | + } | |
| 98 | 213 | Self::Error { reason } => format!("error reason={}\n", reason), |
| 99 | 214 | } |
| 100 | 215 | } |
@@ -149,6 +264,24 @@ impl ControlResponse { | ||
| 149 | 264 | } |
| 150 | 265 | Some("ack") => match parts.next() { |
| 151 | 266 | Some("stopping") => Ok(Self::AckStopping), |
| 267 | + Some("request") => { | |
| 268 | + let mut id = None; | |
| 269 | + let mut state = None; | |
| 270 | + for part in parts { | |
| 271 | + let (key, value) = part | |
| 272 | + .split_once('=') | |
| 273 | + .ok_or(ParseError::InvalidField(part.to_string()))?; | |
| 274 | + match key { | |
| 275 | + "id" => id = Some(value.to_string()), | |
| 276 | + "state" => state = Some(value.to_string()), | |
| 277 | + _ => return Err(ParseError::InvalidField(part.to_string())), | |
| 278 | + } | |
| 279 | + } | |
| 280 | + Ok(Self::AckRequest { | |
| 281 | + id: id.ok_or(ParseError::MissingField("id"))?, | |
| 282 | + state: state.ok_or(ParseError::MissingField("state"))?, | |
| 283 | + }) | |
| 284 | + } | |
| 152 | 285 | Some(other) => Err(ParseError::UnknownToken(other.to_string())), |
| 153 | 286 | None => Err(ParseError::MissingField("ack")), |
| 154 | 287 | }, |
@@ -193,15 +326,45 @@ impl fmt::Display for ParseError { | ||
| 193 | 326 | |
| 194 | 327 | impl std::error::Error for ParseError {} |
| 195 | 328 | |
| 329 | +fn parse_fields<'a, I>(parts: I) -> Option<std::collections::HashMap<String, String>> | |
| 330 | +where | |
| 331 | + I: Iterator<Item = &'a str>, | |
| 332 | +{ | |
| 333 | + let mut fields = std::collections::HashMap::new(); | |
| 334 | + for part in parts { | |
| 335 | + let (key, value) = part.split_once('=')?; | |
| 336 | + fields.insert(key.to_string(), value.to_string()); | |
| 337 | + } | |
| 338 | + Some(fields) | |
| 339 | +} | |
| 340 | + | |
| 196 | 341 | #[cfg(test)] |
| 197 | 342 | mod tests { |
| 198 | - use super::{ControlRequest, ControlResponse, HealthStatus, PROTOCOL_VERSION, StatusResponse}; | |
| 343 | + use super::{ | |
| 344 | + ControlRequest, ControlResponse, HealthStatus, PROTOCOL_VERSION, RequestTransitionTarget, | |
| 345 | + StatusResponse, | |
| 346 | + }; | |
| 199 | 347 | |
| 200 | 348 | #[test] |
| 201 | 349 | fn request_parse_roundtrip() { |
| 202 | - for request in [ControlRequest::Status, ControlRequest::Stop] { | |
| 350 | + for request in [ | |
| 351 | + ControlRequest::Status, | |
| 352 | + ControlRequest::Stop, | |
| 353 | + ControlRequest::BeginRequest { | |
| 354 | + id: "req-1".to_string(), | |
| 355 | + sender: ":1.2".to_string(), | |
| 356 | + app_id: Some("org.test.App".to_string()), | |
| 357 | + parent_window: Some("x11:0x2a".to_string()), | |
| 358 | + }, | |
| 359 | + ControlRequest::TransitionRequest { | |
| 360 | + id: "req-1".to_string(), | |
| 361 | + sender: ":1.2".to_string(), | |
| 362 | + app_id: Some("org.test.App".to_string()), | |
| 363 | + target: RequestTransitionTarget::Cancelled, | |
| 364 | + }, | |
| 365 | + ] { | |
| 203 | 366 | let line = request.as_line(); |
| 204 | - let parsed = ControlRequest::parse_line(line); | |
| 367 | + let parsed = ControlRequest::parse_line(&line); | |
| 205 | 368 | assert_eq!(parsed, Some(request)); |
| 206 | 369 | } |
| 207 | 370 | } |
@@ -220,10 +383,17 @@ mod tests { | ||
| 220 | 383 | |
| 221 | 384 | #[test] |
| 222 | 385 | fn response_ack_roundtrip() { |
| 223 | - let response = ControlResponse::AckStopping; | |
| 224 | - let line = response.to_line(); | |
| 225 | - let parsed = ControlResponse::parse_line(&line).expect("response should parse"); | |
| 226 | - assert_eq!(parsed, response); | |
| 386 | + for response in [ | |
| 387 | + ControlResponse::AckStopping, | |
| 388 | + ControlResponse::AckRequest { | |
| 389 | + id: "req-1".to_string(), | |
| 390 | + state: "pending".to_string(), | |
| 391 | + }, | |
| 392 | + ] { | |
| 393 | + let line = response.to_line(); | |
| 394 | + let parsed = ControlResponse::parse_line(&line).expect("response should parse"); | |
| 395 | + assert_eq!(parsed, response); | |
| 396 | + } | |
| 227 | 397 | } |
| 228 | 398 | |
| 229 | 399 | #[test] |
garwarp/src/daemon.rsmodified@@ -2,17 +2,20 @@ use std::fs; | ||
| 2 | 2 | use std::io::{self, BufRead, BufReader, Write}; |
| 3 | 3 | use std::os::unix::net::{UnixListener, UnixStream}; |
| 4 | 4 | use std::thread; |
| 5 | -use std::time::Duration; | |
| 5 | +use std::time::{Duration, Instant}; | |
| 6 | 6 | |
| 7 | -use garwarp_ipc::{ControlRequest, ControlResponse, HealthStatus, StatusResponse}; | |
| 7 | +use garwarp_ipc::{ | |
| 8 | + ControlRequest, ControlResponse, HealthStatus, RequestTransitionTarget, StatusResponse, | |
| 9 | +}; | |
| 8 | 10 | |
| 9 | 11 | use crate::config::Config; |
| 10 | 12 | use crate::dbus::{self, SessionNameGuard}; |
| 11 | -use crate::error::{PortalError, map_portal_error}; | |
| 13 | +use crate::error::{PortalError, map_portal_error, map_request_error}; | |
| 12 | 14 | use crate::lock::SingleInstanceGuard; |
| 13 | 15 | use crate::logging; |
| 14 | -use crate::request::RequestRegistry; | |
| 16 | +use crate::request::{RequestOwner, RequestRegistry, RequestState}; | |
| 15 | 17 | use crate::runtime::RuntimePaths; |
| 18 | +use crate::window::parse_optional_parent_window; | |
| 16 | 19 | |
| 17 | 20 | pub fn run() -> io::Result<()> { |
| 18 | 21 | let config = Config::from_env(); |
@@ -35,6 +38,11 @@ pub fn run() -> io::Result<()> { | ||
| 35 | 38 | }; |
| 36 | 39 | |
| 37 | 40 | while state.running { |
| 41 | + let expired = state.requests.expire_stale(Instant::now()); | |
| 42 | + for id in expired { | |
| 43 | + logging::warn(&format!("request_expired id={id}")); | |
| 44 | + } | |
| 45 | + | |
| 38 | 46 | match listener.accept() { |
| 39 | 47 | Ok((stream, _address)) => { |
| 40 | 48 | if let Err(error) = handle_connection(stream, &mut state) { |
@@ -88,6 +96,64 @@ fn handle_connection(stream: UnixStream, state: &mut DaemonState) -> io::Result< | ||
| 88 | 96 | state.running = false; |
| 89 | 97 | ControlResponse::AckStopping |
| 90 | 98 | } |
| 99 | + Some(ControlRequest::BeginRequest { | |
| 100 | + id, | |
| 101 | + sender, | |
| 102 | + app_id, | |
| 103 | + parent_window, | |
| 104 | + }) => { | |
| 105 | + let owner = RequestOwner::new(sender, app_id); | |
| 106 | + let parsed_parent_window = match parse_optional_parent_window(parent_window.as_deref()) | |
| 107 | + { | |
| 108 | + Ok(parent_window) => parent_window, | |
| 109 | + Err(_) => { | |
| 110 | + let mapping = map_portal_error(&PortalError::InvalidParentWindow); | |
| 111 | + return write_response( | |
| 112 | + reader.into_inner(), | |
| 113 | + ControlResponse::Error { | |
| 114 | + reason: mapping.reason.to_string(), | |
| 115 | + }, | |
| 116 | + ); | |
| 117 | + } | |
| 118 | + }; | |
| 119 | + | |
| 120 | + match state | |
| 121 | + .requests | |
| 122 | + .begin(id.clone(), owner, parsed_parent_window) | |
| 123 | + { | |
| 124 | + Ok(()) => ControlResponse::AckRequest { | |
| 125 | + id, | |
| 126 | + state: "pending".to_string(), | |
| 127 | + }, | |
| 128 | + Err(error) => { | |
| 129 | + let mapping = map_request_error(&error); | |
| 130 | + ControlResponse::Error { | |
| 131 | + reason: mapping.reason.to_string(), | |
| 132 | + } | |
| 133 | + } | |
| 134 | + } | |
| 135 | + } | |
| 136 | + Some(ControlRequest::TransitionRequest { | |
| 137 | + id, | |
| 138 | + sender, | |
| 139 | + app_id, | |
| 140 | + target, | |
| 141 | + }) => { | |
| 142 | + let owner = RequestOwner::new(sender, app_id); | |
| 143 | + let target_state = map_transition_target(target); | |
| 144 | + match state.requests.transition(&id, &owner, target_state) { | |
| 145 | + Ok(()) => ControlResponse::AckRequest { | |
| 146 | + id, | |
| 147 | + state: target_state.as_str().to_string(), | |
| 148 | + }, | |
| 149 | + Err(error) => { | |
| 150 | + let mapping = map_request_error(&error); | |
| 151 | + ControlResponse::Error { | |
| 152 | + reason: mapping.reason.to_string(), | |
| 153 | + } | |
| 154 | + } | |
| 155 | + } | |
| 156 | + } | |
| 91 | 157 | None => { |
| 92 | 158 | let mapping = map_portal_error(&PortalError::InvalidRequestPayload); |
| 93 | 159 | ControlResponse::Error { |
@@ -113,6 +179,15 @@ fn remove_stale_socket(path: &std::path::Path) -> io::Result<()> { | ||
| 113 | 179 | Ok(()) |
| 114 | 180 | } |
| 115 | 181 | |
| 182 | +fn map_transition_target(target: RequestTransitionTarget) -> RequestState { | |
| 183 | + match target { | |
| 184 | + RequestTransitionTarget::AwaitingUser => RequestState::AwaitingUser, | |
| 185 | + RequestTransitionTarget::Fulfilled => RequestState::Fulfilled, | |
| 186 | + RequestTransitionTarget::Cancelled => RequestState::Cancelled, | |
| 187 | + RequestTransitionTarget::Failed => RequestState::Failed, | |
| 188 | + } | |
| 189 | +} | |
| 190 | + | |
| 116 | 191 | #[cfg(test)] |
| 117 | 192 | mod tests { |
| 118 | 193 | use super::{DaemonState, handle_connection}; |
@@ -122,6 +197,7 @@ mod tests { | ||
| 122 | 197 | use std::time::{Duration, Instant}; |
| 123 | 198 | |
| 124 | 199 | use crate::request::{RequestOwner, RequestRegistry, RequestState}; |
| 200 | + use crate::window::ParentWindowContext; | |
| 125 | 201 | |
| 126 | 202 | #[test] |
| 127 | 203 | fn status_request_returns_status_response() { |
@@ -223,4 +299,105 @@ mod tests { | ||
| 223 | 299 | } |
| 224 | 300 | ); |
| 225 | 301 | } |
| 302 | + | |
| 303 | + #[test] | |
| 304 | + fn begin_request_tracks_parent_window_context() { | |
| 305 | + let (mut client, server) = UnixStream::pair().expect("pair should be created"); | |
| 306 | + client | |
| 307 | + .write_all(b"begin id=req-1 sender=:1.2 parent=x11:0x2a\n") | |
| 308 | + .expect("begin request should be written"); | |
| 309 | + | |
| 310 | + let mut state = DaemonState { | |
| 311 | + health: HealthStatus::Healthy, | |
| 312 | + requests: RequestRegistry::new(Duration::from_secs(5)), | |
| 313 | + running: true, | |
| 314 | + }; | |
| 315 | + handle_connection(server, &mut state).expect("begin should be handled"); | |
| 316 | + | |
| 317 | + let mut response_line = String::new(); | |
| 318 | + let mut reader = BufReader::new(client); | |
| 319 | + reader | |
| 320 | + .read_line(&mut response_line) | |
| 321 | + .expect("response should be readable"); | |
| 322 | + | |
| 323 | + let response = ControlResponse::parse_line(&response_line).expect("response should parse"); | |
| 324 | + assert_eq!( | |
| 325 | + response, | |
| 326 | + ControlResponse::AckRequest { | |
| 327 | + id: "req-1".to_string(), | |
| 328 | + state: "pending".to_string(), | |
| 329 | + } | |
| 330 | + ); | |
| 331 | + assert_eq!( | |
| 332 | + state.requests.parent_window("req-1"), | |
| 333 | + Some(Some(ParentWindowContext::X11 { window_id: 42 })) | |
| 334 | + ); | |
| 335 | + assert_eq!(state.requests.in_flight_count(), 1); | |
| 336 | + } | |
| 337 | + | |
| 338 | + #[test] | |
| 339 | + fn invalid_parent_window_maps_to_stable_reason() { | |
| 340 | + let (mut client, server) = UnixStream::pair().expect("pair should be created"); | |
| 341 | + client | |
| 342 | + .write_all(b"begin id=req-1 sender=:1.2 parent=wayland:abc\n") | |
| 343 | + .expect("begin request should be written"); | |
| 344 | + | |
| 345 | + let mut state = DaemonState { | |
| 346 | + health: HealthStatus::Healthy, | |
| 347 | + requests: RequestRegistry::new(Duration::from_secs(5)), | |
| 348 | + running: true, | |
| 349 | + }; | |
| 350 | + handle_connection(server, &mut state).expect("begin should be handled"); | |
| 351 | + | |
| 352 | + let mut response_line = String::new(); | |
| 353 | + let mut reader = BufReader::new(client); | |
| 354 | + reader | |
| 355 | + .read_line(&mut response_line) | |
| 356 | + .expect("response should be readable"); | |
| 357 | + let response = ControlResponse::parse_line(&response_line).expect("response should parse"); | |
| 358 | + assert_eq!( | |
| 359 | + response, | |
| 360 | + ControlResponse::Error { | |
| 361 | + reason: "invalid_parent_window".to_string(), | |
| 362 | + } | |
| 363 | + ); | |
| 364 | + } | |
| 365 | + | |
| 366 | + #[test] | |
| 367 | + fn transition_owner_mismatch_maps_to_stable_reason() { | |
| 368 | + let (mut client, server) = UnixStream::pair().expect("pair should be created"); | |
| 369 | + client | |
| 370 | + .write_all(b"transition id=req-1 sender=:1.7 state=cancelled\n") | |
| 371 | + .expect("transition request should be written"); | |
| 372 | + | |
| 373 | + let mut state = DaemonState { | |
| 374 | + health: HealthStatus::Healthy, | |
| 375 | + requests: RequestRegistry::new(Duration::from_secs(5)), | |
| 376 | + running: true, | |
| 377 | + }; | |
| 378 | + state | |
| 379 | + .requests | |
| 380 | + .begin_at( | |
| 381 | + "req-1", | |
| 382 | + RequestOwner::new(":1.2", None), | |
| 383 | + Some(ParentWindowContext::X11 { window_id: 42 }), | |
| 384 | + Instant::now(), | |
| 385 | + ) | |
| 386 | + .expect("request should be created"); | |
| 387 | + handle_connection(server, &mut state).expect("transition should be handled"); | |
| 388 | + | |
| 389 | + let mut response_line = String::new(); | |
| 390 | + let mut reader = BufReader::new(client); | |
| 391 | + reader | |
| 392 | + .read_line(&mut response_line) | |
| 393 | + .expect("response should be readable"); | |
| 394 | + let response = ControlResponse::parse_line(&response_line).expect("response should parse"); | |
| 395 | + assert_eq!( | |
| 396 | + response, | |
| 397 | + ControlResponse::Error { | |
| 398 | + reason: "ownership_mismatch".to_string(), | |
| 399 | + } | |
| 400 | + ); | |
| 401 | + assert_eq!(state.requests.state("req-1"), Some(RequestState::Pending)); | |
| 402 | + } | |
| 226 | 403 | } |
garwarp/src/request.rsmodified@@ -40,6 +40,18 @@ impl RequestState { | ||
| 40 | 40 | Self::Fulfilled | Self::Cancelled | Self::Failed | Self::Expired |
| 41 | 41 | ) |
| 42 | 42 | } |
| 43 | + | |
| 44 | + #[must_use] | |
| 45 | + pub fn as_str(self) -> &'static str { | |
| 46 | + match self { | |
| 47 | + Self::Pending => "pending", | |
| 48 | + Self::AwaitingUser => "awaiting_user", | |
| 49 | + Self::Fulfilled => "fulfilled", | |
| 50 | + Self::Cancelled => "cancelled", | |
| 51 | + Self::Failed => "failed", | |
| 52 | + Self::Expired => "expired", | |
| 53 | + } | |
| 54 | + } | |
| 43 | 55 | } |
| 44 | 56 | |
| 45 | 57 | #[derive(Debug, Clone)] |
garwarpctl/src/main.rsmodified@@ -5,32 +5,156 @@ use std::path::PathBuf; | ||
| 5 | 5 | |
| 6 | 6 | use garwarp_ipc::{ |
| 7 | 7 | ControlRequest, ControlResponse, DEFAULT_CONTROL_SOCKET, DEFAULT_RUNTIME_SUBDIR, |
| 8 | - PROTOCOL_VERSION, | |
| 8 | + PROTOCOL_VERSION, RequestTransitionTarget, | |
| 9 | 9 | }; |
| 10 | 10 | |
| 11 | 11 | fn main() { |
| 12 | - let command = parse_command(env::args().nth(1).as_deref()); | |
| 12 | + let args: Vec<String> = env::args().collect(); | |
| 13 | + let command = match parse_command(&args[1..]) { | |
| 14 | + Ok(command) => command, | |
| 15 | + Err(error) => { | |
| 16 | + eprintln!("garwarpctl error: {error}"); | |
| 17 | + print_help(); | |
| 18 | + std::process::exit(1); | |
| 19 | + } | |
| 20 | + }; | |
| 21 | + | |
| 13 | 22 | if let Err(error) = run(command) { |
| 14 | 23 | eprintln!("garwarpctl error: {error}"); |
| 15 | 24 | std::process::exit(1); |
| 16 | 25 | } |
| 17 | 26 | } |
| 18 | 27 | |
| 19 | -#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 28 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 20 | 29 | enum Command { |
| 21 | 30 | Status, |
| 22 | 31 | Stop, |
| 23 | 32 | Version, |
| 24 | 33 | Help, |
| 34 | + Begin { | |
| 35 | + id: String, | |
| 36 | + sender: String, | |
| 37 | + app_id: Option<String>, | |
| 38 | + parent_window: Option<String>, | |
| 39 | + }, | |
| 40 | + Transition { | |
| 41 | + id: String, | |
| 42 | + sender: String, | |
| 43 | + app_id: Option<String>, | |
| 44 | + target: RequestTransitionTarget, | |
| 45 | + }, | |
| 46 | +} | |
| 47 | + | |
| 48 | +fn parse_command(args: &[String]) -> Result<Command, String> { | |
| 49 | + match args { | |
| 50 | + [] => Ok(Command::Status), | |
| 51 | + [command] if command == "status" => Ok(Command::Status), | |
| 52 | + [command] if command == "stop" => Ok(Command::Stop), | |
| 53 | + [command] if command == "version" || command == "--version" || command == "-V" => { | |
| 54 | + Ok(Command::Version) | |
| 55 | + } | |
| 56 | + [command] if command == "help" || command == "--help" || command == "-h" => { | |
| 57 | + Ok(Command::Help) | |
| 58 | + } | |
| 59 | + [command, id, sender] if command == "begin" => Ok(Command::Begin { | |
| 60 | + id: id.clone(), | |
| 61 | + sender: sender.clone(), | |
| 62 | + app_id: None, | |
| 63 | + parent_window: None, | |
| 64 | + }), | |
| 65 | + [command, id, sender, app_id] if command == "begin" => Ok(Command::Begin { | |
| 66 | + id: id.clone(), | |
| 67 | + sender: sender.clone(), | |
| 68 | + app_id: optional_value(app_id), | |
| 69 | + parent_window: None, | |
| 70 | + }), | |
| 71 | + [command, id, sender, app_id, parent_window] if command == "begin" => Ok(Command::Begin { | |
| 72 | + id: id.clone(), | |
| 73 | + sender: sender.clone(), | |
| 74 | + app_id: optional_value(app_id), | |
| 75 | + parent_window: optional_value(parent_window), | |
| 76 | + }), | |
| 77 | + [command, id, sender, state] if command == "transition" => Ok(Command::Transition { | |
| 78 | + id: id.clone(), | |
| 79 | + sender: sender.clone(), | |
| 80 | + app_id: None, | |
| 81 | + target: parse_transition_target(state)?, | |
| 82 | + }), | |
| 83 | + [command, id, sender, state, app_id] if command == "transition" => { | |
| 84 | + Ok(Command::Transition { | |
| 85 | + id: id.clone(), | |
| 86 | + sender: sender.clone(), | |
| 87 | + app_id: optional_value(app_id), | |
| 88 | + target: parse_transition_target(state)?, | |
| 89 | + }) | |
| 90 | + } | |
| 91 | + [command, id, sender] if command == "await" => Ok(Command::Transition { | |
| 92 | + id: id.clone(), | |
| 93 | + sender: sender.clone(), | |
| 94 | + app_id: None, | |
| 95 | + target: RequestTransitionTarget::AwaitingUser, | |
| 96 | + }), | |
| 97 | + [command, id, sender, app_id] if command == "await" => Ok(Command::Transition { | |
| 98 | + id: id.clone(), | |
| 99 | + sender: sender.clone(), | |
| 100 | + app_id: optional_value(app_id), | |
| 101 | + target: RequestTransitionTarget::AwaitingUser, | |
| 102 | + }), | |
| 103 | + [command, id, sender] if command == "fulfill" => Ok(Command::Transition { | |
| 104 | + id: id.clone(), | |
| 105 | + sender: sender.clone(), | |
| 106 | + app_id: None, | |
| 107 | + target: RequestTransitionTarget::Fulfilled, | |
| 108 | + }), | |
| 109 | + [command, id, sender, app_id] if command == "fulfill" => Ok(Command::Transition { | |
| 110 | + id: id.clone(), | |
| 111 | + sender: sender.clone(), | |
| 112 | + app_id: optional_value(app_id), | |
| 113 | + target: RequestTransitionTarget::Fulfilled, | |
| 114 | + }), | |
| 115 | + [command, id, sender] if command == "cancel" => Ok(Command::Transition { | |
| 116 | + id: id.clone(), | |
| 117 | + sender: sender.clone(), | |
| 118 | + app_id: None, | |
| 119 | + target: RequestTransitionTarget::Cancelled, | |
| 120 | + }), | |
| 121 | + [command, id, sender, app_id] if command == "cancel" => Ok(Command::Transition { | |
| 122 | + id: id.clone(), | |
| 123 | + sender: sender.clone(), | |
| 124 | + app_id: optional_value(app_id), | |
| 125 | + target: RequestTransitionTarget::Cancelled, | |
| 126 | + }), | |
| 127 | + [command, id, sender] if command == "fail" => Ok(Command::Transition { | |
| 128 | + id: id.clone(), | |
| 129 | + sender: sender.clone(), | |
| 130 | + app_id: None, | |
| 131 | + target: RequestTransitionTarget::Failed, | |
| 132 | + }), | |
| 133 | + [command, id, sender, app_id] if command == "fail" => Ok(Command::Transition { | |
| 134 | + id: id.clone(), | |
| 135 | + sender: sender.clone(), | |
| 136 | + app_id: optional_value(app_id), | |
| 137 | + target: RequestTransitionTarget::Failed, | |
| 138 | + }), | |
| 139 | + _ => Err("unknown command or invalid arguments".to_string()), | |
| 140 | + } | |
| 141 | +} | |
| 142 | + | |
| 143 | +fn parse_transition_target(value: &str) -> Result<RequestTransitionTarget, String> { | |
| 144 | + match value { | |
| 145 | + "awaiting_user" => Ok(RequestTransitionTarget::AwaitingUser), | |
| 146 | + "fulfilled" => Ok(RequestTransitionTarget::Fulfilled), | |
| 147 | + "cancelled" => Ok(RequestTransitionTarget::Cancelled), | |
| 148 | + "failed" => Ok(RequestTransitionTarget::Failed), | |
| 149 | + _ => Err(format!("unsupported transition state: {value}")), | |
| 150 | + } | |
| 25 | 151 | } |
| 26 | 152 | |
| 27 | -fn parse_command(input: Option<&str>) -> Command { | |
| 28 | - match input { | |
| 29 | - Some("status") | None => Command::Status, | |
| 30 | - Some("stop") => Command::Stop, | |
| 31 | - Some("version") | Some("--version") | Some("-V") => Command::Version, | |
| 32 | - Some("help") | Some("--help") | Some("-h") => Command::Help, | |
| 33 | - Some(_) => Command::Help, | |
| 153 | +fn optional_value(value: &str) -> Option<String> { | |
| 154 | + if value == "-" || value.is_empty() { | |
| 155 | + None | |
| 156 | + } else { | |
| 157 | + Some(value.to_string()) | |
| 34 | 158 | } |
| 35 | 159 | } |
| 36 | 160 | |
@@ -70,6 +194,60 @@ fn run(command: Command) -> io::Result<()> { | ||
| 70 | 194 | )), |
| 71 | 195 | } |
| 72 | 196 | } |
| 197 | + Command::Begin { | |
| 198 | + id, | |
| 199 | + sender, | |
| 200 | + app_id, | |
| 201 | + parent_window, | |
| 202 | + } => { | |
| 203 | + let response = send_request(ControlRequest::BeginRequest { | |
| 204 | + id, | |
| 205 | + sender, | |
| 206 | + app_id, | |
| 207 | + parent_window, | |
| 208 | + })?; | |
| 209 | + match response { | |
| 210 | + ControlResponse::AckRequest { id, state } => { | |
| 211 | + println!("id={id}"); | |
| 212 | + println!("state={state}"); | |
| 213 | + Ok(()) | |
| 214 | + } | |
| 215 | + ControlResponse::Error { reason } => { | |
| 216 | + Err(io::Error::other(format!("daemon error: {reason}"))) | |
| 217 | + } | |
| 218 | + other => Err(io::Error::new( | |
| 219 | + io::ErrorKind::InvalidData, | |
| 220 | + format!("unexpected response: {other:?}"), | |
| 221 | + )), | |
| 222 | + } | |
| 223 | + } | |
| 224 | + Command::Transition { | |
| 225 | + id, | |
| 226 | + sender, | |
| 227 | + app_id, | |
| 228 | + target, | |
| 229 | + } => { | |
| 230 | + let response = send_request(ControlRequest::TransitionRequest { | |
| 231 | + id, | |
| 232 | + sender, | |
| 233 | + app_id, | |
| 234 | + target, | |
| 235 | + })?; | |
| 236 | + match response { | |
| 237 | + ControlResponse::AckRequest { id, state } => { | |
| 238 | + println!("id={id}"); | |
| 239 | + println!("state={state}"); | |
| 240 | + Ok(()) | |
| 241 | + } | |
| 242 | + ControlResponse::Error { reason } => { | |
| 243 | + Err(io::Error::other(format!("daemon error: {reason}"))) | |
| 244 | + } | |
| 245 | + other => Err(io::Error::new( | |
| 246 | + io::ErrorKind::InvalidData, | |
| 247 | + format!("unexpected response: {other:?}"), | |
| 248 | + )), | |
| 249 | + } | |
| 250 | + } | |
| 73 | 251 | Command::Version => { |
| 74 | 252 | println!("garwarpctl protocol v{PROTOCOL_VERSION}"); |
| 75 | 253 | Ok(()) |
@@ -84,7 +262,8 @@ fn run(command: Command) -> io::Result<()> { | ||
| 84 | 262 | fn send_request(request: ControlRequest) -> io::Result<ControlResponse> { |
| 85 | 263 | let socket_path = control_socket_path(); |
| 86 | 264 | let mut stream = UnixStream::connect(&socket_path)?; |
| 87 | - stream.write_all(request.as_line().as_bytes())?; | |
| 265 | + let line = request.as_line(); | |
| 266 | + stream.write_all(line.as_bytes())?; | |
| 88 | 267 | stream.write_all(b"\n")?; |
| 89 | 268 | stream.flush()?; |
| 90 | 269 | |
@@ -108,20 +287,82 @@ fn runtime_dir() -> PathBuf { | ||
| 108 | 287 | |
| 109 | 288 | fn print_help() { |
| 110 | 289 | println!("garwarpctl <command>"); |
| 111 | - println!("commands: status (default), stop, version, help"); | |
| 290 | + println!("commands:"); | |
| 291 | + println!(" status (default)"); | |
| 292 | + println!(" stop"); | |
| 293 | + println!(" begin <id> <sender> [app_id|-] [parent_window|-]"); | |
| 294 | + println!(" transition <id> <sender> <awaiting_user|fulfilled|cancelled|failed> [app_id|-]"); | |
| 295 | + println!(" await|fulfill|cancel|fail <id> <sender> [app_id|-]"); | |
| 296 | + println!(" version"); | |
| 297 | + println!(" help"); | |
| 112 | 298 | } |
| 113 | 299 | |
| 114 | 300 | #[cfg(test)] |
| 115 | 301 | mod tests { |
| 116 | - use super::{Command, parse_command}; | |
| 302 | + use super::{Command, optional_value, parse_command, parse_transition_target}; | |
| 303 | + use garwarp_ipc::RequestTransitionTarget; | |
| 117 | 304 | |
| 118 | 305 | #[test] |
| 119 | 306 | fn status_is_default_command() { |
| 120 | - assert_eq!(parse_command(None), Command::Status); | |
| 307 | + assert_eq!( | |
| 308 | + parse_command(&[]).expect("status should be default"), | |
| 309 | + Command::Status | |
| 310 | + ); | |
| 311 | + } | |
| 312 | + | |
| 313 | + #[test] | |
| 314 | + fn parse_begin_command_with_parent_window() { | |
| 315 | + let args = vec![ | |
| 316 | + "begin".to_string(), | |
| 317 | + "req-1".to_string(), | |
| 318 | + ":1.2".to_string(), | |
| 319 | + "org.test.App".to_string(), | |
| 320 | + "x11:0x2a".to_string(), | |
| 321 | + ]; | |
| 322 | + let command = parse_command(&args).expect("begin command should parse"); | |
| 323 | + assert_eq!( | |
| 324 | + command, | |
| 325 | + Command::Begin { | |
| 326 | + id: "req-1".to_string(), | |
| 327 | + sender: ":1.2".to_string(), | |
| 328 | + app_id: Some("org.test.App".to_string()), | |
| 329 | + parent_window: Some("x11:0x2a".to_string()), | |
| 330 | + } | |
| 331 | + ); | |
| 332 | + } | |
| 333 | + | |
| 334 | + #[test] | |
| 335 | + fn parse_transition_command() { | |
| 336 | + let args = vec![ | |
| 337 | + "transition".to_string(), | |
| 338 | + "req-1".to_string(), | |
| 339 | + ":1.2".to_string(), | |
| 340 | + "cancelled".to_string(), | |
| 341 | + ]; | |
| 342 | + let command = parse_command(&args).expect("transition command should parse"); | |
| 343 | + assert_eq!( | |
| 344 | + command, | |
| 345 | + Command::Transition { | |
| 346 | + id: "req-1".to_string(), | |
| 347 | + sender: ":1.2".to_string(), | |
| 348 | + app_id: None, | |
| 349 | + target: RequestTransitionTarget::Cancelled, | |
| 350 | + } | |
| 351 | + ); | |
| 352 | + } | |
| 353 | + | |
| 354 | + #[test] | |
| 355 | + fn parse_transition_target_rejects_unknown_state() { | |
| 356 | + let parsed = parse_transition_target("bogus"); | |
| 357 | + assert!(parsed.is_err()); | |
| 121 | 358 | } |
| 122 | 359 | |
| 123 | 360 | #[test] |
| 124 | - fn help_for_unknown_command() { | |
| 125 | - assert_eq!(parse_command(Some("bogus")), Command::Help); | |
| 361 | + fn optional_value_uses_dash_as_none() { | |
| 362 | + assert_eq!(optional_value("-"), None); | |
| 363 | + assert_eq!( | |
| 364 | + optional_value("org.test.App"), | |
| 365 | + Some("org.test.App".to_string()) | |
| 366 | + ); | |
| 126 | 367 | } |
| 127 | 368 | } |