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