Skip to main content

teams_cli/setup/
capabilities.rs

1use crate::config::DesktopClientKind;
2use crate::{desktop, error::AppError};
3
4#[derive(Clone, Debug)]
5pub enum Availability {
6    Ready { download: bool },
7    Blocked(String),
8}
9
10impl Availability {
11    fn from_check(result: Result<bool, AppError>) -> Self {
12        match result {
13            Ok(download) => Self::Ready { download },
14            Err(error) => Self::Blocked(error.to_string()),
15        }
16    }
17
18    pub fn reason(&self) -> Option<&str> {
19        match self {
20            Self::Ready { .. } => None,
21            Self::Blocked(reason) => Some(reason),
22        }
23    }
24}
25
26#[derive(Clone, Debug)]
27pub struct Capabilities {
28    pub graph: Availability,
29    pub pending: Vec<crate::config::PendingBrowserSetup>,
30    pub browser: Availability,
31    pub core: Availability,
32    pub native: Availability,
33    pub graph_device_code: bool,
34}
35
36impl Capabilities {
37    pub async fn detect() -> Self {
38        #[cfg(any(target_os = "macos", target_os = "linux"))]
39        let (browser, core, native) = tokio::join!(
40            desktop::browser::preflight(false),
41            desktop::browser::preflight(true),
42            desktop::connection::preflight(),
43        );
44        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
45        let (browser, core, native) = {
46            let unavailable = || {
47                Err(AppError::Unsupported(
48                "Browser mode needs macOS, Linux or WSL2. On Windows, run `teams init` inside WSL2.".into()))
49            };
50            (
51                unavailable(),
52                unavailable(),
53                desktop::connection::preflight().await,
54            )
55        };
56        #[allow(unused_mut)] // Only Unix has pending browser setup records.
57        let mut detected = Self {
58            graph: graph_availability(|key| std::env::var_os(key)),
59            pending: Vec::new(),
60            browser: Availability::from_check(browser),
61            core: Availability::from_check(core),
62            native: Availability::from_check(native.map(|()| false)),
63            graph_device_code: graph_device_code_needed(),
64        };
65        #[cfg(any(target_os = "macos", target_os = "linux"))]
66        match desktop::browser_setup::list() {
67            Ok(pending) => detected.pending = pending,
68            Err(error) => {
69                let reason = format!(
70                    "{error} Check the browser setup records before retrying; no saved sign-in was changed."
71                );
72                detected.browser = Availability::Blocked(reason.clone());
73                detected.core = Availability::Blocked(reason);
74            }
75        }
76        detected
77    }
78
79    pub fn availability(&self, kind: DesktopClientKind) -> &Availability {
80        match kind {
81            DesktopClientKind::Browser => &self.browser,
82            DesktopClientKind::BrowserCore => &self.core,
83            DesktopClientKind::Native => &self.native,
84        }
85    }
86
87    pub fn preferred(&self, personal: bool) -> Option<DesktopClientKind> {
88        use DesktopClientKind::{Browser, BrowserCore, Native};
89        let preference: &[_] = if personal {
90            &[BrowserCore, Browser, Native]
91        } else {
92            &[Browser, Native]
93        };
94        preference
95            .iter()
96            .copied()
97            .find(|kind| self.availability(*kind).reason().is_none())
98    }
99}
100
101pub fn graph_device_code_needed() -> bool {
102    cfg!(target_os = "linux")
103        && !["DISPLAY", "WAYLAND_DISPLAY"]
104            .iter()
105            .any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty()))
106}
107
108fn graph_availability(get: impl Fn(&str) -> Option<std::ffi::OsString>) -> Availability {
109    let blocked = if get("TEAMS_ACCESS_TOKEN").is_some() {
110        Some(
111            "TEAMS_ACCESS_TOKEN overrides browser sign-in. Unset it before guided Graph setup, or use explicit init flags for automation.",
112        )
113    } else if get("TEAMS_READ_ONLY").is_some_and(|value| {
114        matches!(
115            value
116                .to_str()
117                .map(str::trim)
118                .map(str::to_ascii_lowercase)
119                .as_deref(),
120            Some("false" | "0" | "no" | "off")
121        )
122    }) {
123        Some(
124            "TEAMS_READ_ONLY=false overrides Graph's saved read-only setting. Unset it before guided Graph setup, or use explicit init flags.",
125        )
126    } else if get("TEAMS_CLIENT_ID").is_some() {
127        Some(
128            "TEAMS_CLIENT_ID overrides saved Graph application settings. Unset it before guided setup, or use explicit init flags for a custom application.",
129        )
130    } else if get("TEAMS_TENANT").is_some() {
131        Some(
132            "TEAMS_TENANT overrides saved Graph tenant settings. Unset it before guided setup, or use explicit init flags for a custom tenant.",
133        )
134    } else {
135        None
136    };
137    match blocked {
138        Some(reason) => Availability::Blocked(reason.into()),
139        None => Availability::Ready { download: false },
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn guided_graph_excludes_overrides_that_could_verify_another_session_or_unlock_reads() {
149        for (key, value) in [
150            ("TEAMS_ACCESS_TOKEN", "synthetic-token"),
151            ("TEAMS_ACCESS_TOKEN", ""),
152            ("TEAMS_READ_ONLY", "false"),
153            ("TEAMS_READ_ONLY", " OFF "),
154            ("TEAMS_CLIENT_ID", " "),
155            ("TEAMS_CLIENT_ID", "synthetic-app"),
156            ("TEAMS_TENANT", ""),
157            ("TEAMS_TENANT", "synthetic-tenant"),
158        ] {
159            let availability = graph_availability(|name| (name == key).then(|| value.into()));
160            assert!(availability.reason().unwrap().contains(key));
161            if value.starts_with("synthetic-") {
162                assert!(!availability.reason().unwrap().contains(value));
163            }
164        }
165        for value in [None, Some("true")] {
166            assert!(
167                graph_availability(|name| {
168                    (name == "TEAMS_READ_ONLY")
169                        .then_some(value)
170                        .flatten()
171                        .map(Into::into)
172                })
173                .reason()
174                .is_none()
175            );
176        }
177    }
178}