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