gardesk/garwarp / 1e77d9b

Browse files

wire request intake path

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
1e77d9b040ed891dbaf7db60d023764accaa9b1b
Parents
6166dd7
Tree
60c2b8a

5 changed files

StatusFile+-
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:
2121
 2. Check health: `cargo run -p garwarpctl -- status`
2222
 3. Stop daemon: `cargo run -p garwarpctl -- stop`
2323
 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 {
3838
 pub enum ControlRequest {
3939
     Status,
4040
     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
+    }
4183
 }
4284
 
4385
 impl ControlRequest {
4486
     #[must_use]
45
-    pub fn as_line(&self) -> &'static str {
87
+    pub fn as_line(&self) -> String {
4688
         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
+            }
49127
         }
50128
     }
51129
 
52130
     #[must_use]
53131
     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
+            }
57168
             _ => None,
58169
         }
59170
     }
@@ -81,6 +192,7 @@ impl StatusResponse {
81192
 pub enum ControlResponse {
82193
     Status(StatusResponse),
83194
     AckStopping,
195
+    AckRequest { id: String, state: String },
84196
     Error { reason: String },
85197
 }
86198
 
@@ -95,6 +207,9 @@ impl ControlResponse {
95207
                 status.in_flight_requests
96208
             ),
97209
             Self::AckStopping => "ack stopping\n".to_string(),
210
+            Self::AckRequest { id, state } => {
211
+                format!("ack request id={} state={}\n", id, state)
212
+            }
98213
             Self::Error { reason } => format!("error reason={}\n", reason),
99214
         }
100215
     }
@@ -149,6 +264,24 @@ impl ControlResponse {
149264
             }
150265
             Some("ack") => match parts.next() {
151266
                 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
+                }
152285
                 Some(other) => Err(ParseError::UnknownToken(other.to_string())),
153286
                 None => Err(ParseError::MissingField("ack")),
154287
             },
@@ -193,15 +326,45 @@ impl fmt::Display for ParseError {
193326
 
194327
 impl std::error::Error for ParseError {}
195328
 
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
+
196341
 #[cfg(test)]
197342
 mod tests {
198
-    use super::{ControlRequest, ControlResponse, HealthStatus, PROTOCOL_VERSION, StatusResponse};
343
+    use super::{
344
+        ControlRequest, ControlResponse, HealthStatus, PROTOCOL_VERSION, RequestTransitionTarget,
345
+        StatusResponse,
346
+    };
199347
 
200348
     #[test]
201349
     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
+        ] {
203366
             let line = request.as_line();
204
-            let parsed = ControlRequest::parse_line(line);
367
+            let parsed = ControlRequest::parse_line(&line);
205368
             assert_eq!(parsed, Some(request));
206369
         }
207370
     }
@@ -220,10 +383,17 @@ mod tests {
220383
 
221384
     #[test]
222385
     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
+        }
227397
     }
228398
 
229399
     #[test]
garwarp/src/daemon.rsmodified
@@ -2,17 +2,20 @@ use std::fs;
22
 use std::io::{self, BufRead, BufReader, Write};
33
 use std::os::unix::net::{UnixListener, UnixStream};
44
 use std::thread;
5
-use std::time::Duration;
5
+use std::time::{Duration, Instant};
66
 
7
-use garwarp_ipc::{ControlRequest, ControlResponse, HealthStatus, StatusResponse};
7
+use garwarp_ipc::{
8
+    ControlRequest, ControlResponse, HealthStatus, RequestTransitionTarget, StatusResponse,
9
+};
810
 
911
 use crate::config::Config;
1012
 use crate::dbus::{self, SessionNameGuard};
11
-use crate::error::{PortalError, map_portal_error};
13
+use crate::error::{PortalError, map_portal_error, map_request_error};
1214
 use crate::lock::SingleInstanceGuard;
1315
 use crate::logging;
14
-use crate::request::RequestRegistry;
16
+use crate::request::{RequestOwner, RequestRegistry, RequestState};
1517
 use crate::runtime::RuntimePaths;
