1#[cfg(any(target_os = "macos", target_os = "linux", windows, test))]
8mod avatars;
9#[cfg(any(target_os = "macos", target_os = "linux", windows, test))]
10mod cdp;
11pub mod connection;
12pub mod helper;
13#[cfg(not(windows))]
14pub mod install;
15#[cfg(any(target_os = "macos", test))]
16mod macos;
17#[cfg(any(windows, test))]
18mod owner;
19#[cfg(any(target_os = "macos", target_os = "linux", windows, test))]
20pub mod session;
21#[cfg(windows)]
22pub mod windows;
23
24use crate::config::{DesktopAccount, DesktopClientKind, Profile};
25#[cfg(any(target_os = "macos", target_os = "linux"))]
26pub mod browser;
27#[cfg(any(target_os = "macos", target_os = "linux"))]
28mod browser_core;
29#[cfg(any(target_os = "macos", target_os = "linux"))]
30mod browser_install;
31use crate::{error::AppError, graph::Page};
32use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
33use serde::{Deserialize, Serialize};
34use serde_json::{Value, json};
35#[cfg(any(target_os = "macos", target_os = "linux", windows, test))]
36use std::time::Duration;
37#[cfg(any(target_os = "macos", target_os = "linux", windows, test))]
38const MAX_METADATA: usize = 1024 * 1024;
39const MAX_READ: usize = 8 * 1024 * 1024;
40pub const MAX_BODY: usize = 28_000;
42
43pub fn messaging_unsupported() -> AppError {
44 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())
45}
46
47fn account_selection_error() -> AppError {
48 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())
49}
50
51#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(rename_all = "snake_case")]
53pub enum ProbeStatus {
54 NoListener,
55 UnsafeListenerRefused,
56 NonTeamsListenerRefused,
57 MetadataProbeFailed,
58 MetadataOk,
59}
60
61impl ProbeStatus {
62 pub fn detail(self) -> &'static str {
63 match self {
64 Self::NoListener => {
65 "No endpoint on this port: Teams is not running with its debugging port open. `teams desktop connect` opens it by restarting Teams after confirmation (it ends any call in progress)."
66 }
67 Self::UnsafeListenerRefused => "Refused a listener bound beyond loopback.",
68 Self::NonTeamsListenerRefused => {
69 "Refused a listener not owned by this user's installed Teams application."
70 }
71 Self::MetadataProbeFailed => {
72 "Desktop metadata could not be verified; no session access was attempted."
73 }
74 Self::MetadataOk => {
75 "Teams connection metadata is available. Sign-in and chat access remain unverified."
76 }
77 }
78 }
79}
80
81#[derive(Debug, Serialize, Deserialize)]
82pub struct BrowserVersion {
83 pub browser: String,
84 pub protocol: String,
85}
86
87#[derive(Debug, Serialize, Deserialize)]
88pub struct TargetMetadata {
89 #[serde(rename = "type")]
90 pub kind: String,
91 pub url_scheme: String,
92 pub url_origin: String,
93 pub teams_origin: bool,
94 pub websocket_advertised: bool,
95}
96
97#[derive(Debug, Serialize, Deserialize)]
98pub struct ConnectionMetadata {
99 pub port: u16,
100 pub status: ProbeStatus,
101 pub loopback_only: Option<bool>,
102 pub teams_owned: Option<bool>,
103 pub version: Option<BrowserVersion>,
104 pub browser_websocket_advertised: bool,
105 pub targets: Vec<TargetMetadata>,
106 #[serde(default, skip_serializing_if = "Vec::is_empty")]
108 pub foreign_listeners: Vec<String>,
109 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
111 pub user_debugging_flags: bool,
112}
113
114impl ConnectionMetadata {
115 #[cfg(any(target_os = "macos", target_os = "linux", windows, test))]
116 fn empty(port: u16) -> Self {
117 Self {
118 port,
119 status: ProbeStatus::NoListener,
120 loopback_only: None,
121 teams_owned: None,
122 version: None,
123 browser_websocket_advertised: false,
124 targets: vec![],
125 foreign_listeners: vec![],
126 user_debugging_flags: false,
127 }
128 }
129 pub fn endpoint_available(&self) -> bool {
130 self.status == ProbeStatus::MetadataOk
131 && self.loopback_only == Some(true)
132 && self.teams_owned == Some(true)
133 }
134
135 #[cfg(any(target_os = "macos", target_os = "linux", test))]
137 pub fn signed_out(&self) -> bool {
138 let pages: Vec<_> = self.targets.iter().filter(|t| t.kind == "page").collect();
139 self.endpoint_available()
140 && pages.iter().any(|t| t.url_scheme == "https")
141 && !pages.iter().any(|t| t.teams_origin)
142 }
143
144 pub fn detail(&self) -> String {
146 let mut detail = self.status.detail().to_string();
147 if self.status != ProbeStatus::NonTeamsListenerRefused {
148 return detail;
149 }
150 if !self.foreign_listeners.is_empty() {
151 detail.push_str(&format!(
152 " Port {} is also served by {}.",
153 self.port,
154 self.foreign_listeners.join(", ")
155 ));
156 }
157 if self.user_debugging_flags {
158 detail.push_str(" A user-wide WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS setting opens a debugging port in every WebView2 application. Run `teams desktop clear-flags` to remove only its debugging flags, then fully quit and reopen those applications.");
159 }
160 detail
161 }
162
163 pub fn stray_debugging_flags(&self) -> Option<&[String]> {
165 (self.status == ProbeStatus::NonTeamsListenerRefused && self.user_debugging_flags)
166 .then_some(self.foreign_listeners.as_slice())
167 }
168}
169
170pub struct DesktopClient {
171 port: u16,
172 account: DesktopAccount,
173 kind: DesktopClientKind,
174 #[cfg(any(target_os = "macos", target_os = "linux"))]
175 browser: tokio::sync::Mutex<session::Session<browser::BrowserSystem>>,
176 #[cfg(windows)]
178 session: tokio::sync::Mutex<session::Session<windows::WindowsSystem>>,
179 #[cfg(target_os = "macos")]
180 session: tokio::sync::Mutex<session::Session<macos::MacSystem>>,
181 #[cfg(not(any(windows, target_os = "macos")))]
183 helper: tokio::sync::Mutex<helper::HelperClient>,
184}
185
186impl std::fmt::Debug for DesktopClient {
187 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188 f.debug_struct("DesktopClient")
189 .field("port", &self.port)
190 .field("account", &self.account)
191 .finish_non_exhaustive()
192 }
193}
194
195impl DesktopClient {
196 pub fn new(port: u16) -> Result<Self, AppError> {
197 Self::with_account(port, DesktopAccount::Auto)
198 }
199
200 pub fn with_account(port: u16, account: DesktopAccount) -> Result<Self, AppError> {
201 if port < 1024 {
202 return Err(AppError::InvalidInput(
203 "desktop port must be between 1024 and 65535".into(),
204 ));
205 }
206 Ok(Self {
207 port,
208 account,
209 kind: DesktopClientKind::Native,
210 #[cfg(any(target_os = "macos", target_os = "linux"))]
211 browser: tokio::sync::Mutex::new(session::Session::new(browser::BrowserSystem {
212 core: false,
213 })),
214 #[cfg(windows)]
215 session: tokio::sync::Mutex::new(session::Session::new(windows::WindowsSystem)),
216 #[cfg(target_os = "macos")]
217 session: tokio::sync::Mutex::new(session::Session::new(macos::MacSystem)),
218 #[cfg(not(any(windows, target_os = "macos")))]
219 helper: Default::default(),
220 })
221 }
222
223 pub fn from_profile(profile: &Profile) -> Result<Self, AppError> {
224 Self::with_client(
225 profile.desktop_port,
226 profile.desktop_account,
227 profile.desktop_client,
228 )
229 }
230
231 pub fn with_client(
232 port: u16,
233 account: DesktopAccount,
234 kind: DesktopClientKind,
235 ) -> Result<Self, AppError> {
236 let mut client = Self::with_account(port, account)?;
237 if kind.is_browser() && !cfg!(any(target_os = "macos", target_os = "linux")) {
238 return Err(AppError::Unsupported(
239 "the browser desktop client currently requires macOS or Linux (including WSL2)"
240 .into(),
241 ));
242 }
243 #[cfg(any(target_os = "macos", target_os = "linux"))]
244 if kind == DesktopClientKind::BrowserCore {
245 if account != DesktopAccount::Personal {
246 return Err(AppError::Unsupported("browser-core currently requires --desktop-account personal; work/school core startup is not validated".into()));
247 }
248 client.browser =
249 tokio::sync::Mutex::new(session::Session::new(browser::BrowserSystem {
250 core: true,
251 }));
252 }
253 client.kind = kind;
254 Ok(client)
255 }
256
257 pub fn debugging_setup(&self) -> Option<DebuggingSetup> {
258 if self.kind.is_browser() {
259 let mut setup = DebuggingSetup::for_shell("bash", self.port);
260 setup.open = format!(
261 "teams desktop login --desktop-client {} --port {}",
262 self.kind.as_str(),
263 self.port
264 );
265 setup.close = format!(
266 "teams desktop disconnect --desktop-client {} --port {}",
267 self.kind.as_str(),
268 self.port
269 );
270 Some(setup)
271 } else {
272 DebuggingSetup::new(self.port)
273 }
274 }
275
276 pub fn prerequisites(&self) -> Result<&'static str, AppError> {
277 #[cfg(any(target_os = "macos", target_os = "linux"))]
278 if self.kind.is_browser() {
279 browser::prerequisites_for(self.kind == DesktopClientKind::BrowserCore)?;
280 return Ok("browser_loopback");
281 }
282 local_prerequisites()
283 }
284
285 pub async fn needs_debugging_restart(&self) -> bool {
287 self.kind == DesktopClientKind::Native
288 && platform().ok() == Some("wsl_windows_helper")
289 && self
290 .probe()
291 .await
292 .is_ok_and(|probe| probe.status == ProbeStatus::NoListener)
293 }
294
295 pub async fn stray_debugging_flags(&self) -> Option<Vec<String>> {
298 if self.kind != DesktopClientKind::Native {
299 return None;
300 }
301 let probe = self.probe().await.ok()?;
302 probe.stray_debugging_flags().map(<[String]>::to_vec)
303 }
304
305 pub async fn clear_debugging_flags(&self) -> Result<(), AppError> {
307 connection::clear_flags(self).await.map(|_| ())
308 }
309
310 pub async fn restart_with_debugging(&self) -> Result<(), AppError> {
312 if !self.needs_debugging_restart().await {
313 return Err(AppError::Desktop(
314 "Connection changed; press r to retry before restarting Teams".into(),
315 ));
316 }
317 connection::manage(self, connection::Action::Connect)
318 .await
319 .map(|_| ())
320 }
321
322 pub async fn probe(&self) -> Result<ConnectionMetadata, AppError> {
323 #[cfg(any(target_os = "macos", target_os = "linux"))]
324 if self.kind.is_browser() {
325 return self.browser.lock().await.probe(self.port).await;
326 }
327 platform()?;
328 #[cfg(any(windows, target_os = "macos"))]
329 return self.session.lock().await.probe(self.port).await;
330 #[cfg(not(any(windows, target_os = "macos")))]
331 {
332 let metadata = self
333 .helper
334 .lock()
335 .await
336 .call(json!({"op": "probe", "port": self.port}))
337 .await?;
338 serde_json::from_value(metadata)
339 .map_err(|_| AppError::Desktop("Windows helper returned invalid metadata".into()))
340 }
341 }
342
343 pub async fn prepare_browser(&self, quiet: bool) -> Result<(), AppError> {
345 #[cfg(any(target_os = "macos", target_os = "linux"))]
346 if self.kind == DesktopClientKind::BrowserCore {
347 browser_install::ensure_with_progress(quiet).await?;
348 }
349 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
350 let _ = quiet;
351 Ok(())
352 }
353
354 pub async fn login(&self) -> Result<Value, AppError> {
356 self.login_with_progress(false).await
357 }
358
359 pub async fn login_with_progress(&self, quiet: bool) -> Result<Value, AppError> {
360 if !self.kind.is_browser() {
361 return Err(AppError::InvalidInput(
362 "desktop login requires --desktop-client browser".into(),
363 ));
364 }
365 #[cfg(any(target_os = "macos", target_os = "linux"))]
366 return browser::login_with_progress(
367 self.port,
368 self.account,
369 self.kind == DesktopClientKind::BrowserCore,
370 quiet,
371 )
372 .await;
373 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
374 let _ = quiet;
375 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
376 Err(AppError::Unsupported(
377 "browser login requires macOS or Linux (including WSL2)".into(),
378 ))
379 }
380
381 pub async fn chats(&self, limit: u16, cursor: Option<&str>) -> Result<Page, AppError> {
382 self.read(None, limit, cursor).await
383 }
384
385 pub async fn search_people(&self, query: &str, limit: u16) -> Result<Page, AppError> {
386 crate::graph::validate_people_query(query, usize::from(limit))?;
387 let value = self
388 .people(json!({"op":"search","query":query.trim(),"limit":limit}))
389 .await?;
390 let items = value["items"]
391 .as_array()
392 .ok_or_else(|| AppError::Desktop("Invalid recipient-search response".into()))?
393 .clone();
394 Ok(Page {
395 items,
396 next_cursor: None,
397 next_offset: None,
398 truncated: value["truncated"] == true,
399 source: Some("desktop_people"),
400 viewer: None,
401 })
402 }
403
404 pub async fn create_chat(&self, recipient: &str) -> Result<Value, AppError> {
405 check_recipient(recipient)?;
406 let value = self
407 .people(json!({"op":"create","recipient":recipient}))
408 .await?;
409 if !value["id"].as_str().is_some_and(chat_id) {
410 return Err(AppError::Unconfirmed("Teams may have created the conversation; check Teams before trying again. No message was sent".into()));
411 }
412 Ok(value)
413 }
414
415 async fn people(&self, mut request: Value) -> Result<Value, AppError> {
416 let create = request["op"] == "create";
417 request["port"] = json!(self.port);
418 request["account_type"] = json!(self.account);
419 #[cfg(any(target_os = "macos", target_os = "linux"))]
420 if self.kind.is_browser() {
421 return people_result(&self.browser.lock().await.people(&request).await?, create);
422 }
423 platform()?;
424 #[cfg(any(windows, target_os = "macos"))]
425 let reply = self.session.lock().await.people(&request).await?;
426 #[cfg(not(any(windows, target_os = "macos")))]
427 let reply = {
428 let mut helper = self.helper.lock().await;
429 let op = json!({"op":"people","request":request});
430 if create {
431 helper.call_once(op).await?
432 } else {
433 helper.call(op).await?
434 }
435 };
436 people_result(&reply, create)
437 }
438
439 pub async fn messages(
440 &self,
441 chat: &str,
442 limit: u16,
443 cursor: Option<&str>,
444 ) -> Result<Page, AppError> {
445 self.read(Some(chat), limit, cursor).await
446 }
447
448 async fn read(
449 &self,
450 chat: Option<&str>,
451 limit: u16,
452 cursor: Option<&str>,
453 ) -> Result<Page, AppError> {
454 let mut request = read_request(self.port, chat, limit, cursor)?;
455 request["account_type"] = json!(self.account);
456 #[cfg(any(target_os = "macos", target_os = "linux"))]
457 if self.kind.is_browser() {
458 let mut browser = self.browser.lock().await;
459 return match read_page(&browser.read(&request).await?) {
460 Ok(page) => Ok(page),
461 Err(error) => {
462 let hint = format!(
463 "Sign in again: `teams desktop login --desktop-client {} --port {}`.",
464 self.kind.as_str(),
465 self.port
466 );
467 let signed_out = browser
468 .probe(self.port)
469 .await
470 .is_ok_and(|metadata| metadata.signed_out());
471 Err(if signed_out {
472 AppError::Desktop(format!(
473 "Teams in the dedicated browser was sent back to Microsoft sign-in. {hint}"
474 ))
475 } else {
476 error.with_hint(&hint)
477 })
478 }
479 };
480 }
481 platform()?;
482 #[cfg(any(windows, target_os = "macos"))]
483 let reply = self.session.lock().await.read(&request).await?;
484 #[cfg(not(any(windows, target_os = "macos")))]
485 let reply = self
486 .helper
487 .lock()
488 .await
489 .call(json!({"op": "read", "request": request}))
490 .await?;
491 read_page(&reply)
492 }
493
494 pub async fn send(&self, chat: &str, body: &str) -> Result<Value, AppError> {
497 self.send_with_id(chat, body, &crate::delivery::client_message_id())
498 .await
499 }
500
501 pub async fn send_with_id(&self, chat: &str, body: &str, id: &str) -> Result<Value, AppError> {
503 let mut request = send_request(self.port, chat, body)?;
504 if !crate::delivery::valid_client_message_id(id) {
505 return Err(AppError::InvalidInput("invalid client message ID".into()));
506 }
507 request["client_message_id"] = json!(id);
508 request["account_type"] = json!(self.account);
509 #[cfg(any(target_os = "macos", target_os = "linux"))]
510 if self.kind.is_browser() {
511 let reply = self.browser.lock().await.send(&request).await?;
512 return self.send_result(&reply, chat, id);
513 }
514 platform()?;
515 #[cfg(any(windows, target_os = "macos"))]
516 let reply = self.session.lock().await.send(&request).await?;
517 #[cfg(not(any(windows, target_os = "macos")))]
518 let reply = self
519 .helper
520 .lock()
521 .await
522 .call_once(json!({"op": "send", "request": request}))
523 .await?;
524 self.send_result(&reply, chat, id)
525 }
526
527 fn send_result(&self, reply: &Value, chat: &str, id: &str) -> Result<Value, AppError> {
528 sent_message(reply, chat).map_err(|error| match error {
529 AppError::Unconfirmed(message) => AppError::UnconfirmedSend {
530 message,
531 chat_id: chat.into(),
532 client_message_id: id.into(),
533 },
534 other => other,
535 })
536 }
537
538 pub async fn delivery(&self, chat: &str, id: &str) -> Result<Value, AppError> {
540 check_chat(chat)?;
541 if !crate::delivery::valid_client_message_id(id) {
542 return Err(AppError::InvalidInput(
543 "client message ID must contain 18 digits".into(),
544 ));
545 }
546 let page = self.messages(chat, 50, None).await?;
547 Ok(crate::delivery::reconcile(chat, id, &page.items))
548 }
549
550 pub async fn send_check(&self) -> Result<(), AppError> {
552 #[cfg(any(target_os = "macos", target_os = "linux"))]
553 if self.kind.is_browser() {
554 let reply = self
555 .browser
556 .lock()
557 .await
558 .send_check(self.port, self.account)
559 .await?;
560 return check_send_reply(&reply);
561 }
562 platform()?;
563 #[cfg(any(windows, target_os = "macos"))]
564 let reply = self
565 .session
566 .lock()
567 .await
568 .send_check(self.port, self.account)
569 .await?;
570 #[cfg(not(any(windows, target_os = "macos")))]
571 let reply = self
572 .helper
573 .lock()
574 .await
575 .call(json!({"op": "send_check", "port": self.port, "account_type": self.account}))
576 .await?;
577 check_send_reply(&reply)
578 }
579}
580
581pub fn check_recipient(id: &str) -> Result<(), AppError> {
582 if id.starts_with("8:") && valid_id(id) && id.len() > 2 {
583 Ok(())
584 } else {
585 Err(AppError::InvalidInput("Select an exact recipient ID from `teams people search`; desktop recipient IDs start with 8:".into()))
586 }
587}
588
589fn people_result(reply: &Value, create: bool) -> Result<Value, AppError> {
590 let invalid = || {
591 if create {
592 AppError::Unconfirmed("Teams may have created the conversation; check Teams before trying again. No message was sent".into())
593 } else {
594 AppError::Desktop(
595 "Recipient search failed. Check the Teams connection and try again".into(),
596 )
597 }
598 };
599 let value = if reply["error"].is_string() {
600 reply.clone()
601 } else {
602 reply["result"]["result"]["value"]
603 .as_str()
604 .and_then(|s| serde_json::from_str::<Value>(s).ok())
605 .ok_or_else(invalid)?
606 };
607 if let Some(code) = value["error"].as_str() {
608 return Err(match code {
609 "account_ambiguous" => account_selection_error(),
610 "permission_denied" => AppError::Permission("Teams policy does not allow a chat with this recipient".into()),
611 "search_failed" => AppError::Desktop("Teams could not resolve this person. Check sign-in and try their full email address".into()),
612 "layout" => AppError::Unsupported("This Teams version does not expose a supported recipient interface".into()),
613 "unconfirmed" => invalid(),
614 _ => send_error(code),
615 });
616 }
617 Ok(value)
618}
619
620fn check_send_reply(reply: &Value) -> Result<(), AppError> {
621 refused(reply)?;
622 match reply["error"].as_str() {
623 None if reply["ok"] == true => Ok(()),
624 code => Err(send_error(code.unwrap_or_default())),
625 }
626}
627
628fn valid_id(id: &str) -> bool {
629 (1..=256).contains(&id.len())
630 && id
631 .bytes()
632 .all(|b| b.is_ascii_alphanumeric() || b"_.:@-".contains(&b))
633}
634
635fn read_request(
636 port: u16,
637 chat: Option<&str>,
638 limit: u16,
639 cursor: Option<&str>,
640) -> Result<Value, AppError> {
641 if let Some(chat) = chat {
642 check_chat(chat)?;
643 }
644 let after = cursor.map(decode_cursor).transpose()?;
645 Ok(json!({
646 "operation": "read",
647 "port": port,
648 "query": if chat.is_some() { "messages" } else { "chats" },
649 "chat_id": chat,
650 "limit": limit,
651 "after": after,
652 }))
653}
654
655fn chat_id(id: &str) -> bool {
657 id == NOTES || id.starts_with("19:") && valid_id(id)
658}
659
660const NOTES: &str = "48:notes";
661
662pub fn check_chat(chat: &str) -> Result<(), AppError> {
664 if chat_id(chat) {
665 Ok(())
666 } else {
667 Err(AppError::InvalidInput(
668 "desktop chat ids look like `19:...@thread.v2`, `19:...@unq.gbl.spaces` or `48:notes`"
669 .into(),
670 ))
671 }
672}
673
674fn valid_body(body: &str) -> bool {
676 !body.trim().is_empty() && body.chars().count() <= MAX_BODY && !body.contains('\0')
677}
678
679fn send_request(port: u16, chat: &str, body: &str) -> Result<Value, AppError> {
681 check_chat(chat)?;
682 if !valid_body(body) {
683 return Err(AppError::InvalidInput(format!(
684 "message body must be non-empty plain text of at most {MAX_BODY} characters"
685 )));
686 }
687 let id: u64 = rand::Rng::random_range(
688 &mut rand::rng(),
689 100_000_000_000_000_000..1_000_000_000_000_000_000,
690 );
691 Ok(json!({
692 "operation": "send",
693 "port": port,
694 "chat_id": chat,
695 "body": body,
696 "client_message_id": id.to_string(),
697 }))
698}
699
700fn refused(reply: &Value) -> Result<(), AppError> {
702 match reply
703 .get("connection")
704 .cloned()
705 .map(serde_json::from_value::<ConnectionMetadata>)
706 {
707 Some(Ok(metadata)) if reply.get("error").is_some() => {
708 Err(AppError::Desktop(metadata.detail()))
709 }
710 _ => Ok(()),
711 }
712}
713
714fn send_error(code: &str) -> AppError {
715 match code {
716 "not_found" => AppError::NotFound("chat not found in the Teams desktop cache".into()),
717 "account_ambiguous" => account_selection_error(),
718 "unconfirmed" => AppError::Unconfirmed(
719 "Teams did not confirm the message; it may still be delivered. Check the chat before sending again".into(),
720 ),
721 "layout" => AppError::Desktop(
722 "The Teams window's send interface was not recognised; this Teams version is not supported for sending yet".into(),
723 ),
724 "no_target" => AppError::Desktop(
725 "No Teams window is available on the debugging endpoint; open Teams and sign in.".into(),
726 ),
727 code => match serde_json::from_value::<ProbeStatus>(json!(code)) {
728 Ok(status) => AppError::Desktop(status.detail().into()),
729 Err(_) => AppError::Desktop(
730 "The message was not sent; the Teams page could not be reached. Run `teams doctor`".into(),
731 ),
732 },
733 }
734}
735
736fn sent_message(reply: &Value, chat: &str) -> Result<Value, AppError> {
738 refused(reply)?;
739 if let Some(code) = reply.get("error").and_then(Value::as_str) {
740 return Err(send_error(code));
741 }
742 let result = &reply["result"];
743 if let Some(details) = result.get("exceptionDetails") {
744 let layout = details["exception"]["description"]
747 .as_str()
748 .is_some_and(|d| d.starts_with("Error: layout"));
749 return Err(send_error(if layout { "layout" } else { "" }));
750 }
751 let value: Value = result["result"]["value"]
752 .as_str()
753 .and_then(|v| serde_json::from_str(v).ok())
754 .ok_or_else(|| send_error("unconfirmed"))?;
755 if let Some(code) = value["error"].as_str() {
756 return Err(send_error(code));
757 }
758 let id = value["id"]
759 .as_str()
760 .filter(|id| valid_id(id))
761 .ok_or_else(|| send_error("unconfirmed"))?;
762 let label = |key: &str| {
763 value[key]
764 .as_str()
765 .filter(|v| v.len() <= 64 && valid_id(v))
766 .map(String::from)
767 };
768 let arrival = &value["originalArrivalTime"];
769 let created = arrival
770 .as_i64()
771 .or_else(|| arrival.as_str().and_then(|t| t.parse().ok()))
772 .and_then(|ms| jiff::Timestamp::from_millisecond(ms).ok())
773 .or_else(|| arrival.as_str().and_then(|t| t.parse().ok()))
774 .map(|t| t.to_string());
775 let mut message = json!({
776 "id": id,
777 "chatId": chat,
778 "createdDateTime": created,
779 "webUrl": null,
780 "clientMessageId": label("clientMessageId"),
781 "status": label("status"),
782 "source": "desktop",
783 });
784 message["delivery_state"] = json!(crate::delivery::State::from_message(&message));
785 Ok(message)
786}
787
788fn encode_cursor(next: &Value) -> Result<String, AppError> {
790 let t = next.get("t").and_then(Value::as_i64);
791 let id = next.get("id").and_then(Value::as_str);
792 match (t, id) {
793 (Some(t), Some(id)) if valid_id(id) => {
794 Ok(URL_SAFE_NO_PAD.encode(json!({"t":t,"id":id}).to_string()))
795 }
796 _ => Err(AppError::Desktop(
797 "Teams cache returned an invalid page position".into(),
798 )),
799 }
800}
801
802fn decode_cursor(cursor: &str) -> Result<Value, AppError> {
803 let invalid = || AppError::InvalidInput("invalid desktop cursor".into());
804 let bytes = URL_SAFE_NO_PAD
805 .decode(cursor.as_bytes())
806 .map_err(|_| invalid())?;
807 let value: Value = serde_json::from_slice(&bytes).map_err(|_| invalid())?;
808 let t = value.get("t").and_then(Value::as_i64).ok_or_else(invalid)?;
809 let id = value
810 .get("id")
811 .and_then(Value::as_str)
812 .ok_or_else(invalid)?;
813 if t < 0 || !valid_id(id) {
814 return Err(invalid());
815 }
816 Ok(json!({"t":t,"id":id}))
817}
818
819fn read_page(reply: &Value) -> Result<Page, AppError> {
821 let invalid = || AppError::Desktop("the Teams page returned an invalid chat response".into());
822 refused(reply)?;
823 if let Some(code) = reply.get("error").and_then(Value::as_str) {
824 let status = serde_json::from_value::<ProbeStatus>(json!(code)).ok();
825 if code == "account_ambiguous" {
826 return Err(account_selection_error());
827 }
828 return Err(AppError::Desktop(match (status, code) {
829 (Some(status), _) => status.detail().into(),
830 (None, "no_target") => {
831 "No Teams window is available on the debugging endpoint; open Teams and sign in."
832 .into()
833 }
834 _ => "The Teams page could not be read over the debugging endpoint; run `teams doctor`"
835 .into(),
836 }));
837 }
838 let result = &reply["result"];
839 if let Some(details) = result.get("exceptionDetails") {
840 let layout = details["exception"]["description"]
841 .as_str()
842 .is_some_and(|d| d.starts_with("Error: layout"));
843 return Err(AppError::Desktop(if layout {
844 "The Teams cache layout was not recognised; this Teams version is not supported yet"
845 .into()
846 } else {
847 "The Teams page could not read its local cache; run `teams doctor`".into()
848 }));
849 }
850 let value: Value = result["result"]["value"]
851 .as_str()
852 .and_then(|v| serde_json::from_str(v).ok())
853 .ok_or_else(invalid)?;
854 if value["error"] == "not_found" {
855 return Err(AppError::NotFound(
856 "chat not found in the Teams desktop cache".into(),
857 ));
858 }
859 if value["error"] == "account_ambiguous" {
860 return Err(account_selection_error());
861 }
862 let items = value["items"].as_array().ok_or_else(invalid)?.clone();
863 let next_cursor = match &value["next"] {
864 Value::Null => None,
865 next => Some(encode_cursor(next)?),
866 };
867 Ok(Page {
868 items,
869 truncated: next_cursor.is_some(),
870 next_cursor,
871 next_offset: None,
872 source: Some("desktop_cache"),
873 viewer: value["me"]
874 .as_str()
875 .filter(|me| valid_id(me))
876 .map(String::from),
877 })
878}
879
880pub fn platform() -> Result<&'static str, AppError> {
881 if cfg!(target_os = "macos") {
882 Ok("macos_loopback")
883 } else if cfg!(windows) {
884 Ok("windows_native")
885 } else if cfg!(target_os = "linux")
886 && std::fs::read_to_string("/proc/sys/kernel/osrelease")
887 .unwrap_or_default()
888 .to_ascii_lowercase()
889 .contains("microsoft")
890 {
891 Ok("wsl_windows_helper")
892 } else {
893 Err(AppError::Unsupported(
894 "desktop diagnostics require macOS, Windows, or WSL with Windows executable interop"
895 .into(),
896 ))
897 }
898}
899
900#[derive(Debug, Serialize)]
903pub struct DebuggingSetup {
904 pub shell: &'static str,
906 pub open: String,
907 pub close: String,
908}
909
910impl DebuggingSetup {
911 pub fn new(port: u16) -> Option<Self> {
912 let shell = match platform().ok()? {
913 "wsl_windows_helper" => "wsl",
914 "windows_native" => "powershell",
915 "macos_loopback" => "zsh",
916 _ => return None,
917 };
918 Some(Self::for_shell(shell, port))
919 }
920
921 fn for_shell(shell: &'static str, port: u16) -> Self {
922 Self {
923 shell,
924 open: format!("teams desktop connect --port {port}"),
925 close: format!("teams desktop disconnect --port {port}"),
926 }
927 }
928
929 pub fn text(&self) -> String {
930 if self.shell == "bash" {
931 return format!(
932 "Sign in once, then continue with a hidden Teams browser:\n {}\nThe window closes automatically after sign-in is verified. Close the hidden browser:\n {}\nThe sign-in profile is retained. While its debugging port is open, local programs can read chats and send as you.",
933 self.open, self.close
934 );
935 }
936 let shell = match self.shell {
937 "wsl" => "this WSL shell",
938 "zsh" => "Terminal (zsh or bash)",
939 _ => "PowerShell",
940 };
941 format!(
942 "To open Teams' debugging port, run this in {shell}. It restarts Teams, which ends any call in progress:\n {}\nTo close it again:\n {}\nThe flags apply only to that Teams launch; other apps and your settings are unchanged. If setup is interrupted, run `teams desktop recover`. While the port is open, any program running as you can read your chats and send as you.",
943 self.open, self.close
944 )
945 }
946}
947
948pub async fn serve_helper() -> Result<(), AppError> {
950 #[cfg(windows)]
951 {
952 helper::serve(
953 tokio::io::BufReader::new(tokio::io::stdin()),
954 tokio::io::stdout(),
955 &mut session::Session::new(windows::WindowsSystem),
956 )
957 .await
958 }
959 #[cfg(not(windows))]
960 Err(AppError::Unsupported(
961 "the desktop helper runs only on Windows".into(),
962 ))
963}
964
965pub fn helper_check() -> Option<(&'static str, String)> {
968 #[cfg(not(windows))]
969 if platform().ok() == Some("wsl_windows_helper") {
970 return Some(match install::state() {
971 Ok(install::HelperState::Override(path)) if !path.is_file() => (
972 "fail",
973 format!(
974 "TEAMS_DESKTOP_HELPER points to {}, which does not exist",
975 path.display()
976 ),
977 ),
978 Ok(state @ install::HelperState::Missing(_)) => ("skip", state.describe()),
979 Ok(state) => ("pass", state.describe()),
980 Err(error) => ("fail", error.to_string()),
981 });
982 }
983 None
984}
985
986pub fn local_prerequisites() -> Result<&'static str, AppError> {
987 let transport = platform()?;
988 if transport == "macos_loopback" {
989 if !std::path::Path::new("/usr/sbin/lsof").is_file() {
990 return Err(AppError::Desktop(
991 "lsof is required for Teams listener ownership checks".into(),
992 ));
993 }
994 } else if transport == "wsl_windows_helper"
995 && !["WSLInterop", "WSLInterop-late"].iter().any(|name| {
996 std::path::Path::new("/proc/sys/fs/binfmt_misc")
997 .join(name)
998 .exists()
999 })
1000 {
1001 return Err(AppError::Desktop(
1002 "WSL cannot run Windows executables; enable interop in /etc/wsl.conf".into(),
1003 ));
1004 }
1005 Ok(transport)
1006}
1007
1008#[cfg(any(target_os = "macos", target_os = "linux", windows, test))]
1011async fn fill_metadata(metadata: &mut ConnectionMetadata) -> Vec<Value> {
1012 let Ok((version, targets)) = read_metadata(metadata.port).await else {
1013 metadata.status = ProbeStatus::MetadataProbeFailed;
1014 return vec![];
1015 };
1016 let text = |key: &str| {
1017 version
1018 .get(key)
1019 .and_then(Value::as_str)
1020 .unwrap_or_default()
1021 .to_string()
1022 };
1023 metadata.version = Some(BrowserVersion {
1024 browser: text("Browser"),
1025 protocol: text("Protocol-Version"),
1026 });
1027 metadata.browser_websocket_advertised = !text("webSocketDebuggerUrl").is_empty();
1028 metadata.targets = targets.iter().map(sanitize_target).collect();
1029 metadata.status = ProbeStatus::MetadataOk;
1030 targets
1031}
1032
1033#[cfg(any(target_os = "macos", target_os = "linux", windows, test))]
1034async fn read_metadata(port: u16) -> Result<(Value, Vec<Value>), AppError> {
1035 let http = reqwest::Client::builder()
1036 .no_proxy()
1037 .redirect(reqwest::redirect::Policy::none())
1038 .timeout(Duration::from_secs(5))
1039 .user_agent("TeamsCLI-DesktopProbe/0.1")
1040 .build()
1041 .map_err(|_| AppError::Desktop("could not create metadata reader".into()))?;
1042 async fn read(http: &reqwest::Client, port: u16, route: &str) -> Result<Value, AppError> {
1043 let mut response = http
1044 .get(format!("http://127.0.0.1:{port}{route}"))
1045 .send()
1046 .await
1047 .map_err(|_| AppError::Desktop("metadata endpoint is unavailable".into()))?;
1048 if response.status() != reqwest::StatusCode::OK {
1049 return Err(AppError::Desktop(
1050 "metadata endpoint returned an unexpected status".into(),
1051 ));
1052 }
1053 let mut bytes = Vec::new();
1054 while let Some(chunk) = response
1055 .chunk()
1056 .await
1057 .map_err(|_| AppError::Desktop("metadata response failed".into()))?
1058 {
1059 if bytes.len() + chunk.len() > MAX_METADATA {
1060 return Err(AppError::Desktop("metadata size limit exceeded".into()));
1061 }
1062 bytes.extend_from_slice(&chunk);
1063 }
1064 serde_json::from_slice(&bytes)
1065 .map_err(|_| AppError::Desktop("invalid endpoint metadata".into()))
1066 }
1067 let version = read(&http, port, "/json/version").await?;
1068 let targets = read(&http, port, "/json/list").await?;
1069 if !version.is_object() {
1070 return Err(AppError::Desktop("invalid browser metadata".into()));
1071 }
1072 Ok((
1073 version,
1074 targets
1075 .as_array()
1076 .ok_or_else(|| AppError::Desktop("invalid target list".into()))?
1077 .clone(),
1078 ))
1079}
1080
1081#[cfg(any(target_os = "macos", target_os = "linux", windows, test))]
1082fn sanitize_target(target: &Value) -> TargetMetadata {
1083 let uri = target
1084 .get("url")
1085 .and_then(Value::as_str)
1086 .and_then(|u| url::Url::parse(u).ok());
1087 let scheme = uri.as_ref().map(|u| u.scheme()).unwrap_or("");
1088 let host = uri.as_ref().and_then(|u| u.host_str()).unwrap_or("");
1089 let secure =
1090 scheme == "https" && uri.as_ref().and_then(|u| u.port_or_known_default()) == Some(443);
1091 let teams = secure
1092 && matches!(
1093 host,
1094 "teams.microsoft.com" | "teams.cloud.microsoft" | "teams.live.com"
1095 );
1096 let origin =
1097 if teams || (secure && matches!(host, "login.microsoftonline.com" | "login.live.com")) {
1098 format!("https://{host}")
1099 } else if matches!(scheme, "about" | "blob" | "file") {
1100 format!("{scheme}:")
1101 } else {
1102 "[other origin omitted]".into()
1103 };
1104 TargetMetadata {
1105 kind: target
1106 .get("type")
1107 .and_then(Value::as_str)
1108 .unwrap_or("other")
1109 .into(),
1110 url_scheme: scheme.into(),
1111 url_origin: origin,
1112 teams_origin: teams,
1113 websocket_advertised: target
1114 .get("webSocketDebuggerUrl")
1115 .and_then(Value::as_str)
1116 .is_some_and(|s| !s.is_empty()),
1117 }
1118}
1119
1120#[cfg(test)]
1121mod tests {
1122 use super::macos::{loopback_address, parse_listeners};
1123 use super::*;
1124 use wiremock::{
1125 Mock, MockServer, ResponseTemplate,
1126 matchers::{header, method, path},
1127 };
1128
1129 const CHAT: &str = "19:a@thread.v2";
1130
1131 fn page_reply(value: Value) -> Value {
1132 json!({"result": {"result": {"type": "string", "value": value.to_string()}}})
1133 }
1134
1135 #[test]
1136 fn debugging_setup_uses_managed_commands_on_every_shell() {
1137 for shell in ["wsl", "powershell", "zsh"] {
1138 let setup = DebuggingSetup::for_shell(shell, 9333);
1139 assert_eq!(setup.open, "teams desktop connect --port 9333");
1140 assert_eq!(setup.close, "teams desktop disconnect --port 9333");
1141 assert!(setup.text().contains("teams desktop recover"));
1142 assert!(setup.text().contains("any program running as you"));
1143 }
1144 }
1145
1146 #[test]
1152 fn the_protocol_is_pinned() {
1153 assert_eq!(helper::PROTOCOL, 4);
1154 let cursor = encode_cursor(&json!({"t": 1, "id": "m1"})).unwrap();
1155 assert_eq!(
1156 read_request(9222, Some(NOTES), 20, Some(&cursor)).unwrap(),
1157 json!({"operation": "read", "port": 9222, "query": "messages",
1158 "chat_id": NOTES, "limit": 20, "after": {"t": 1, "id": "m1"}})
1159 );
1160 assert_eq!(
1161 read_request(9222, None, 20, None).unwrap(),
1162 json!({"operation": "read", "port": 9222, "query": "chats",
1163 "chat_id": null, "limit": 20, "after": null})
1164 );
1165 let mut send = send_request(9222, NOTES, "hi").unwrap();
1166 assert!(
1167 send["client_message_id"]
1168 .as_str()
1169 .is_some_and(|id| id.len() == 18)
1170 );
1171 send["client_message_id"] = json!("<id>");
1172 assert_eq!(
1173 send,
1174 json!({"operation": "send", "port": 9222, "chat_id": NOTES,
1175 "body": "hi", "client_message_id": "<id>"})
1176 );
1177 let scripts = [
1178 include_str!("desktop.js"),
1179 include_str!("desktop_send.js"),
1180 include_str!("desktop_cache.js"),
1181 include_str!("desktop_people.js"),
1182 ]
1183 .map(|script| {
1184 use sha2::Digest;
1185 let digest = sha2::Sha256::digest(script.replace('\r', ""));
1187 digest[..6]
1188 .iter()
1189 .map(|b| format!("{b:02x}"))
1190 .collect::<String>()
1191 });
1192 assert_eq!(
1194 scripts,
1195 [
1196 "32ca0293d2d5",
1197 "baa0bc29c44b",
1198 "3e837cc21336",
1199 "3d628a24b464"
1200 ]
1201 );
1202 }
1203
1204 #[test]
1205 fn recipient_scripts_keep_search_readonly_and_creation_once_only() {
1206 let output = std::process::Command::new("node")
1207 .args(["--test", "tests/js/people.test.mjs"])
1208 .current_dir(env!("CARGO_MANIFEST_DIR"))
1209 .output()
1210 .expect("Node.js 20 or newer is required for desktop script tests");
1211 assert!(
1212 output.status.success(),
1213 "{}",
1214 String::from_utf8_lossy(&output.stdout)
1215 );
1216 }
1217
1218 #[test]
1219 fn recipient_replies_preserve_uncertainty_and_account_errors() {
1220 assert!(matches!(
1221 people_result(&json!({"error":"unconfirmed"}), true),
1222 Err(AppError::Unconfirmed(_))
1223 ));
1224 assert!(matches!(
1225 people_result(&json!({"result":{}}), true),
1226 Err(AppError::Unconfirmed(_))
1227 ));
1228 assert!(matches!(
1229 people_result(&page_reply(json!({"error":"permission_denied"})), true),
1230 Err(AppError::Permission(_))
1231 ));
1232 assert!(matches!(
1233 people_result(&page_reply(json!({"error":"account_ambiguous"})), false),
1234 Err(AppError::Desktop(_))
1235 ));
1236 for id in [
1237 "someone@example.invalid",
1238 "8:bad id",
1239 "8:",
1240 "8:bad/extra",
1241 "",
1242 ] {
1243 assert!(check_recipient(id).is_err());
1244 }
1245 assert!(check_recipient("8:live:.cid.012345").is_ok());
1246 }
1247
1248 #[test]
1249 fn send_requests_validate_and_carry_a_fresh_client_message_id() {
1250 let request = send_request(9222, CHAT, "hello\nthere").unwrap();
1251 assert_eq!(request["operation"], "send");
1252 assert_eq!(request["chat_id"], CHAT);
1253 assert_eq!(request["body"], "hello\nthere");
1254 let id = request["client_message_id"].as_str().unwrap();
1255 assert!(id.len() == 18 && id.bytes().all(|b| b.is_ascii_digit()));
1256 assert_ne!(
1257 send_request(9222, CHAT, "x").unwrap()["client_message_id"],
1258 request["client_message_id"]
1259 );
1260 for (chat, body) in [
1261 ("fixture", "hello"),
1262 ("19:a b@thread.v2", "hello"),
1263 ("48:notes2", "hello"),
1264 ("48:other", "hello"),
1265 (CHAT, ""),
1266 (CHAT, " \n\t"),
1267 (CHAT, "a\0b"),
1268 (CHAT, &"x".repeat(MAX_BODY + 1)),
1269 ] {
1270 assert!(
1271 matches!(
1272 send_request(9222, chat, body),
1273 Err(AppError::InvalidInput(_))
1274 ),
1275 "{chat:?} accepted"
1276 );
1277 }
1278 assert!(send_request(9222, CHAT, &"é".repeat(MAX_BODY)).is_ok());
1279 assert!(send_request(9222, NOTES, "hello").is_ok());
1280 }
1281
1282 #[test]
1283 fn sent_messages_map_to_the_graph_shape() {
1284 let reply = page_reply(
1285 json!({"id": "1760000000000", "clientMessageId": "123456789012345678",
1286 "originalArrivalTime": "1760000000000", "status": "Sent"}),
1287 );
1288 let message = sent_message(&reply, CHAT).unwrap();
1289 assert_eq!(message["id"], "1760000000000");
1290 assert_eq!(message["chatId"], CHAT);
1291 assert_eq!(message["createdDateTime"], "2025-10-09T08:53:20Z");
1292 assert_eq!(message["clientMessageId"], "123456789012345678");
1293 assert_eq!(message["status"], "Sent");
1294 assert_eq!(message["source"], "desktop");
1295 for arrival in [json!(1760000000000_i64), json!("2025-10-09T08:53:20Z")] {
1296 let reply = page_reply(json!({"id": "1", "originalArrivalTime": arrival}));
1297 assert_eq!(
1298 sent_message(&reply, CHAT).unwrap()["createdDateTime"],
1299 "2025-10-09T08:53:20Z"
1300 );
1301 }
1302 let reply = page_reply(json!({"id": "1", "status": "<b>not a label</b>"}));
1303 let message = sent_message(&reply, CHAT).unwrap();
1304 assert!(message["status"].is_null() && message["createdDateTime"].is_null());
1305 }
1306
1307 #[test]
1308 fn send_failures_say_whether_the_message_may_have_gone() {
1309 let unconfirmed = |error: AppError| error.to_string().contains("may still be delivered");
1310 for reply in [
1311 json!({"error": "unconfirmed"}),
1312 page_reply(json!({"error": "unconfirmed"})),
1313 page_reply(json!({"id": "bad id"})),
1314 page_reply(json!({})),
1315 json!({"result": {"result": {"type": "undefined"}}}),
1316 ] {
1317 assert!(
1318 unconfirmed(sent_message(&reply, CHAT).unwrap_err()),
1319 "{reply}"
1320 );
1321 }
1322 assert!(matches!(
1323 sent_message(&page_reply(json!({"error": "not_found"})), CHAT),
1324 Err(AppError::NotFound(_))
1325 ));
1326 let thrown =
1327 json!({"result": {"exceptionDetails": {"exception": {"description": "TypeError: x"}}}});
1328 let error = sent_message(&thrown, CHAT).unwrap_err();
1329 assert!(
1330 !unconfirmed(error)
1331 && sent_message(&thrown, CHAT)
1332 .unwrap_err()
1333 .to_string()
1334 .contains("not sent")
1335 );
1336 for code in [
1337 "layout",
1338 "no_target",
1339 "non_teams_listener_refused",
1340 "something_else",
1341 ] {
1342 let error = sent_message(&json!({"error": code}), CHAT).unwrap_err();
1343 assert!(
1344 matches!(error, AppError::Desktop(_)) && !unconfirmed(error),
1345 "{code}"
1346 );
1347 }
1348 }
1349
1350 #[test]
1351 fn read_requests_validate_chat_ids_and_cursors() {
1352 let request = read_request(9222, None, 5, None).unwrap();
1353 assert_eq!(request["query"], "chats");
1354 assert!(request["after"].is_null());
1355 let cursor = encode_cursor(&json!({"t": 42, "id": "19:a@thread.v2"})).unwrap();
1356 let request = read_request(9222, Some("19:a@thread.v2"), 5, Some(&cursor)).unwrap();
1357 assert_eq!(request["query"], "messages");
1358 assert_eq!(request["chat_id"], "19:a@thread.v2");
1359 assert_eq!(request["after"], json!({"t": 42, "id": "19:a@thread.v2"}));
1360 for chat in ["fixture", "19:a b", "19:a'); x", ""] {
1361 assert!(matches!(
1362 read_request(9222, Some(chat), 5, None),
1363 Err(AppError::InvalidInput(_))
1364 ));
1365 }
1366 let negative = URL_SAFE_NO_PAD.encode(r#"{"t":-1,"id":"x"}"#);
1367 for cursor in ["%%%", "e30", negative.as_str()] {
1368 assert!(matches!(
1369 read_request(9222, None, 5, Some(cursor)),
1370 Err(AppError::InvalidInput(_))
1371 ));
1372 }
1373 }
1374
1375 fn reply(value: Value) -> Value {
1376 json!({"result":{"result":{"type":"string","value":value.to_string()}}})
1377 }
1378
1379 #[test]
1380 fn read_pages_map_cache_results_and_failures() {
1381 let page = read_page(&reply(json!({
1382 "me": "0f0e-guid",
1383 "items": [{"id": "19:a@thread.v2", "topic": null}],
1384 "next": {"t": 7, "id": "19:a@thread.v2"}
1385 })))
1386 .unwrap();
1387 assert_eq!(page.items.len(), 1);
1388 assert_eq!(page.viewer.as_deref(), Some("0f0e-guid"));
1389 assert!(serde_json::to_value(&page).unwrap().get("viewer").is_none());
1390 assert!(page.truncated);
1391 assert_eq!(page.source, Some("desktop_cache"));
1392 assert_eq!(
1393 decode_cursor(page.next_cursor.as_deref().unwrap()).unwrap(),
1394 json!({"t": 7, "id": "19:a@thread.v2"})
1395 );
1396 let last = read_page(&reply(json!({"me": "a b", "items": [], "next": null}))).unwrap();
1397 assert!(!last.truncated && last.next_cursor.is_none() && last.viewer.is_none());
1398 assert!(matches!(
1399 read_page(&reply(json!({"error": "not_found"}))),
1400 Err(AppError::NotFound(_))
1401 ));
1402 let layout = json!({"result":{"exceptionDetails":{"exception":{"description":"Error: layout\n at x"}}}});
1403 for ambiguous in [
1404 json!({"error": "account_ambiguous"}),
1405 reply(json!({"error": "account_ambiguous"})),
1406 ] {
1407 assert!(
1408 matches!(read_page(&ambiguous), Err(AppError::Desktop(m)) if m.contains("--desktop-account"))
1409 );
1410 assert!(
1411 matches!(sent_message(&ambiguous, CHAT), Err(AppError::Desktop(m)) if m.contains("--desktop-account"))
1412 );
1413 }
1414 assert!(matches!(read_page(&layout), Err(AppError::Desktop(m)) if m.contains("layout")));
1415 for reply in [
1416 json!({"error": "no_target"}),
1417 json!({"error": "non_teams_listener_refused"}),
1418 json!({"error": "read_failed"}),
1419 json!("not an object"),
1420 reply(json!({"items": [], "next": {"t": 1, "id": "bad id"}})),
1421 ] {
1422 assert!(matches!(read_page(&reply), Err(AppError::Desktop(_))));
1423 }
1424 }
1425
1426 #[test]
1427 fn listener_parser_keeps_every_binding_and_refuses_non_loopback() {
1428 let listeners =
1429 parse_listeners("p12\nn127.0.0.1:9222\nn[::1]:9222\np13\nn*:9222\n").unwrap();
1430 assert_eq!(listeners.len(), 3);
1431 assert!(loopback_address(&listeners[0].1, 9222));
1432 assert!(loopback_address(&listeners[1].1, 9222));
1433 assert!(!loopback_address(&listeners[2].1, 9222));
1434 assert!(!loopback_address("127.0.0.1:19222", 9222));
1435 assert!(parse_listeners("n127.0.0.1:9222").is_err());
1436 }
1437
1438 #[test]
1439 fn origins_are_exact_and_private_metadata_is_removed() {
1440 let cases = [
1441 (
1442 "https://teams.microsoft.com/v2/?account=PRIVATE",
1443 true,
1444 "https://teams.microsoft.com",
1445 ),
1446 (
1447 "https://teams.cloud.microsoft/path",
1448 true,
1449 "https://teams.cloud.microsoft",
1450 ),
1451 (
1452 "https://teams.live.com/PRIVATE",
1453 true,
1454 "https://teams.live.com",
1455 ),
1456 (
1457 "https://teams.microsoft.com.evil.example/PRIVATE",
1458 false,
1459 "[other origin omitted]",
1460 ),
1461 (
1462 "https://teams.microsoft.com:444/PRIVATE",
1463 false,
1464 "[other origin omitted]",
1465 ),
1466 (
1467 "http://teams.microsoft.com/PRIVATE",
1468 false,
1469 "[other origin omitted]",
1470 ),
1471 ("file:///Users/PRIVATE/SECRET.html", false, "file:"),
1472 ("blob:https://private-tenant.example/SECRET", false, "blob:"),
1473 (
1474 "https://login.microsoftonline.com/PRIVATE",
1475 false,
1476 "https://login.microsoftonline.com",
1477 ),
1478 ];
1479 for (url, teams, origin) in cases {
1480 let target = sanitize_target(
1481 &json!({"type":"page","url":url,"title":"PRIVATE","id":"SECRET","webSocketDebuggerUrl":"ws://127.0.0.1/SECRET"}),
1482 );
1483 assert_eq!(target.teams_origin, teams);
1484 assert_eq!(target.url_origin, origin);
1485 let output = serde_json::to_string(&target).unwrap();
1486 assert!(!output.contains("PRIVATE") && !output.contains("SECRET"));
1487 }
1488 }
1489
1490 #[test]
1491 fn sign_out_means_web_pages_without_a_teams_page() {
1492 let metadata = |pages: &[(&str, &str)]| {
1493 let mut metadata = ConnectionMetadata::empty(9224);
1494 metadata.status = ProbeStatus::MetadataOk;
1495 metadata.loopback_only = Some(true);
1496 metadata.teams_owned = Some(true);
1497 metadata.targets = pages
1498 .iter()
1499 .map(|(kind, url)| sanitize_target(&json!({"type":kind,"url":url})))
1500 .collect();
1501 metadata
1502 };
1503 let login = ("page", "https://login.microsoftonline.com/common/oauth2");
1504 let federated = ("page", "https://adfs.example.com/adfs/ls/wia");
1505 let teams = ("page", "https://teams.cloud.microsoft/");
1506 let worker = ("service_worker", "https://teams.cloud.microsoft/sw.js");
1507 assert!(metadata(&[login, worker]).signed_out());
1508 assert!(metadata(&[federated]).signed_out());
1509 assert!(!metadata(&[teams, worker]).signed_out());
1510 assert!(!metadata(&[teams, login]).signed_out());
1511 assert!(!metadata(&[]).signed_out());
1513 assert!(!metadata(&[("page", "about:blank")]).signed_out());
1514 let mut refused = metadata(&[login]);
1515 refused.teams_owned = Some(false);
1516 assert!(!refused.signed_out());
1517 }
1518
1519 #[tokio::test]
1520 async fn metadata_reader_uses_only_the_two_read_routes_and_generic_user_agent() {
1521 let server = MockServer::start().await;
1522 Mock::given(method("GET"))
1523 .and(path("/json/version"))
1524 .and(header("User-Agent", "TeamsCLI-DesktopProbe/0.1"))
1525 .respond_with(
1526 ResponseTemplate::new(200)
1527 .set_body_json(json!({"Browser":"Edg/fixture","Protocol-Version":"1.3"})),
1528 )
1529 .expect(1)
1530 .mount(&server)
1531 .await;
1532 Mock::given(method("GET"))
1533 .and(path("/json/list"))
1534 .respond_with(
1535 ResponseTemplate::new(200)
1536 .set_body_json(json!([{"type":"page","url":"https://teams.live.com/PRIVATE"}])),
1537 )
1538 .expect(1)
1539 .mount(&server)
1540 .await;
1541 let (version, targets) = read_metadata(server.address().port()).await.unwrap();
1542 assert_eq!(version["Protocol-Version"], "1.3");
1543 assert_eq!(
1544 sanitize_target(&targets[0]).url_origin,
1545 "https://teams.live.com"
1546 );
1547 let mut connection = ConnectionMetadata::empty(server.address().port());
1548 connection.status = ProbeStatus::MetadataOk;
1549 assert!(!connection.endpoint_available());
1550 connection.loopback_only = Some(true);
1551 connection.teams_owned = Some(true);
1552 assert!(connection.endpoint_available());
1553 }
1554
1555 #[tokio::test]
1556 async fn metadata_reader_refuses_redirects_and_oversized_responses() {
1557 let server = MockServer::start().await;
1558 let sink = MockServer::start().await;
1559 Mock::given(method("GET"))
1560 .respond_with(ResponseTemplate::new(200))
1561 .expect(0)
1562 .mount(&sink)
1563 .await;
1564 Mock::given(path("/json/version"))
1565 .respond_with(ResponseTemplate::new(302).insert_header("Location", sink.uri()))
1566 .mount(&server)
1567 .await;
1568 assert!(read_metadata(server.address().port()).await.is_err());
1569 server.reset().await;
1570 Mock::given(path("/json/version"))
1571 .respond_with(ResponseTemplate::new(200).set_body_string("x".repeat(MAX_METADATA + 1)))
1572 .mount(&server)
1573 .await;
1574 assert!(read_metadata(server.address().port()).await.is_err());
1575 }
1576}