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