18
+use crate::window::parse_optional_parent_window;
1619
 
1720
 pub fn run() -> io::Result<()> {
1821
     let config = Config::from_env();
@@ -35,6 +38,11 @@ pub fn run() -> io::Result<()> {
3538
     };
3639
 
3740
     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
+
3846
         match listener.accept() {
3947
             Ok((stream, _address)) => {
4048
                 if let Err(error) = handle_connection(stream, &mut state) {
@@ -88,6 +96,64 @@ fn handle_connection(stream: UnixStream, state: &mut DaemonState) -> io::Result<
8896
             state.running = false;
8997
             ControlResponse::AckStopping
9098
         }
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
+        }
91157
         None => {
92158
             let mapping = map_portal_error(&PortalError::InvalidRequestPayload);
93159
             ControlResponse::Error {
@@ -113,6 +179,15 @@ fn remove_stale_socket(path: &std::path::Path) -> io::Result<()> {
113179
     Ok(())
114180
 }
115181
 
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
+
116191
 #[cfg(test)]
117192
 mod tests {
118193
     use super::{DaemonState, handle_connection};
@@ -122,6 +197,7 @@ mod tests {
122197
     use std::time::{Duration, Instant};
123198
 
124199
     use crate::request::{RequestOwner, RequestRegistry, RequestState};
200
+    use crate::window::ParentWindowContext;
125201
 
126202
     #[test]
127203
     fn status_request_returns_status_response() {
@@ -223,4 +299,105 @@ mod tests {
223299
             }
224300
         );
225301
     }
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
+    }
226403
 }
garwarp/src/request.rsmodified
@@ -40,6 +40,18 @@ impl RequestState {
4040
             Self::Fulfilled | Self::Cancelled | Self::Failed | Self::Expired
4141
         )
4242
     }
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
+    }
4355
 }
4456
 
4557
 #[derive(Debug, Clone)]
garwarpctl/src/main.rsmodified
@@ -5,32 +5,156 @@ use std::path::PathBuf;
55
 
66
 use garwarp_ipc::{
77
     ControlRequest, ControlResponse, DEFAULT_CONTROL_SOCKET, DEFAULT_RUNTIME_SUBDIR,
8
-    PROTOCOL_VERSION,
8
+    PROTOCOL_VERSION, RequestTransitionTarget,
99
 };
1010
 
1111
 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
+
1322
     if let Err(error) = run(command) {
1423
         eprintln!("garwarpctl error: {error}");
1524
         std::process::exit(1);
1625
     }
1726
 }
1827
 
19
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28
+#[derive(Debug, Clone, PartialEq, Eq)]
2029
 enum Command {
2130
     Status,
2231
     Stop,
2332
     Version,
2433
     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
+    }
25151
 }
26152
 
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())
34158
     }
35159
 }
36160
 
@@ -70,6 +194,60 @@ fn run(command: Command) -> io::Result<()> {
70194
                 )),
71195
             }
72196
         }
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
+        }
73251
         Command::Version => {
74252
             println!("garwarpctl protocol v{PROTOCOL_VERSION}");
75253
             Ok(())
@@ -84,7 +262,8 @@ fn run(command: Command) -> io::Result<()> {
84262
 fn send_request(request: ControlRequest) -> io::Result<ControlResponse> {
85263
     let socket_path = control_socket_path();
86264
     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())?;
88267
     stream.write_all(b"\n")?;
89268
     stream.flush()?;
90269
 
@@ -108,20 +287,82 @@ fn runtime_dir() -> PathBuf {
108287
 
109288
 fn print_help() {
110289
     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");
112298
 }
113299
 
114300
 #[cfg(test)]
115301
 mod tests {
116
-    use super::{Command, parse_command};
302
+    use super::{Command, optional_value, parse_command, parse_transition_target};
303
+    use garwarp_ipc::RequestTransitionTarget;
117304
 
118305
     #[test]
119306
     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());
121358
     }
122359
 
123360
     #[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
+        );
126367
     }
127368
 }