1use std::io::{Read, Write};
5use std::path::{Path, PathBuf};
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::time::{Duration, Instant};
8use serde_json::{json, Value};
9use super::{WakeError, WakeOutcome};
10
11const MAX_FRAME: usize = 1024 * 1024;
12const MAX_SNAPSHOT_FRAME: usize = 16 * 1024 * 1024;
14const TIMEOUT: Duration = Duration::from_secs(20);
15static NEXT_ID: AtomicU64 = AtomicU64::new(1);
16
17pub fn default_endpoint() -> Option<PathBuf> {
18 #[cfg(windows)]
19 { Some(PathBuf::from(r"\\.\pipe\codex-ipc")) }
20 #[cfg(not(windows))]
21 { None } }
23
24pub fn probe(path: &Path, thread: &str) -> Result<(), WakeError> {
25 discover(&mut connect(path)?, thread).map(|_| ())
26}
27
28pub fn wake(path: &Path, thread: &str, text: &str) -> Result<WakeOutcome, WakeError> {
29 wake_on(&mut connect(path)?, thread, text)
30}
31
32fn wake_on<S: Read + Write>(stream: &mut S, thread: &str, text: &str) -> Result<WakeOutcome, WakeError> {
33 let (client, owner) = discover(stream, thread)?;
34 require_idle(stream, thread, &client, &owner)?;
39 let params = json!({"conversationId": thread, "turnStart": {
40 "request": {"threadId": thread, "input": [{"type": "text", "text": text, "text_elements": []}]},
41 "context": {"inheritThreadSettings": true}
42 }});
43 let reply = call(stream, "thread-follower-start-turn", 2, params, Some(&client), Some(&owner))?;
44 if reply.pointer("/result/result/turn/id").and_then(Value::as_str)
45 .filter(|id| !id.is_empty()).is_none() {
46 return Err(WakeError::Transport("desktop did not return an accepted turn".into()));
47 }
48 Ok(WakeOutcome::Delivered)
49}
50
51fn require_idle<S: Read + Write>(stream: &mut S, thread: &str, client: &str, owner: &str) -> Result<(), WakeError> {
52 write_frame(stream, &json!({"type": "broadcast", "method": "thread-stream-following-changed",
53 "version": 1, "sourceClientId": client, "targetClientIds": [owner],
54 "params": {"conversationId": thread, "hostId": "local", "following": true}}))?;
55 let deadline = Instant::now() + TIMEOUT;
56 loop {
57 let update = read_frame(stream, deadline)?;
58 if update["type"] != "broadcast" || update["method"] != "thread-stream-state-changed"
59 || update["version"] != 11 || update["sourceClientId"] != owner
60 || update.pointer("/params/conversationId").and_then(Value::as_str) != Some(thread)
61 || update.pointer("/params/hostId").and_then(Value::as_str) != Some("local")
62 || update.pointer("/params/change/type").and_then(Value::as_str) != Some("snapshot") { continue; }
63 let state = &update["params"]["change"]["conversationState"];
64 if state["id"] != thread || state["resumeState"] != "resumed"
66 || state.pointer("/threadRuntimeStatus/type").and_then(Value::as_str) != Some("idle")
67 || state["unconfirmedTurnSubmissions"].as_array().is_some_and(|items| !items.is_empty()) {
68 return Err(WakeError::Unavailable("desktop thread busy or idle state unconfirmed; retry later".into()));
69 }
70 let busy = match state["turnHistory"]["kind"].as_str() {
71 Some("canonical") => state["turnHistory"]["history"]["entitiesByKey"].as_object()
72 .map(|items| items.values().any(|turn| turn["status"] == "inProgress")),
73 _ => state["turns"].as_array().map(|items| items.iter().any(|turn| turn["status"] == "inProgress")),
74 };
75 if busy != Some(false) {
76 return Err(WakeError::Unavailable("desktop turn state busy or unavailable; retry later".into()));
77 }
78 return Ok(());
79 }
80}
81
82fn discover<S: Read + Write>(stream: &mut S, thread: &str) -> Result<(String, String), WakeError> {
83 let hello = call(stream, "initialize", 0, json!({"clientType": "mail4agent"}), None, None)?;
84 let client = hello.pointer("/result/clientId").and_then(Value::as_str)
85 .ok_or_else(|| WakeError::Transport("desktop initialization wire changed".into()))?;
86 let owner = call(stream, "thread-owner-discovery", 1,
87 json!({"hostId": "local", "conversationId": thread}), Some(client), None)?;
88 let owner_id = owner["handledByClientId"].as_str()
89 .ok_or_else(|| WakeError::Transport("desktop owner discovery wire changed".into()))?;
90 if owner.pointer("/result/supportsUntrustedAppInput").and_then(Value::as_bool) != Some(true) {
91 return Err(WakeError::Unavailable("desktop owner protocol unsupported".into()));
92 }
93 Ok((client.into(), owner_id.into()))
94}
95
96fn call<S: Read + Write>(stream: &mut S, method: &str, version: u32, params: Value,
97 client: Option<&str>, owner: Option<&str>) -> Result<Value, WakeError> {
98 let id = format!("m4a-{}-{}", std::process::id(), NEXT_ID.fetch_add(1, Ordering::Relaxed));
99 let mut request = json!({"type": "request", "requestId": id, "method": method,
100 "version": version, "params": params, "timeoutMs": 15000});
101 if let Some(client) = client { request["sourceClientId"] = client.into(); }
102 if let Some(owner) = owner { request["targetClientId"] = owner.into(); }
103 write_frame(stream, &request)?;
104 let deadline = Instant::now() + TIMEOUT;
105 loop {
106 let reply = read_frame(stream, deadline)?;
107 if reply["type"] != "response" || reply["requestId"] != id { continue; }
108 if reply["resultType"] != "success" {
109 return Err(WakeError::Unavailable(format!("desktop {method} refused")));
111 }
112 if let Some(owner) = owner {
113 if reply["handledByClientId"] != owner {
114 return Err(WakeError::Transport("desktop owner changed during delivery".into()));
115 }
116 }
117 return Ok(reply);
118 }
119}
120
121fn write_frame<S: Write>(stream: &mut S, value: &Value) -> Result<(), WakeError> {
122 let bytes = serde_json::to_vec(value).map_err(|_| transport())?;
123 if bytes.is_empty() || bytes.len() > MAX_FRAME { return Err(transport()); }
124 stream.write_all(&(bytes.len() as u32).to_le_bytes()).map_err(|_| transport())?;
125 stream.write_all(&bytes).map_err(|_| transport())?;
126 stream.flush().map_err(|_| transport())
127}
128
129fn read_frame<S: Read>(stream: &mut S, deadline: Instant) -> Result<Value, WakeError> {
130 let mut header = [0; 4];
131 read_exact_until(stream, &mut header, deadline)?;
132 let size = u32::from_le_bytes(header) as usize;
133 if size == 0 || size > MAX_SNAPSHOT_FRAME { return Err(transport()); }
134 let mut bytes = vec![0; size];
135 read_exact_until(stream, &mut bytes, deadline)?;
136 serde_json::from_slice(&bytes).map_err(|_| transport())
137}
138
139fn read_exact_until<S: Read>(stream: &mut S, mut bytes: &mut [u8], deadline: Instant) -> Result<(), WakeError> {
140 while !bytes.is_empty() {
141 if Instant::now() >= deadline { return Err(WakeError::Transport("desktop IPC timed out".into())); }
142 match stream.read(bytes) {
143 Ok(0) => return Err(transport()),
144 Ok(n) => { bytes = &mut bytes[n..]; }
145 Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {},
146 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock || err.raw_os_error() == Some(232) => {
147 std::thread::sleep(Duration::from_millis(10));
148 }
149 Err(err) => return Err(WakeError::Transport(format!("desktop IPC read failed ({:?}/{})",
150 err.kind(), err.raw_os_error().unwrap_or(0)))),
151 }
152 }
153 Ok(())
154}
155
156fn transport() -> WakeError { WakeError::Transport("desktop IPC frame failed".into()) }
157
158#[cfg(windows)]
159struct Pipe(std::fs::File);
160
161#[cfg(windows)]
162impl Read for Pipe {
163 fn read(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> {
164 use std::os::windows::io::AsRawHandle;
165 #[link(name = "kernel32")]
166 extern "system" {
167 fn PeekNamedPipe(handle: *mut std::ffi::c_void, buffer: *mut std::ffi::c_void,
168 size: u32, read: *mut u32, available: *mut u32, left: *mut u32) -> i32;
169 }
170 let mut available = 0;
171 let ok = unsafe { PeekNamedPipe(self.0.as_raw_handle(), std::ptr::null_mut(), 0,
172 std::ptr::null_mut(), &mut available, std::ptr::null_mut()) };
173 if ok == 0 { return Err(std::io::Error::last_os_error()); }
174 if available == 0 { return Err(std::io::ErrorKind::WouldBlock.into()); }
175 let size = bytes.len().min(available as usize);
176 self.0.read(&mut bytes[..size])
177 }
178}
179
180#[cfg(windows)]
181impl Write for Pipe {
182 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> { self.0.write(bytes) }
183 fn flush(&mut self) -> std::io::Result<()> { self.0.flush() }
184}
185
186#[cfg(windows)]
187fn connect(path: &Path) -> Result<Pipe, WakeError> {
188 if !path.to_string_lossy().starts_with(r"\\.\pipe\") {
189 return Err(WakeError::Unavailable("desktop requires a local named pipe".into()));
190 }
191 let stream = std::fs::OpenOptions::new().read(true).write(true).open(path)
192 .map_err(|_| WakeError::Unavailable("desktop IPC not available".into()))?;
193 Ok(Pipe(stream))
194}
195
196#[cfg(unix)]
197fn connect(path: &Path) -> Result<std::os::unix::net::UnixStream, WakeError> {
198 let stream = std::os::unix::net::UnixStream::connect(path)
199 .map_err(|_| WakeError::Unavailable("desktop IPC not available".into()))?;
200 stream.set_read_timeout(Some(TIMEOUT)).map_err(|_| transport())?;
201 stream.set_write_timeout(Some(TIMEOUT)).map_err(|_| transport())?;
202 Ok(stream)
203}
204
205#[cfg(not(any(unix, windows)))]
206fn connect(_: &Path) -> Result<std::io::Cursor<Vec<u8>>, WakeError> {
207 Err(WakeError::Unavailable("desktop IPC unsupported on this platform".into()))
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 #[test]
214 fn frames_reject_unbounded_and_truncated_input() {
215 for bytes in [vec![0,0,0,0], vec![255,255,255,255], vec![4,0,0,0,b'{']] {
216 assert!(read_frame(&mut std::io::Cursor::new(bytes), Instant::now()+TIMEOUT).is_err());
217 }
218 }
219 #[test]
220 fn frame_roundtrip() {
221 let mut stream = std::io::Cursor::new(Vec::new());
222 let value = json!({"body": "Тест", "type": "response"});
223 write_frame(&mut stream, &value).unwrap();
224 stream.set_position(0);
225 assert_eq!(read_frame(&mut stream, Instant::now()+TIMEOUT).unwrap(), value);
226 }
227 #[test]
228 fn existing_owner_receives_turn_without_thread_creation() {
229 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
230 let address = listener.local_addr().unwrap();
231 let server = std::thread::spawn(move || {
232 let (mut stream, _) = listener.accept().unwrap();
233 for method in ["initialize", "thread-owner-discovery", "thread-follower-start-turn"] {
234 if method == "thread-follower-start-turn" {
235 let following = read_frame(&mut stream, Instant::now()+TIMEOUT).unwrap();
236 assert_eq!(following["method"], "thread-stream-following-changed");
237 write_frame(&mut stream, &json!({"type": "broadcast", "method": "thread-stream-state-changed",
238 "version": 11, "sourceClientId": "owner", "params": {"hostId": "local",
239 "conversationId": "existing-thread", "change": {"type": "snapshot", "conversationState": {
240 "id": "existing-thread", "resumeState": "resumed", "threadRuntimeStatus": {"type": "idle"}, "turns": []
241 }}}})).unwrap();
242 }
243 let req = read_frame(&mut stream, Instant::now()+TIMEOUT).unwrap();
244 assert_eq!(req["method"], method);
245 let result = match method {
246 "initialize" => json!({"clientId": "courier"}),
247 "thread-owner-discovery" => {
248 assert_eq!(req["params"]["conversationId"], "existing-thread");
249 json!({"supportsUntrustedAppInput": true})
250 }
251 _ => {
252 assert_eq!(req["targetClientId"], "owner");
253 assert_eq!(req["version"], 2);
254 assert_eq!(req["params"]["turnStart"]["request"]["threadId"], "existing-thread");
255 assert_eq!(req["params"]["turnStart"]["request"]["input"][0]["text_elements"], json!([]));
256 json!({"result": {"turn": {"id": "accepted"}}})
257 }
258 };
259 write_frame(&mut stream, &json!({"type": "response", "requestId": req["requestId"],
260 "resultType": "success", "handledByClientId": "owner", "result": result})).unwrap();
261 }
262 });
263 let mut stream = std::net::TcpStream::connect(address).unwrap();
264 stream.set_read_timeout(Some(TIMEOUT)).unwrap();
265 assert_eq!(wake_on(&mut stream, "existing-thread", "letter").unwrap(), WakeOutcome::Delivered);
266 server.join().unwrap();
267 }
268
269 #[test]
270 fn busy_or_unknown_owner_never_receives_start() {
271 for status in ["active", "notLoaded", "unknown"] {
272 let update = json!({"type": "broadcast", "method": "thread-stream-state-changed", "version": 11,
273 "sourceClientId": "owner", "params": {"hostId": "local", "conversationId": "thread",
274 "change": {"type": "snapshot", "conversationState": {"id": "thread", "resumeState": "resumed",
275 "threadRuntimeStatus": {"type": status}, "turns": []}}}});
276 let mut input = std::io::Cursor::new(Vec::new());
277 write_frame(&mut input, &update).unwrap();
278 input.set_position(0);
279 struct Duplex { input: std::io::Cursor<Vec<u8>>, output: Vec<u8> }
280 impl Read for Duplex { fn read(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> { self.input.read(bytes) } }
281 impl Write for Duplex {
282 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> { self.output.write(bytes) }
283 fn flush(&mut self) -> std::io::Result<()> { Ok(()) }
284 }
285 let mut stream = Duplex { input, output: Vec::new() };
286 assert!(require_idle(&mut stream, "thread", "courier", "owner").is_err());
287 let sent = read_frame(&mut std::io::Cursor::new(stream.output), Instant::now()+TIMEOUT).unwrap();
288 assert_eq!(sent["method"], "thread-stream-following-changed");
289 }
290 }
291}