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