1#[cfg(any(target_os = "macos", windows, test))]
8mod cdp;
9pub mod connection;
10pub mod helper;
11#[cfg(not(windows))]
12pub mod install;
13#[cfg(any(target_os = "macos", test))]
14mod macos;
15#[cfg(any(windows, test))]
16mod owner;
17#[cfg(any(target_os = "macos", windows, test))]
18pub mod session;
19#[cfg(windows)]
20pub mod windows;
21
22use crate::config::DesktopAccount;
23use crate::{error::AppError, graph::Page};
24use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
25use serde::{Deserialize, Serialize};
26use serde_json::{Value, json};
27#[cfg(any(target_os = "macos", windows, test))]
28use std::time::Duration;
29#[cfg(any(target_os = "macos", windows, test))]
30const MAX_METADATA: usize = 1024 * 1024;
31const MAX_READ: usize = 8 * 1024 * 1024;
32pub const MAX_BODY: usize = 28_000;
34
35pub fn messaging_unsupported() -> AppError {
36 AppError::Unsupported("the experimental desktop backend reads chats and chat messages and sends plain-text chat messages only (macOS/Windows/WSL); teams, channels and identity need a Graph profile".into())
37}
38
39fn account_selection_error() -> AppError {
40 AppError::Desktop("Multiple Teams accounts are available; use `init --backend desktop --desktop-account personal` or `--desktop-account work-school` to select a surface. Multiple cached accounts within that surface are still unsupported".into())
41}
42
43#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
44#[serde(rename_all = "snake_case")]
45pub enum ProbeStatus {
46 NoListener,
47 UnsafeListenerRefused,
48 NonTeamsListenerRefused,
49 MetadataProbeFailed,
50 MetadataOk,
51}
52
53impl ProbeStatus {
54 pub fn detail(self) -> &'static str {
55 match self {
56 Self::NoListener => {
57 "No endpoint on this port: Teams is not running with its debugging port open. `teams doctor` shows the commands to open it; the WSL TUI can also restart Teams after confirmation."
58 }
59 Self::UnsafeListenerRefused => "Refused a listener bound beyond loopback.",
60 Self::NonTeamsListenerRefused => {
61 "Refused a listener not owned by this user's installed Teams application."
62 }
63 Self::MetadataProbeFailed => {
64 "Desktop metadata could not be verified; no session access was attempted."
65 }
66 Self::MetadataOk => {
67 "Teams connection metadata is available. Sign-in and chat access remain unverified."
68 }
69 }
70 }
71}
72
73#[derive(Debug, Serialize, Deserialize)]
74pub struct BrowserVersion {
75 pub browser: String,
76 pub protocol: String,
77}
78
79#[derive(Debug, Serialize, Deserialize)]
80pub struct TargetMetadata {
81 #[serde(rename = "type")]
82 pub kind: String,
83 pub url_scheme: String,
84 pub url_origin: String,
85 pub teams_origin: bool,
86 pub websocket_advertised: bool,
87}
88
89#[derive(Debug, Serialize, Deserialize)]
90pub struct ConnectionMetadata {
91 pub port: u16,
92 pub status: ProbeStatus,
93 pub loopback_only: Option<bool>,
94 pub teams_owned: Option<bool>,
95 pub version: Option<BrowserVersion>,
96 pub browser_websocket_advertised: bool,
97 pub targets: Vec<TargetMetadata>,
98}
99
100impl ConnectionMetadata {
101 #[cfg(any(target_os = "macos", windows, test))]
102 fn empty(port: u16) -> Self {
103 Self {
104 port,
105 status: ProbeStatus::NoListener,
106 loopback_only: None,
107 teams_owned: None,
108 version: None,
109 browser_websocket_advertised: false,
110 targets: vec![],
111 }
112 }
113 pub fn endpoint_available(&self) -> bool {
114 self.status == ProbeStatus::MetadataOk
115 && self.loopback_only == Some(true)
116 && self.teams_owned == Some(true)
117 }
118}
119
120pub struct DesktopClient {
121 port: u16,
122 account: DesktopAccount,
123 #[cfg(windows)]
125 session: tokio::sync::Mutex<session::Session<windows::WindowsSystem>>,
126 #[cfg(target_os = "macos")]
127 session: tokio::sync::Mutex<session::Session<macos::MacSystem>>,
128 #[cfg(not(any(windows, target_os = "macos")))]
130 helper: tokio::sync::Mutex<helper::HelperClient>,
131}
132
133impl std::fmt::Debug for DesktopClient {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 f.debug_struct("DesktopClient")
136 .field("port", &self.port)
137 .field("account", &self.account)
138 .finish_non_exhaustive()
139 }
140}
141
142impl DesktopClient {
143 pub fn new(port: u16) -> Result<Self, AppError> {
144 Self::with_account(port, DesktopAccount::Auto)
145 }
146
147 pub fn with_account(port: u16, account: DesktopAccount) -> Result<Self, AppError> {
148 if port < 1024 {
149 return Err(AppError::InvalidInput(
150 "desktop port must be between 1024 and 65535".into(),
151 ));
152 }
153 Ok(Self {
154 port,
155 account,
156 #[cfg(windows)]
157 session: tokio::sync::Mutex::new(session::Session::new(windows::WindowsSystem)),
158 #[cfg(target_os = "macos")]
159 session: tokio::sync::Mutex::new(session::Session::new(macos::MacSystem)),
160 #[cfg(not(any(windows, target_os = "macos")))]
161 helper: Default::default(),
162 })
163 }
164
165 pub async fn needs_debugging_restart(&self) -> bool {
167 platform().ok() == Some("wsl_windows_helper")
168 && self
169 .probe()
170 .await
171 .is_ok_and(|probe| probe.status == ProbeStatus::NoListener)
172 }
173
174 pub async fn restart_with_debugging(&self) -> Result<(), AppError> {
176 if !self.needs_debugging_restart().await {
177 return Err(AppError::Desktop(
178 "Connection changed; press r to retry before restarting Teams".into(),
179 ));
180 }
181 connection::manage(self, connection::Action::Connect)
182 .await
183 .map(|_| ())
184 }
185
186 pub async fn probe(&self) -> Result<ConnectionMetadata, AppError> {
187 platform()?;
188 #[cfg(any(windows, target_os = "macos"))]
189 return self.session.lock().await.probe(self.port).await;
190 #[cfg(not(any(windows, target_os = "macos")))]
191 {
192 let metadata = self
193 .helper
194 .lock()
195 .await
196 .call(json!({"op": "probe", "port": self.port}))
197 .await?;
198 serde_json::from_value(metadata)
199 .map_err(|_| AppError::Desktop("Windows helper returned invalid metadata".into()))
200 }
201 }
202
203 pub async fn chats(&self, limit: u16, cursor: Option<&str>) -> Result<Page, AppError> {
204 self.read(None, limit, cursor).await
205 }
206
207 pub async fn messages(
208 &self,
209 chat: &str,
210 limit: u16,
211 cursor: Option<&str>,
212 ) -> Result<Page, AppError> {
213 self.read(Some(chat), limit, cursor).await
214 }
215
216 async fn read(
217 &self,
218 chat: Option<&str>,
219 limit: u16,
220 cursor: Option<&str>,
221 ) -> Result<Page, AppError> {
222 let mut request = read_request(self.port, chat, limit, cursor)?;
223 request["account_type"] = json!(self.account);
224 platform()?;
225 #[cfg(any(windows, target_os = "macos"))]
226 let reply = self.session.lock().await.read(&request).await?;
227 #[cfg(not(any(windows, target_os = "macos")))]
228 let reply = self
229 .helper
230 .lock()
231 .await
232 .call(json!({"op": "read", "request": request}))
233 .await?;
234 read_page(&reply)
235 }
236
237 pub async fn send(&self, chat: &str, body: &str) -> Result<Value, AppError> {
240 self.send_with_id(chat, body, &crate::delivery::client_message_id())
241 .await
242 }
243
244 pub async fn send_with_id(&self, chat: &str, body: &str, id: &str) -> Result<Value, AppError> {
246 let mut request = send_request(self.port, chat, body)?;
247 if !crate::delivery::valid_client_message_id(id) {
248 return Err(AppError::InvalidInput("invalid client message ID".into()));
249 }
250 request["client_message_id"] = json!(id);
251 request["account_type"] = json!(self.account);
252 platform()?;
253 #[cfg(any(windows, target_os = "macos"))]
254 let reply = self.session.lock().await.send(&request).await?;
255 #[cfg(not(any(windows, target_os = "macos")))]
256 let reply = self
257 .helper
258 .lock()
259 .await
260 .call_once(json!({"op": "send", "request": request}))
261 .await?;
262 sent_message(&reply, chat).map_err(|error| match error {
263 AppError::Unconfirmed(message) => AppError::UnconfirmedSend {
264 message,
265 chat_id: chat.into(),
266 client_message_id: id.into(),
267 },
268 other => other,
269 })
270 }
271
272 pub async fn delivery(&self, chat: &str, id: &str) -> Result<Value, AppError> {
274 check_chat(chat)?;
275 if !crate::delivery::valid_client_message_id(id) {
276 return Err(AppError::InvalidInput(
277 "client message ID must contain 18 digits".into(),
278 ));
279 }
280 let page = self.messages(chat, 50, None).await?;
281 Ok(crate::delivery::reconcile(chat, id, &page.items))
282 }
283
284 pub async fn send_check(&self) -> Result<(), AppError> {
286 platform()?;
287 #[cfg(any(windows, target_os = "macos"))]
288 let reply = self
289 .session
290 .lock()
291 .await
292 .send_check(self.port, self.account)
293 .await?;
294 #[cfg(not(any(windows, target_os = "macos")))]
295 let reply = self
296 .helper
297 .lock()
298 .await
299 .call(json!({"op": "send_check", "port": self.port, "account_type": self.account}))
300 .await?;
301 match reply["error"].as_str() {
302 None if reply["ok"] == true => Ok(()),
303 code => Err(send_error(code.unwrap_or_default())),
304 }
305 }
306}
307
308fn valid_id(id: &str) -> bool {
309 (1..=256).contains(&id.len())
310 && id
311 .bytes()
312 .all(|b| b.is_ascii_alphanumeric() || b"_.:@-".contains(&b))
313}
314
315fn read_request(
316 port: u16,
317 chat: Option<&str>,
318 limit: u16,
319 cursor: Option<&str>,
320) -> Result<Value, AppError> {
321 if let Some(chat) = chat {
322 check_chat(chat)?;
323 }
324 let after = cursor.map(decode_cursor).transpose()?;
325 Ok(json!({
326 "operation": "read",
327 "port": port,
328 "query": if chat.is_some() { "messages" } else { "chats" },
329 "chat_id": chat,
330 "limit": limit,
331 "after": after,
332 }))
333}
334
335fn chat_id(id: &str) -> bool {
337 id == NOTES || id.starts_with("19:") && valid_id(id)
338}
339
340const NOTES: &str = "48:notes";
341
342pub fn check_chat(chat: &str) -> Result<(), AppError> {
344 if chat_id(chat) {
345 Ok(())
346 } else {
347 Err(AppError::InvalidInput(
348 "desktop chat ids look like `19:...@thread.v2`, `19:...@unq.gbl.spaces` or `48:notes`"
349 .into(),
350 ))
351 }
352}
353
354fn valid_body(body: &str) -> bool {
356 !body.trim().is_empty() && body.chars().count() <= MAX_BODY && !body.contains('\0')
357}
358
359fn send_request(port: u16, chat: &str, body: &str) -> Result<Value, AppError> {
361 check_chat(chat)?;
362 if !valid_body(body) {
363 return Err(AppError::InvalidInput(format!(
364 "message body must be non-empty plain text of at most {MAX_BODY} characters"
365 )));
366 }
367 let id: u64 = rand::Rng::random_range(
368 &mut rand::rng(),
369 100_000_000_000_000_000..1_000_000_000_000_000_000,
370 );
371 Ok(json!({
372 "operation": "send",
373 "port": port,
374 "chat_id": chat,
375 "body": body,
376 "client_message_id": id.to_string(),
377 }))
378}
379
380fn send_error(code: &str) -> AppError {
381 match code {
382 "not_found" => AppError::NotFound("chat not found in the Teams desktop cache".into()),
383 "account_ambiguous" => account_selection_error(),
384 "unconfirmed" => AppError::Unconfirmed(
385 "Teams did not confirm the message; it may still be delivered. Check the chat before sending again".into(),
386 ),
387 "layout" => AppError::Desktop(
388 "The Teams window's send interface was not recognised; this Teams version is not supported for sending yet".into(),
389 ),
390 "no_target" => AppError::Desktop(
391 "No Teams window is available on the debugging endpoint; open Teams and sign in".into(),
392 ),
393 code => match serde_json::from_value::<ProbeStatus>(json!(code)) {
394 Ok(status) => AppError::Desktop(status.detail().into()),
395 Err(_) => AppError::Desktop(
396 "The message was not sent; the Teams page could not be reached. Run `teams doctor`".into(),
397 ),
398 },
399 }
400}
401
402fn sent_message(reply: &Value, chat: &str) -> Result<Value, AppError> {
404 if let Some(code) = reply.get("error").and_then(Value::as_str) {
405 return Err(send_error(code));
406 }
407 let result = &reply["result"];
408 if let Some(details) = result.get("exceptionDetails") {
409 let layout = details["exception"]["description"]
412 .as_str()
413 .is_some_and(|d| d.starts_with("Error: layout"));
414 return Err(send_error(if layout { "layout" } else { "" }));
415 }
416 let value: Value = result["result"]["value"]
417 .as_str()
418 .and_then(|v| serde_json::from_str(v).ok())
419 .ok_or_else(|| send_error("unconfirmed"))?;
420 if let Some(code) = value["error"].as_str() {
421 return Err(send_error(code));
422 }
423 let id = value["id"]
424 .as_str()
425 .filter(|id| valid_id(id))
426 .ok_or_else(|| send_error("unconfirmed"))?;
427 let label = |key: &str| {
428 value[key]
429 .as_str()
430 .filter(|v| v.len() <= 64 && valid_id(v))
431 .map(String::from)
432 };
433 let arrival = &value["originalArrivalTime"];
434 let created = arrival
435 .as_i64()
436 .or_else(|| arrival.as_str().and_then(|t| t.parse().ok()))
437 .and_then(|ms| jiff::Timestamp::from_millisecond(ms).ok())
438 .or_else(|| arrival.as_str().and_then(|t| t.parse().ok()))
439 .map(|t| t.to_string());
440 let mut message = json!({
441 "id": id,
442 "chatId": chat,
443 "createdDateTime": created,
444 "webUrl": null,
445 "clientMessageId": label("clientMessageId"),
446 "status": label("status"),
447 "source": "desktop",
448 });
449 message["delivery_state"] = json!(crate::delivery::State::from_message(&message));
450 Ok(message)
451}
452
453fn encode_cursor(next: &Value) -> Result<String, AppError> {
455 let t = next.get("t").and_then(Value::as_i64);
456 let id = next.get("id").and_then(Value::as_str);
457 match (t, id) {
458 (Some(t), Some(id)) if valid_id(id) => {
459 Ok(URL_SAFE_NO_PAD.encode(json!({"t":t,"id":id}).to_string()))
460 }
461 _ => Err(AppError::Desktop(
462 "Teams cache returned an invalid page position".into(),
463 )),
464 }
465}
466
467fn decode_cursor(cursor: &str) -> Result<Value, AppError> {
468 let invalid = || AppError::InvalidInput("invalid desktop cursor".into());
469 let bytes = URL_SAFE_NO_PAD
470 .decode(cursor.as_bytes())
471 .map_err(|_| invalid())?;
472 let value: Value = serde_json::from_slice(&bytes).map_err(|_| invalid())?;
473 let t = value.get("t").and_then(Value::as_i64).ok_or_else(invalid)?;
474 let id = value
475 .get("id")
476 .and_then(Value::as_str)
477 .ok_or_else(invalid)?;
478 if t < 0 || !valid_id(id) {
479 return Err(invalid());
480 }
481 Ok(json!({"t":t,"id":id}))
482}
483
484fn read_page(reply: &Value) -> Result<Page, AppError> {
486 let invalid = || AppError::Desktop("the Teams page returned an invalid chat response".into());
487 if let Some(code) = reply.get("error").and_then(Value::as_str) {
488 let status = serde_json::from_value::<ProbeStatus>(json!(code)).ok();
489 if code == "account_ambiguous" {
490 return Err(account_selection_error());
491 }
492 return Err(AppError::Desktop(match (status, code) {
493 (Some(status), _) => status.detail().into(),
494 (None, "no_target") => {
495 "No Teams window is available on the debugging endpoint; open Teams and sign in"
496 .into()
497 }
498 _ => "The Teams page could not be read over the debugging endpoint; run `teams doctor`"
499 .into(),
500 }));
501 }
502 let result = &reply["result"];
503 if let Some(details) = result.get("exceptionDetails") {
504 let layout = details["exception"]["description"]
505 .as_str()
506 .is_some_and(|d| d.starts_with("Error: layout"));
507 return Err(AppError::Desktop(if layout {
508 "The Teams cache layout was not recognised; this Teams version is not supported yet"
509 .into()
510 } else {
511 "The Teams page could not read its local cache; run `teams doctor`".into()
512 }));
513 }
514 let value: Value = result["result"]["value"]
515 .as_str()
516 .and_then(|v| serde_json::from_str(v).ok())
517 .ok_or_else(invalid)?;
518 if value["error"] == "not_found" {
519 return Err(AppError::NotFound(
520 "chat not found in the Teams desktop cache".into(),
521 ));
522 }
523 if value["error"] == "account_ambiguous" {
524 return Err(account_selection_error());
525 }
526 let items = value["items"].as_array().ok_or_else(invalid)?.clone();
527 let next_cursor = match &value["next"] {
528 Value::Null => None,
529 next => Some(encode_cursor(next)?),
530 };
531 Ok(Page {
532 items,
533 truncated: next_cursor.is_some(),
534 next_cursor,
535 next_offset: None,
536 source: Some("desktop_cache"),
537 viewer: value["me"]
538 .as_str()
539 .filter(|me| valid_id(me))
540 .map(String::from),
541 })
542}
543
544pub fn platform() -> Result<&'static str, AppError> {
545 if cfg!(target_os = "macos") {
546 Ok("macos_loopback")
547 } else if cfg!(windows) {
548 Ok("windows_native")
549 } else if cfg!(target_os = "linux")
550 && std::fs::read_to_string("/proc/sys/kernel/osrelease")
551 .unwrap_or_default()
552 .to_ascii_lowercase()
553 .contains("microsoft")
554 {
555 Ok("wsl_windows_helper")
556 } else {
557 Err(AppError::Unsupported(
558 "desktop diagnostics require macOS, Windows, or WSL with Windows executable interop"
559 .into(),
560 ))
561 }
562}
563
564#[derive(Debug, Serialize)]
567pub struct DebuggingSetup {
568 pub shell: &'static str,
570 pub open: String,
571 pub close: String,
572}
573
574impl DebuggingSetup {
575 pub fn new(port: u16) -> Option<Self> {
576 let shell = match platform().ok()? {
577 "wsl_windows_helper" => "wsl",
578 "windows_native" => "powershell",
579 "macos_loopback" => "zsh",
580 _ => return None,
581 };
582 Some(Self::for_shell(shell, port))
583 }
584
585 fn for_shell(shell: &'static str, port: u16) -> Self {
586 Self {
587 shell,
588 open: format!("teams desktop connect --port {port}"),
589 close: format!("teams desktop disconnect --port {port}"),
590 }
591 }
592
593 pub fn text(&self) -> String {
594 let shell = match self.shell {
595 "wsl" => "this WSL shell",
596 "zsh" => "Terminal (zsh or bash)",
597 _ => "PowerShell",
598 };
599 format!(
600 "To open Teams' debugging port, run this in {shell}. It restarts Teams, which ends any call in progress:\n {}\nTo close it again:\n {}\nManaged setup preserves existing settings. `teams desktop recover` restores settings after an interrupted setup. While the port is open, any program running as you can read your chats and send as you.",
601 self.open, self.close
602 )
603 }
604}
605
606pub async fn serve_helper() -> Result<(), AppError> {
608 #[cfg(windows)]
609 {
610 helper::serve(
611 tokio::io::BufReader::new(tokio::io::stdin()),
612 tokio::io::stdout(),
613 &mut session::Session::new(windows::WindowsSystem),
614 )
615 .await
616 }
617 #[cfg(not(windows))]
618 Err(AppError::Unsupported(
619 "the desktop helper runs only on Windows".into(),
620 ))
621}
622
623pub fn helper_check() -> Option<(&'static str, String)> {
626 #[cfg(not(windows))]
627 if platform().ok() == Some("wsl_windows_helper") {
628 return Some(match install::state() {
629 Ok(install::HelperState::Override(path)) if !path.is_file() => (
630 "fail",
631 format!(
632 "TEAMS_DESKTOP_HELPER points to {}, which does not exist",
633 path.display()
634 ),
635 ),
636 Ok(state @ install::HelperState::Missing(_)) => ("skip", state.describe()),
637 Ok(state) => ("pass", state.describe()),
638 Err(error) => ("fail", error.to_string()),
639 });
640 }
641 None
642}
643
644pub fn local_prerequisites() -> Result<&'static str, AppError> {
645 let transport = platform()?;
646 if transport == "macos_loopback" {
647 if !std::path::Path::new("/usr/sbin/lsof").is_file() {
648 return Err(AppError::Desktop(
649 "lsof is required for Teams listener ownership checks".into(),
650 ));
651 }
652 } else if transport == "wsl_windows_helper"
653 && !["WSLInterop", "WSLInterop-late"].iter().any(|name| {
654 std::path::Path::new("/proc/sys/fs/binfmt_misc")
655 .join(name)
656 .exists()
657 })
658 {
659 return Err(AppError::Desktop(
660 "WSL cannot run Windows executables; enable interop in /etc/wsl.conf".into(),
661 ));
662 }
663 Ok(transport)
664}
665
666#[cfg(any(target_os = "macos", windows, test))]
669async fn fill_metadata(metadata: &mut ConnectionMetadata) -> Vec<Value> {
670 let Ok((version, targets)) = read_metadata(metadata.port).await else {
671 metadata.status = ProbeStatus::MetadataProbeFailed;
672 return vec![];
673 };
674 let text = |key: &str| {
675 version
676 .get(key)
677 .and_then(Value::as_str)
678 .unwrap_or_default()
679 .to_string()
680 };
681 metadata.version = Some(BrowserVersion {
682 browser: text("Browser"),
683 protocol: text("Protocol-Version"),
684 });
685 metadata.browser_websocket_advertised = !text("webSocketDebuggerUrl").is_empty();
686 metadata.targets = targets.iter().map(sanitize_target).collect();
687 metadata.status = ProbeStatus::MetadataOk;
688 targets
689}
690
691#[cfg(any(target_os = "macos", windows, test))]
692async fn read_metadata(port: u16) -> Result<(Value, Vec<Value>), AppError> {
693 let http = reqwest::Client::builder()
694 .no_proxy()
695 .redirect(reqwest::redirect::Policy::none())
696 .timeout(Duration::from_secs(5))
697 .user_agent("TeamsCLI-DesktopProbe/0.1")
698 .build()
699 .map_err(|_| AppError::Desktop("could not create metadata reader".into()))?;
700 async fn read(http: &reqwest::Client, port: u16, route: &str) -> Result<Value, AppError> {
701 let mut response = http
702 .get(format!("http://127.0.0.1:{port}{route}"))
703 .send()
704 .await
705 .map_err(|_| AppError::Desktop("metadata endpoint is unavailable".into()))?;
706 if response.status() != reqwest::StatusCode::OK {
707 return Err(AppError::Desktop(
708 "metadata endpoint returned an unexpected status".into(),
709 ));
710 }
711 let mut bytes = Vec::new();
712 while let Some(chunk) = response
713 .chunk()
714 .await
715 .map_err(|_| AppError::Desktop("metadata response failed".into()))?
716 {
717 if bytes.len() + chunk.len() > MAX_METADATA {
718 return Err(AppError::Desktop("metadata size limit exceeded".into()));
719 }
720 bytes.extend_from_slice(&chunk);
721 }
722 serde_json::from_slice(&bytes)
723 .map_err(|_| AppError::Desktop("invalid endpoint metadata".into()))
724 }
725 let version = read(&http, port, "/json/version").await?;
726 let targets = read(&http, port, "/json/list").await?;
727 if !version.is_object() {
728 return Err(AppError::Desktop("invalid browser metadata".into()));
729 }
730 Ok((
731 version,
732 targets
733 .as_array()
734 .ok_or_else(|| AppError::Desktop("invalid target list".into()))?
735 .clone(),
736 ))
737}
738
739#[cfg(any(target_os = "macos", windows, test))]
740fn sanitize_target(target: &Value) -> TargetMetadata {
741 let uri = target
742 .get("url")
743 .and_then(Value::as_str)
744 .and_then(|u| url::Url::parse(u).ok());
745 let scheme = uri.as_ref().map(|u| u.scheme()).unwrap_or("");
746 let host = uri.as_ref().and_then(|u| u.host_str()).unwrap_or("");
747 let secure =
748 scheme == "https" && uri.as_ref().and_then(|u| u.port_or_known_default()) == Some(443);
749 let teams = secure
750 && matches!(
751 host,
752 "teams.microsoft.com" | "teams.cloud.microsoft" | "teams.live.com"
753 );
754 let origin =
755 if teams || (secure && matches!(host, "login.microsoftonline.com" | "login.live.com")) {
756 format!("https://{host}")
757 } else if matches!(scheme, "about" | "blob" | "file") {
758 format!("{scheme}:")
759 } else {
760 "[other origin omitted]".into()
761 };
762 TargetMetadata {
763 kind: target
764 .get("type")
765 .and_then(Value::as_str)
766 .unwrap_or("other")
767 .into(),
768 url_scheme: scheme.into(),
769 url_origin: origin,
770 teams_origin: teams,
771 websocket_advertised: target
772 .get("webSocketDebuggerUrl")
773 .and_then(Value::as_str)
774 .is_some_and(|s| !s.is_empty()),
775 }
776}
777
778#[cfg(test)]
779mod tests {
780 use super::macos::{loopback_address, parse_listeners};
781 use super::*;
782 use wiremock::{
783 Mock, MockServer, ResponseTemplate,
784 matchers::{header, method, path},
785 };
786
787 const CHAT: &str = "19:a@thread.v2";
788
789 fn page_reply(value: Value) -> Value {
790 json!({"result": {"result": {"type": "string", "value": value.to_string()}}})
791 }
792
793 #[test]
794 fn debugging_setup_uses_managed_commands_on_every_shell() {
795 for shell in ["wsl", "powershell", "zsh"] {
796 let setup = DebuggingSetup::for_shell(shell, 9333);
797 assert_eq!(setup.open, "teams desktop connect --port 9333");
798 assert_eq!(setup.close, "teams desktop disconnect --port 9333");
799 assert!(setup.text().contains("teams desktop recover"));
800 assert!(setup.text().contains("any program running as you"));
801 }
802 }
803
804 #[test]
810 fn the_protocol_is_pinned() {
811 assert_eq!(helper::PROTOCOL, 3);
812 let cursor = encode_cursor(&json!({"t": 1, "id": "m1"})).unwrap();
813 assert_eq!(
814 read_request(9222, Some(NOTES), 20, Some(&cursor)).unwrap(),
815 json!({"operation": "read", "port": 9222, "query": "messages",
816 "chat_id": NOTES, "limit": 20, "after": {"t": 1, "id": "m1"}})
817 );
818 assert_eq!(
819 read_request(9222, None, 20, None).unwrap(),
820 json!({"operation": "read", "port": 9222, "query": "chats",
821 "chat_id": null, "limit": 20, "after": null})
822 );
823 let mut send = send_request(9222, NOTES, "hi").unwrap();
824 assert!(
825 send["client_message_id"]
826 .as_str()
827 .is_some_and(|id| id.len() == 18)
828 );
829 send["client_message_id"] = json!("<id>");
830 assert_eq!(
831 send,
832 json!({"operation": "send", "port": 9222, "chat_id": NOTES,
833 "body": "hi", "client_message_id": "<id>"})
834 );
835 let scripts = [
836 include_str!("desktop.js"),
837 include_str!("desktop_send.js"),
838 include_str!("desktop_cache.js"),
839 ]
840 .map(|script| {
841 use sha2::Digest;
842 let digest = sha2::Sha256::digest(script.replace('\r', ""));
844 digest[..6]
845 .iter()
846 .map(|b| format!("{b:02x}"))
847 .collect::<String>()
848 });
849 assert_eq!(scripts, ["32ca0293d2d5", "b9d1fd2a4447", "3e837cc21336"]);
851 }
852
853 #[test]
854 fn send_requests_validate_and_carry_a_fresh_client_message_id() {
855 let request = send_request(9222, CHAT, "hello\nthere").unwrap();
856 assert_eq!(request["operation"], "send");
857 assert_eq!(request["chat_id"], CHAT);
858 assert_eq!(request["body"], "hello\nthere");
859 let id = request["client_message_id"].as_str().unwrap();
860 assert!(id.len() == 18 && id.bytes().all(|b| b.is_ascii_digit()));
861 assert_ne!(
862 send_request(9222, CHAT, "x").unwrap()["client_message_id"],
863 request["client_message_id"]
864 );
865 for (chat, body) in [
866 ("fixture", "hello"),
867 ("19:a b@thread.v2", "hello"),
868 ("48:notes2", "hello"),
869 ("48:other", "hello"),
870 (CHAT, ""),
871 (CHAT, " \n\t"),
872 (CHAT, "a\0b"),
873 (CHAT, &"x".repeat(MAX_BODY + 1)),
874 ] {
875 assert!(
876 matches!(
877 send_request(9222, chat, body),
878 Err(AppError::InvalidInput(_))
879 ),
880 "{chat:?} accepted"
881 );
882 }
883 assert!(send_request(9222, CHAT, &"é".repeat(MAX_BODY)).is_ok());
884 assert!(send_request(9222, NOTES, "hello").is_ok());
885 }
886
887 #[test]
888 fn sent_messages_map_to_the_graph_shape() {
889 let reply = page_reply(
890 json!({"id": "1760000000000", "clientMessageId": "123456789012345678",
891 "originalArrivalTime": "1760000000000", "status": "Sent"}),
892 );
893 let message = sent_message(&reply, CHAT).unwrap();
894 assert_eq!(message["id"], "1760000000000");
895 assert_eq!(message["chatId"], CHAT);
896 assert_eq!(message["createdDateTime"], "2025-10-09T08:53:20Z");
897 assert_eq!(message["clientMessageId"], "123456789012345678");
898 assert_eq!(message["status"], "Sent");
899 assert_eq!(message["source"], "desktop");
900 for arrival in [json!(1760000000000_i64), json!("2025-10-09T08:53:20Z")] {
901 let reply = page_reply(json!({"id": "1", "originalArrivalTime": arrival}));
902 assert_eq!(
903 sent_message(&reply, CHAT).unwrap()["createdDateTime"],
904 "2025-10-09T08:53:20Z"
905 );
906 }
907 let reply = page_reply(json!({"id": "1", "status": "<b>not a label</b>"}));
908 let message = sent_message(&reply, CHAT).unwrap();
909 assert!(message["status"].is_null() && message["createdDateTime"].is_null());
910 }
911
912 #[test]
913 fn send_failures_say_whether_the_message_may_have_gone() {
914 let unconfirmed = |error: AppError| error.to_string().contains("may still be delivered");
915 for reply in [
916 json!({"error": "unconfirmed"}),
917 page_reply(json!({"error": "unconfirmed"})),
918 page_reply(json!({"id": "bad id"})),
919 page_reply(json!({})),
920 json!({"result": {"result": {"type": "undefined"}}}),
921 ] {
922 assert!(
923 unconfirmed(sent_message(&reply, CHAT).unwrap_err()),
924 "{reply}"
925 );
926 }
927 assert!(matches!(
928 sent_message(&page_reply(json!({"error": "not_found"})), CHAT),
929 Err(AppError::NotFound(_))
930 ));
931 let thrown =
932 json!({"result": {"exceptionDetails": {"exception": {"description": "TypeError: x"}}}});
933 let error = sent_message(&thrown, CHAT).unwrap_err();
934 assert!(
935 !unconfirmed(error)
936 && sent_message(&thrown, CHAT)
937 .unwrap_err()
938 .to_string()
939 .contains("not sent")
940 );
941 for code in [
942 "layout",
943 "no_target",
944 "non_teams_listener_refused",
945 "something_else",
946 ] {
947 let error = sent_message(&json!({"error": code}), CHAT).unwrap_err();
948 assert!(
949 matches!(error, AppError::Desktop(_)) && !unconfirmed(error),
950 "{code}"
951 );
952 }
953 }
954
955 #[test]
956 fn read_requests_validate_chat_ids_and_cursors() {
957 let request = read_request(9222, None, 5, None).unwrap();
958 assert_eq!(request["query"], "chats");
959 assert!(request["after"].is_null());
960 let cursor = encode_cursor(&json!({"t": 42, "id": "19:a@thread.v2"})).unwrap();
961 let request = read_request(9222, Some("19:a@thread.v2"), 5, Some(&cursor)).unwrap();
962 assert_eq!(request["query"], "messages");
963 assert_eq!(request["chat_id"], "19:a@thread.v2");
964 assert_eq!(request["after"], json!({"t": 42, "id": "19:a@thread.v2"}));
965 for chat in ["fixture", "19:a b", "19:a'); x", ""] {
966 assert!(matches!(
967 read_request(9222, Some(chat), 5, None),
968 Err(AppError::InvalidInput(_))
969 ));
970 }
971 let negative = URL_SAFE_NO_PAD.encode(r#"{"t":-1,"id":"x"}"#);
972 for cursor in ["%%%", "e30", negative.as_str()] {
973 assert!(matches!(
974 read_request(9222, None, 5, Some(cursor)),
975 Err(AppError::InvalidInput(_))
976 ));
977 }
978 }
979
980 fn reply(value: Value) -> Value {
981 json!({"result":{"result":{"type":"string","value":value.to_string()}}})
982 }
983
984 #[test]
985 fn read_pages_map_cache_results_and_failures() {
986 let page = read_page(&reply(json!({
987 "me": "0f0e-guid",
988 "items": [{"id": "19:a@thread.v2", "topic": null}],
989 "next": {"t": 7, "id": "19:a@thread.v2"}
990 })))
991 .unwrap();
992 assert_eq!(page.items.len(), 1);
993 assert_eq!(page.viewer.as_deref(), Some("0f0e-guid"));
994 assert!(serde_json::to_value(&page).unwrap().get("viewer").is_none());
995 assert!(page.truncated);
996 assert_eq!(page.source, Some("desktop_cache"));
997 assert_eq!(
998 decode_cursor(page.next_cursor.as_deref().unwrap()).unwrap(),
999 json!({"t": 7, "id": "19:a@thread.v2"})
1000 );
1001 let last = read_page(&reply(json!({"me": "a b", "items": [], "next": null}))).unwrap();
1002 assert!(!last.truncated && last.next_cursor.is_none() && last.viewer.is_none());
1003 assert!(matches!(
1004 read_page(&reply(json!({"error": "not_found"}))),
1005 Err(AppError::NotFound(_))
1006 ));
1007 let layout = json!({"result":{"exceptionDetails":{"exception":{"description":"Error: layout\n at x"}}}});
1008 for ambiguous in [
1009 json!({"error": "account_ambiguous"}),
1010 reply(json!({"error": "account_ambiguous"})),
1011 ] {
1012 assert!(
1013 matches!(read_page(&ambiguous), Err(AppError::Desktop(m)) if m.contains("--desktop-account"))
1014 );
1015 assert!(
1016 matches!(sent_message(&ambiguous, CHAT), Err(AppError::Desktop(m)) if m.contains("--desktop-account"))
1017 );
1018 }
1019 assert!(matches!(read_page(&layout), Err(AppError::Desktop(m)) if m.contains("layout")));
1020 for reply in [
1021 json!({"error": "no_target"}),
1022 json!({"error": "non_teams_listener_refused"}),
1023 json!({"error": "read_failed"}),
1024 json!("not an object"),
1025 reply(json!({"items": [], "next": {"t": 1, "id": "bad id"}})),
1026 ] {
1027 assert!(matches!(read_page(&reply), Err(AppError::Desktop(_))));
1028 }
1029 }
1030
1031 #[test]
1032 fn listener_parser_keeps_every_binding_and_refuses_non_loopback() {
1033 let listeners =
1034 parse_listeners("p12\nn127.0.0.1:9222\nn[::1]:9222\np13\nn*:9222\n").unwrap();
1035 assert_eq!(listeners.len(), 3);
1036 assert!(loopback_address(&listeners[0].1, 9222));
1037 assert!(loopback_address(&listeners[1].1, 9222));
1038 assert!(!loopback_address(&listeners[2].1, 9222));
1039 assert!(!loopback_address("127.0.0.1:19222", 9222));
1040 assert!(parse_listeners("n127.0.0.1:9222").is_err());
1041 }
1042
1043 #[test]
1044 fn origins_are_exact_and_private_metadata_is_removed() {
1045 let cases = [
1046 (
1047 "https://teams.microsoft.com/v2/?account=PRIVATE",
1048 true,
1049 "https://teams.microsoft.com",
1050 ),
1051 (
1052 "https://teams.cloud.microsoft/path",
1053 true,
1054 "https://teams.cloud.microsoft",
1055 ),
1056 (
1057 "https://teams.live.com/PRIVATE",
1058 true,
1059 "https://teams.live.com",
1060 ),
1061 (
1062 "https://teams.microsoft.com.evil.example/PRIVATE",
1063 false,
1064 "[other origin omitted]",
1065 ),
1066 (
1067 "https://teams.microsoft.com:444/PRIVATE",
1068 false,
1069 "[other origin omitted]",
1070 ),
1071 (
1072 "http://teams.microsoft.com/PRIVATE",
1073 false,
1074 "[other origin omitted]",
1075 ),
1076 ("file:///Users/PRIVATE/SECRET.html", false, "file:"),
1077 ("blob:https://private-tenant.example/SECRET", false, "blob:"),
1078 (
1079 "https://login.microsoftonline.com/PRIVATE",
1080 false,
1081 "https://login.microsoftonline.com",
1082 ),
1083 ];
1084 for (url, teams, origin) in cases {
1085 let target = sanitize_target(
1086 &json!({"type":"page","url":url,"title":"PRIVATE","id":"SECRET","webSocketDebuggerUrl":"ws://127.0.0.1/SECRET"}),
1087 );
1088 assert_eq!(target.teams_origin, teams);
1089 assert_eq!(target.url_origin, origin);
1090 let output = serde_json::to_string(&target).unwrap();
1091 assert!(!output.contains("PRIVATE") && !output.contains("SECRET"));
1092 }
1093 }
1094
1095 #[tokio::test]
1096 async fn metadata_reader_uses_only_the_two_read_routes_and_generic_user_agent() {
1097 let server = MockServer::start().await;
1098 Mock::given(method("GET"))
1099 .and(path("/json/version"))
1100 .and(header("User-Agent", "TeamsCLI-DesktopProbe/0.1"))
1101 .respond_with(
1102 ResponseTemplate::new(200)
1103 .set_body_json(json!({"Browser":"Edg/fixture","Protocol-Version":"1.3"})),
1104 )
1105 .expect(1)
1106 .mount(&server)
1107 .await;
1108 Mock::given(method("GET"))
1109 .and(path("/json/list"))
1110 .respond_with(
1111 ResponseTemplate::new(200)
1112 .set_body_json(json!([{"type":"page","url":"https://teams.live.com/PRIVATE"}])),
1113 )
1114 .expect(1)
1115 .mount(&server)
1116 .await;
1117 let (version, targets) = read_metadata(server.address().port()).await.unwrap();
1118 assert_eq!(version["Protocol-Version"], "1.3");
1119 assert_eq!(
1120 sanitize_target(&targets[0]).url_origin,
1121 "https://teams.live.com"
1122 );
1123 let mut connection = ConnectionMetadata::empty(server.address().port());
1124 connection.status = ProbeStatus::MetadataOk;
1125 assert!(!connection.endpoint_available());
1126 connection.loopback_only = Some(true);
1127 connection.teams_owned = Some(true);
1128 assert!(connection.endpoint_available());
1129 }
1130
1131 #[tokio::test]
1132 async fn metadata_reader_refuses_redirects_and_oversized_responses() {
1133 let server = MockServer::start().await;
1134 let sink = MockServer::start().await;
1135 Mock::given(method("GET"))
1136 .respond_with(ResponseTemplate::new(200))
1137 .expect(0)
1138 .mount(&sink)
1139 .await;
1140 Mock::given(path("/json/version"))
1141 .respond_with(ResponseTemplate::new(302).insert_header("Location", sink.uri()))
1142 .mount(&server)
1143 .await;
1144 assert!(read_metadata(server.address().port()).await.is_err());
1145 server.reset().await;
1146 Mock::given(path("/json/version"))
1147 .respond_with(ResponseTemplate::new(200).set_body_string("x".repeat(MAX_METADATA + 1)))
1148 .mount(&server)
1149 .await;
1150 assert!(read_metadata(server.address().port()).await.is_err());
1151 }
1152}