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