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