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