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