1use crate::error::AppError;
9use serde_json::{Value, json};
10use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWrite, AsyncWriteExt};
11
12pub const PROTOCOL: u32 = 3;
13const VERSION: &str = env!("CARGO_PKG_VERSION");
14const BUILD: &str = env!("TEAMS_BUILD");
17const MAX_LINE: usize = super::MAX_READ + 64 * 1024;
18
19async fn read_line(reader: &mut (impl AsyncBufRead + Unpin)) -> Result<Option<String>, AppError> {
21 let mut line = Vec::new();
22 loop {
23 let buffer = reader.fill_buf().await?;
24 if buffer.is_empty() {
25 return Ok((!line.is_empty()).then(|| String::from_utf8_lossy(&line).into_owned()));
26 }
27 let (chunk, done) = match buffer.iter().position(|&b| b == b'\n') {
28 Some(end) => (&buffer[..end], Some(end + 1)),
29 None => (buffer, None),
30 };
31 if line.len() + chunk.len() > MAX_LINE {
32 return Err(AppError::Desktop(
33 "desktop helper message is too large".into(),
34 ));
35 }
36 line.extend_from_slice(chunk);
37 let consumed = done.unwrap_or(chunk.len());
38 reader.consume(consumed);
39 if done.is_some() {
40 return Ok(Some(String::from_utf8_lossy(&line).into_owned()));
41 }
42 }
43}
44
45async fn write_line(writer: &mut (impl AsyncWrite + Unpin), value: &Value) -> Result<(), AppError> {
46 let mut line = value.to_string();
47 line.push('\n');
48 writer.write_all(line.as_bytes()).await?;
49 writer.flush().await?;
50 Ok(())
51}
52
53#[cfg(any(windows, test))]
55pub async fn serve<S: super::session::Verifier>(
56 mut reader: impl AsyncBufRead + Unpin,
57 mut writer: impl AsyncWrite + Unpin,
58 session: &mut super::session::Session<S>,
59) -> Result<(), AppError> {
60 while let Some(line) = read_line(&mut reader).await? {
61 let request: Value = serde_json::from_str(&line).unwrap_or_default();
62 let result = match request["op"].as_str() {
63 Some("hello") => Ok(json!({"version": VERSION, "protocol": PROTOCOL, "build": BUILD})),
64 Some("probe") => match request["port"].as_u64().and_then(|p| u16::try_from(p).ok()) {
65 Some(port) => session.probe(port).await.map(|metadata| json!(metadata)),
66 None => Err(AppError::InvalidInput("invalid desktop port".into())),
67 },
68 Some("read") => session.read(&request["request"]).await,
69 Some("send") => session.send(&request["request"]).await,
70 Some("send_check") => {
71 match request["port"].as_u64().and_then(|p| u16::try_from(p).ok()) {
72 Some(port) => {
73 match crate::config::DesktopAccount::from_request(&request["account_type"])
74 {
75 Ok(account) => session.send_check(port, account).await,
76 Err(error) => Err(error),
77 }
78 }
79 None => Err(AppError::InvalidInput("invalid desktop port".into())),
80 }
81 }
82 _ => Err(AppError::InvalidInput(
83 "unknown desktop helper request".into(),
84 )),
85 };
86 let reply = match result {
87 Ok(value) => json!({"id": request["id"], "ok": value}),
88 Err(error) => json!({"id": request["id"], "error": error.to_string()}),
89 };
90 write_line(&mut writer, &reply).await?;
91 }
92 Ok(())
93}
94
95#[cfg(not(windows))]
96pub use client::HelperClient;
97
98#[cfg(not(windows))]
99mod client {
100 use super::*;
101 use std::{
102 path::{Path, PathBuf},
103 process::Stdio,
104 time::Duration,
105 };
106 use tokio::{
107 io::BufReader,
108 process::{Child, ChildStdin, ChildStdout, Command},
109 };
110
111 const START_TIMEOUT: Duration = Duration::from_secs(20);
112 const CALL_TIMEOUT: Duration = Duration::from_secs(45);
113
114 enum Failure {
115 Refused(String),
117 Transport,
119 }
120
121 struct Running {
122 _child: Child,
124 stdin: ChildStdin,
125 stdout: BufReader<ChildStdout>,
126 next_id: u64,
127 }
128
129 impl Running {
130 async fn call(&mut self, mut request: Value) -> Result<Value, Failure> {
131 self.next_id += 1;
132 request["id"] = json!(self.next_id);
133 let exchange = async {
134 write_line(&mut self.stdin, &request).await.ok()?;
135 let line = read_line(&mut self.stdout).await.ok()??;
136 serde_json::from_str::<Value>(&line).ok()
137 };
138 let reply = tokio::time::timeout(CALL_TIMEOUT, exchange)
139 .await
140 .ok()
141 .flatten()
142 .ok_or(Failure::Transport)?;
143 if reply["id"] != self.next_id {
144 return Err(Failure::Transport);
145 }
146 match (reply.get("ok"), reply["error"].as_str()) {
147 (Some(ok), _) => Ok(ok.clone()),
148 (None, Some(message)) => Err(Failure::Refused(message.into())),
149 _ => Err(Failure::Transport),
150 }
151 }
152 }
153
154 #[derive(Default)]
156 pub struct HelperClient {
157 running: Option<Running>,
158 executable: Option<PathBuf>,
160 }
161
162 impl HelperClient {
163 pub async fn call(&mut self, request: Value) -> Result<Value, AppError> {
164 if let Some(running) = &mut self.running {
165 match running.call(request.clone()).await {
166 Ok(value) => return Ok(value),
167 Err(Failure::Refused(message)) => return Err(AppError::Desktop(message)),
168 Err(Failure::Transport) => self.running = None,
169 }
170 }
171 match self.started().await?.call(request).await {
172 Ok(value) => Ok(value),
173 Err(Failure::Refused(message)) => Err(AppError::Desktop(message)),
174 Err(Failure::Transport) => {
175 self.running = None;
176 Err(AppError::Desktop(
177 "the Windows desktop helper stopped unexpectedly".into(),
178 ))
179 }
180 }
181 }
182
183 pub async fn call_once(&mut self, request: Value) -> Result<Value, AppError> {
186 match self.started().await?.call(request).await {
187 Ok(value) => Ok(value),
188 Err(Failure::Refused(message)) => Err(AppError::Desktop(message)),
189 Err(Failure::Transport) => {
190 self.running = None;
191 Ok(json!({"error": "unconfirmed"}))
192 }
193 }
194 }
195
196 async fn started(&mut self) -> Result<&mut Running, AppError> {
198 let running = match self.running.take() {
199 Some(running) => running,
200 None => {
201 let executable = match &self.executable {
202 Some(executable) => executable.clone(),
203 None => super::super::install::ensure().await?,
204 };
205 let running = start(&executable).await?;
206 self.executable = Some(executable);
207 running
208 }
209 };
210 Ok(self.running.insert(running))
211 }
212 }
213
214 fn mismatch(hello: &Value) -> Option<String> {
216 (hello["protocol"] != PROTOCOL).then(|| {
217 let theirs = describe(
218 hello["version"].as_str(),
219 hello["build"].as_str(),
220 hello["protocol"].as_u64(),
221 );
222 let ours = describe(Some(VERSION), Some(BUILD), Some(PROTOCOL.into()));
223 format!("is {theirs}, but this CLI is {ours}")
224 })
225 }
226
227 fn describe(version: Option<&str>, build: Option<&str>, protocol: Option<u64>) -> String {
229 let mut text = format!("teams-cli {}", version.unwrap_or("of an unknown version"));
230 if let Some(build) = build.filter(|b| !b.is_empty()) {
231 text.push_str(&format!(" ({build})"));
232 }
233 match protocol {
234 Some(protocol) => text.push_str(&format!(" speaking protocol {protocol}")),
235 None => text.push_str(" without a protocol number"),
236 }
237 text
238 }
239
240 async fn start(executable: &Path) -> Result<Running, AppError> {
241 let mut child = Command::new(executable)
242 .arg("desktop-helper")
243 .stdin(Stdio::piped())
244 .stdout(Stdio::piped())
245 .stderr(Stdio::null())
246 .kill_on_drop(true)
247 .spawn()
248 .map_err(|_| {
249 AppError::Desktop(format!(
250 "could not start the Windows desktop helper at {}; check that WSL can run Windows executables",
251 executable.display()
252 ))
253 })?;
254 let (Some(stdin), Some(stdout)) = (child.stdin.take(), child.stdout.take()) else {
255 return Err(AppError::Desktop(
256 "desktop helper pipes are unavailable".into(),
257 ));
258 };
259 let mut running = Running {
260 _child: child,
261 stdin,
262 stdout: BufReader::new(stdout),
263 next_id: 0,
264 };
265 let hello = tokio::time::timeout(START_TIMEOUT, running.call(json!({"op": "hello"})))
266 .await
267 .ok()
268 .and_then(Result::ok);
269 let problem = match &hello {
270 None => Some("did not answer".to_owned()),
271 Some(hello) => mismatch(hello),
272 };
273 if let Some(problem) = problem {
274 return Err(AppError::Desktop(format!(
275 "the Windows desktop helper at {} {problem}; {}",
276 executable.display(),
277 super::super::install::remedy()
278 )));
279 }
280 Ok(running)
281 }
282
283 #[cfg(all(test, unix))]
284 mod tests {
285 use super::*;
286 use std::os::unix::fs::PermissionsExt;
287
288 fn fake_helper(dir: &std::path::Path, version: &str, protocol: u32, lives: u32) -> PathBuf {
291 let path = dir.join("teams.exe");
292 let script = format!(
293 r#"#!/bin/sh
294[ "$1" = desktop-helper ] || exit 2
295n=0
296while read -r line; do
297 id=$(printf '%s' "$line" | sed 's/.*"id":\([0-9]*\).*/\1/')
298 case "$line" in
299 *'"op":"hello"'*) echo "{{\"id\":$id,\"ok\":{{\"version\":\"{version}\",\"protocol\":{protocol},\"build\":\"0123abcd\"}}}}" ;;
300 *'"op":"fail"'*) echo "{{\"id\":$id,\"error\":\"refused\"}}" ;;
301 *) n=$((n+1)); echo "{{\"id\":$id,\"ok\":$n}}"; [ $n -ge {lives} ] && exit 0 ;;
302 esac
303done
304"#
305 );
306 std::fs::write(&path, script).unwrap();
307 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
308 while matches!(
312 std::process::Command::new(&path).status(),
313 Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy
314 ) {
315 std::thread::sleep(Duration::from_millis(5));
316 }
317 path
318 }
319
320 #[tokio::test]
321 async fn handshakes_and_refuses_other_protocols_only() {
322 let dir = tempfile::tempdir().unwrap();
323 let mut running = start(&fake_helper(dir.path(), VERSION, PROTOCOL, 9))
324 .await
325 .unwrap();
326 assert_eq!(running.call(json!({"op": "x"})).await.ok(), Some(json!(1)));
327 assert_eq!(running.call(json!({"op": "x"})).await.ok(), Some(json!(2)));
328 assert!(matches!(
329 running.call(json!({"op": "fail"})).await,
330 Err(Failure::Refused(m)) if m == "refused"
331 ));
332 let older = tempfile::tempdir().unwrap();
334 assert!(
335 start(&fake_helper(older.path(), "0.0.1", PROTOCOL, 9))
336 .await
337 .is_ok()
338 );
339 let other = tempfile::tempdir().unwrap();
340 let error = start(&fake_helper(other.path(), "0.0.1", 1, 9))
341 .await
342 .err()
343 .unwrap()
344 .to_string();
345 assert!(
346 error
347 .contains("is teams-cli 0.0.1 (0123abcd) speaking protocol 1, but this CLI is"),
348 "{error}"
349 );
350 assert!(
351 error.contains(&format!("speaking protocol {PROTOCOL};")),
352 "{error}"
353 );
354 }
355
356 #[tokio::test]
357 async fn a_dead_helper_is_replaced_once() {
358 let dir = tempfile::tempdir().unwrap();
359 let mut client = HelperClient {
360 running: None,
361 executable: Some(fake_helper(dir.path(), VERSION, PROTOCOL, 1)),
362 };
363 for _ in 0..3 {
365 assert_eq!(client.call(json!({"op": "x"})).await.unwrap(), json!(1));
366 }
367 assert!(matches!(
368 client.call(json!({"op": "fail"})).await,
369 Err(AppError::Desktop(m)) if m == "refused"
370 ));
371 }
372
373 #[tokio::test]
374 async fn a_lost_answer_to_a_single_call_is_unconfirmed() {
375 let dir = tempfile::tempdir().unwrap();
376 let mut client = HelperClient {
378 running: None,
379 executable: Some(fake_helper(dir.path(), VERSION, PROTOCOL, 1)),
380 };
381 assert_eq!(
382 client.call_once(json!({"op": "x"})).await.unwrap(),
383 json!(1)
384 );
385 assert_eq!(
386 client.call_once(json!({"op": "x"})).await.unwrap(),
387 json!({"error": "unconfirmed"})
388 );
389 assert_eq!(
391 client.call_once(json!({"op": "x"})).await.unwrap(),
392 json!(1)
393 );
394 }
395 }
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401 use crate::desktop::{owner::tests::FakeSystem, session::Session};
402 use tokio::io::{BufReader, duplex};
403
404 #[tokio::test]
405 async fn serves_hello_probe_errors_and_stops_at_end_of_input() {
406 let (client, server) = duplex(1 << 20);
407 let (server_read, server_write) = tokio::io::split(server);
408 let task = tokio::spawn(async move {
409 let mut session = Session::new(FakeSystem {
410 listeners: vec![],
411 ..FakeSystem::teams()
412 });
413 serve(BufReader::new(server_read), server_write, &mut session).await
414 });
415 let (client_read, mut client_write) = tokio::io::split(client);
416 let mut client_read = BufReader::new(client_read);
417 let mut call = async |request: Value| {
418 write_line(&mut client_write, &request).await.unwrap();
419 serde_json::from_str::<Value>(&read_line(&mut client_read).await.unwrap().unwrap())
420 .unwrap()
421 };
422 let hello = call(json!({"id": 1, "op": "hello"})).await;
423 assert_eq!(hello["id"], 1);
424 assert_eq!(
425 hello["ok"],
426 json!({"version": VERSION, "protocol": PROTOCOL, "build": BUILD})
427 );
428 let probe = call(json!({"id": 2, "op": "probe", "port": 9222})).await;
429 assert_eq!(probe["ok"]["status"], "no_listener");
430 let bad = call(json!({"id": 3, "op": "read", "request": {"port": 9222}})).await;
431 assert!(bad["error"].is_string() && bad.get("ok").is_none());
432 let send = call(json!({"id": 4, "op": "send", "request": {"port": 9222}})).await;
433 assert!(send["error"].is_string() && send.get("ok").is_none());
434 let check = call(json!({"id": 5, "op": "send_check", "port": 9222})).await;
435 assert_eq!(check["ok"]["error"], "no_listener");
436 let unknown = call(json!({"id": 6, "op": "launch"})).await;
437 assert!(unknown["error"].is_string());
438 let invalid_account =
439 call(json!({"id": 7, "op": "send_check", "port": 9222, "account_type": "all"})).await;
440 assert!(
441 invalid_account["error"]
442 .as_str()
443 .unwrap()
444 .contains("account type")
445 );
446 drop((client_read, client_write));
448 task.await.unwrap().unwrap();
449 }
450
451 #[tokio::test]
452 async fn oversized_lines_are_refused() {
453 let data = vec![b'x'; MAX_LINE + 1];
454 let mut reader = BufReader::new(&data[..]);
455 assert!(read_line(&mut reader).await.is_err());
456 let mut reader = BufReader::new(&b"a\nb"[..]);
457 assert_eq!(read_line(&mut reader).await.unwrap().as_deref(), Some("a"));
458 assert_eq!(read_line(&mut reader).await.unwrap().as_deref(), Some("b"));
459 assert_eq!(read_line(&mut reader).await.unwrap(), None);
460 }
461}