Skip to main content

teams_cli/
desktop.rs

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