Skip to main content

teams_cli/desktop/
connection.rs

1//! Explicit, recoverable debugging launches. Never restarts Teams on process exit.
2use super::{DesktopClient, ProbeStatus};
3use crate::error::AppError;
4use base64::{Engine, engine::general_purpose::STANDARD};
5use serde_json::{Value, json};
6use std::{path::PathBuf, process::Stdio, time::Duration};
7use tokio::process::Command;
8
9#[derive(Debug, Clone, Copy, clap::ValueEnum)]
10pub enum Action {
11    Status,
12    Connect,
13    Disconnect,
14    Recover,
15}
16
17fn failure(code: &str) -> AppError {
18    let message = match code {
19        "busy" => "Another desktop connection command is running; try again shortly",
20        "different_port" => {
21            "Another port has a managed session; select that profile and disconnect or recover it first"
22        }
23        "already_managed" => {
24            "A saved desktop session needs attention; run `teams desktop recover`, then disconnect it before connecting again"
25        }
26        "existing_flags" => {
27            "The user-wide WebView2 setting already has remote-debugging flags, which open the port in every WebView2 app; run `teams desktop clear-flags` first"
28        }
29        "launcher_missing" => {
30            "Install Microsoft Teams, or enable its ms-teams.exe alias under Settings > Apps > Advanced app settings > App execution aliases, then retry"
31        }
32        "settings_changed" => {
33            "Debugging settings changed outside this CLI and were preserved; restore the prior settings manually before retrying recovery"
34        }
35        "not_managed" | "session_changed" => {
36            "This Teams process was not started by this managed session; it was left running. Quit Teams completely and relaunch it normally, then run `teams desktop disconnect` to clear the record"
37        }
38        "invalid_state" | "missing_state" => {
39            "The desktop recovery record is missing or invalid; no settings were changed. Close debugging manually using `teams doctor`"
40        }
41        _ => {
42            "Desktop connection operation failed; run `teams desktop recover`, then `teams doctor`"
43        }
44    };
45    AppError::Desktop(message.into())
46}
47
48/// Read installation and interop facts only; never runs the lifecycle script.
49pub async fn preflight() -> Result<(), AppError> {
50    super::local_prerequisites()?;
51    #[cfg(target_os = "macos")]
52    {
53        let system = PathBuf::from("/Applications/Microsoft Teams.app/Contents/MacOS/MSTeams");
54        let user = dirs::home_dir()
55            .map(|home| home.join("Applications/Microsoft Teams.app/Contents/MacOS/MSTeams"));
56        if system.is_file() || user.is_some_and(|path| path.is_file()) {
57            return Ok(());
58        }
59        Err(AppError::Desktop(
60            "Teams Desktop is not installed. Install Microsoft Teams, then run `teams init` again."
61                .into(),
62        ))
63    }
64    #[cfg(not(target_os = "macos"))]
65    {
66        let mut command = Command::new(powershell_path()?);
67        command.args(["-NoProfile", "-NonInteractive", "-Command",
68            "$ErrorActionPreference='Stop'; $p=Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'Microsoft\\WindowsApps\\ms-teams.exe'; if(Test-Path -LiteralPath $p){exit 0}else{exit 2}"])
69            .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).kill_on_drop(true);
70        let status = tokio::time::timeout(Duration::from_secs(5), command.status()).await
71            .map_err(|_| AppError::Desktop("Checking Teams Desktop timed out. Check Windows executable interop and retry `teams init`.".into()))??;
72        if !status.success() {
73            return Err(failure("launcher_missing"));
74        }
75        #[cfg(not(windows))]
76        if let super::install::HelperState::Override(path) = super::install::state()?
77            && !path.is_file()
78        {
79            return Err(AppError::Desktop("TEAMS_DESKTOP_HELPER does not exist; fix or unset the override and retry `teams init`.".into()));
80        }
81        Ok(())
82    }
83}
84
85/// Includes metadata only. An available endpoint does not establish sign-in.
86pub async fn manage(client: &DesktopClient, action: Action) -> Result<Value, AppError> {
87    manage_visible(client, action, false).await
88}
89
90pub async fn manage_visible(
91    client: &DesktopClient,
92    action: Action,
93    visible: bool,
94) -> Result<Value, AppError> {
95    #[cfg(any(target_os = "macos", target_os = "linux"))]
96    if client.kind.is_browser() {
97        return super::browser::manage(
98            client.port,
99            client.account,
100            action,
101            visible,
102            client.kind == crate::config::DesktopClientKind::BrowserCore,
103        )
104        .await;
105    }
106    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
107    let _ = visible;
108    super::platform()?;
109    let token = format!("{:032x}", rand::random::<u128>());
110    if matches!(action, Action::Recover) {
111        let state = local("recover", client.port, false, None).await?;
112        return Ok(
113            json!({"port":client.port,"endpoint_available":null,"session_verified":false,"management":state,"connection":null}),
114        );
115    }
116    let metadata = client.probe().await?;
117    if matches!(action, Action::Connect) && !metadata.endpoint_available() {
118        if metadata.status != ProbeStatus::NoListener {
119            return Err(AppError::Desktop(metadata.detail()));
120        }
121        // Even if starting or waiting fails, keep a recovery record so a killed
122        // process can recover on the next run.
123        let result = async {
124            local("connect", client.port, false, Some(&token)).await?;
125            for _ in 0..20 {
126                tokio::time::sleep(Duration::from_secs(1)).await;
127                let current = client.probe().await?;
128                if current.endpoint_available() {
129                    local("finish", client.port, false, Some(&token)).await?;
130                    return Ok(());
131                }
132                if current.status != ProbeStatus::NoListener {
133                    return Err(AppError::Desktop(current.detail()));
134                }
135            }
136            Err(AppError::Desktop("Teams has not opened its endpoint; run `teams desktop status` and `teams desktop recover` before trying again".into()))
137        }.await;
138        if let Err(error) = result {
139            if let Err(recovery) = local("recover", client.port, false, Some(&token)).await {
140                return Err(AppError::Desktop(format!("{error}. Recovery: {recovery}")));
141            }
142            return Err(error);
143        }
144    } else if matches!(action, Action::Disconnect) {
145        if !metadata.endpoint_available() && metadata.status != ProbeStatus::NoListener {
146            return Err(AppError::Desktop(metadata.detail()));
147        }
148        local(
149            "disconnect",
150            client.port,
151            metadata.endpoint_available(),
152            None,
153        )
154        .await?;
155    }
156    let state = local("status", client.port, false, None).await?;
157    let metadata = if matches!(action, Action::Status) {
158        metadata
159    } else {
160        client.probe().await?
161    };
162    Ok(
163        json!({"port":client.port,"endpoint_available":metadata.endpoint_available(),"session_verified":false,"management":state,"connection":metadata}),
164    )
165}
166
167/// Removes remote-debugging flags from the user-wide WebView2 arguments, keeping the
168/// rest. Apps already running keep their port until they are fully quit.
169pub async fn clear_flags(client: &DesktopClient) -> Result<Value, AppError> {
170    if cfg!(target_os = "macos") || client.kind.is_browser() {
171        return Err(AppError::Unsupported(
172            "clear-flags applies to the Teams desktop app on Windows or WSL".into(),
173        ));
174    }
175    super::platform()?;
176    let value = local("clear_flags", client.port, false, None).await?;
177    Ok(json!({"port":client.port,"cleared":value["cleared"] == true}))
178}
179
180async fn local(
181    action: &str,
182    port: u16,
183    restart: bool,
184    token: Option<&str>,
185) -> Result<Value, AppError> {
186    #[cfg(target_os = "macos")]
187    {
188        mac::manage(action, port, restart, token).await
189    }
190    #[cfg(not(target_os = "macos"))]
191    {
192        powershell(action, port, restart, token).await
193    }
194}
195
196#[cfg_attr(target_os = "macos", allow(dead_code))]
197fn powershell_path() -> Result<PathBuf, AppError> {
198    let mut candidates = Vec::new();
199    #[cfg(windows)]
200    if let Some(root) = std::env::var_os("SystemRoot") {
201        candidates.push(PathBuf::from(root).join("System32/WindowsPowerShell/v1.0/powershell.exe"));
202    }
203    candidates.push(PathBuf::from(
204        "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe",
205    ));
206    if let Some(path) = std::env::var_os("PATH") {
207        candidates.extend(std::env::split_paths(&path).map(|dir| dir.join("powershell.exe")));
208    }
209    candidates.into_iter().find(|path| path.is_file()).ok_or_else(|| AppError::Desktop("Windows PowerShell was not found; enable WSL Windows executable interop or add its WindowsPowerShell/v1.0 directory to PATH".into()))
210}
211
212#[cfg_attr(target_os = "macos", allow(dead_code))]
213async fn powershell(
214    action: &str,
215    port: u16,
216    restart: bool,
217    token: Option<&str>,
218) -> Result<Value, AppError> {
219    let request = json!({"action":action,"port":port,"restart":restart,"token":token});
220    let script = format!(
221        "$request = ConvertFrom-Json '{request}';\n{}",
222        include_str!("connection.ps1")
223    );
224    let encoded = STANDARD.encode(
225        script
226            .encode_utf16()
227            .flat_map(u16::to_le_bytes)
228            .collect::<Vec<_>>(),
229    );
230    let mut command = Command::new(powershell_path()?);
231    command.args(["-NoProfile", "-NonInteractive", "-EncodedCommand", &encoded]);
232    let bytes = run(command).await?;
233    let value: Value = serde_json::from_slice(&bytes).map_err(|_| failure("operation_failed"))?;
234    if let Some(code) = value["error"].as_str() {
235        return Err(failure(code));
236    }
237    Ok(value)
238}
239
240async fn run(mut command: Command) -> Result<Vec<u8>, AppError> {
241    command
242        .stdin(Stdio::null())
243        .stdout(Stdio::piped())
244        .stderr(Stdio::null())
245        .kill_on_drop(true);
246    let output = tokio::time::timeout(Duration::from_secs(30), command.output())
247        .await
248        .map_err(|_| {
249            AppError::Desktop(
250                "Desktop connection operation timed out; run `teams desktop recover`".into(),
251            )
252        })?
253        .map_err(|_| failure("operation_failed"))?;
254    if !output.status.success() || output.stdout.len() > 1024 * 1024 {
255        return Err(failure("operation_failed"));
256    }
257    Ok(output.stdout)
258}
259
260#[cfg(target_os = "macos")]
261mod mac {
262    use super::*;
263    use serde::{Deserialize, Serialize};
264    use std::{
265        fs::{File, OpenOptions},
266        io::Write,
267        os::unix::fs::OpenOptionsExt,
268    };
269
270    #[derive(Serialize, Deserialize)]
271    struct State {
272        version: u8,
273        port: u16,
274        phase: String,
275        token: String,
276        previous: Option<String>,
277        owners: Vec<String>,
278    }
279
280    struct LockGuard(File);
281
282    impl Drop for LockGuard {
283        fn drop(&mut self) {
284            // Another thread can spawn a child while this descriptor is open.
285            // Unlock explicitly so an inherited descriptor cannot extend the lock
286            // until that child reaches exec and closes its copy.
287            let _ = self.0.unlock();
288        }
289    }
290
291    fn save(path: &std::path::Path, state: &State) -> Result<(), AppError> {
292        let mut file = tempfile::NamedTempFile::new_in(path.parent().expect("state directory"))?;
293        file.write_all(&serde_json::to_vec(state).map_err(|_| failure("invalid_state"))?)?;
294        file.as_file().sync_all()?;
295        file.persist(path).map_err(|e| e.error)?;
296        Ok(())
297    }
298    async fn owners() -> Result<Vec<String>, AppError> {
299        let mut command = Command::new("/bin/ps");
300        command.args(["-axo", "pid=,lstart=,comm="]);
301        let text =
302            String::from_utf8(run(command).await?).map_err(|_| failure("operation_failed"))?;
303        Ok(text
304            .lines()
305            .filter(|line| {
306                line.trim_end()
307                    .ends_with("/Microsoft Teams.app/Contents/MacOS/MSTeams")
308            })
309            .map(|s| s.trim().to_string())
310            .collect())
311    }
312    async fn launch(value: &str) -> Result<(), AppError> {
313        let mut quit = Command::new("/usr/bin/osascript");
314        quit.args(["-e", "tell application id \"com.microsoft.teams2\" to quit"]);
315        run(quit).await?;
316        tokio::time::sleep(Duration::from_secs(2)).await;
317        let mut open = Command::new("/usr/bin/open");
318        open.args([
319            "-b",
320            "com.microsoft.teams2",
321            "--env",
322            &format!("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS={value}"),
323        ]);
324        run(open).await?;
325        Ok(())
326    }
327    pub async fn manage(
328        action: &str,
329        port: u16,
330        restart: bool,
331        token: Option<&str>,
332    ) -> Result<Value, AppError> {
333        let dir = dirs::data_local_dir()
334            .ok_or_else(|| failure("invalid_state"))?
335            .join("teams-cli");
336        manage_at(&dir, action, port, restart, token).await
337    }
338    async fn manage_at(
339        dir: &std::path::Path,
340        action: &str,
341        port: u16,
342        restart: bool,
343        token: Option<&str>,
344    ) -> Result<Value, AppError> {
345        std::fs::create_dir_all(dir)?;
346        let lock: File = OpenOptions::new()
347            .create(true)
348            .truncate(false)
349            .read(true)
350            .write(true)
351            .mode(0o600)
352            .open(dir.join("desktop-connection.lock"))?;
353        lock.try_lock().map_err(|_| failure("busy"))?;
354        let _lock = LockGuard(lock);
355        let path = dir.join("desktop-connection.json");
356        let mut state: Option<State> = match std::fs::read(&path) {
357            Ok(bytes) => {
358                Some(serde_json::from_slice(&bytes).map_err(|_| failure("invalid_state"))?)
359            }
360            Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
361            Err(e) => return Err(e.into()),
362        };
363        if state
364            .as_ref()
365            .is_some_and(|s| s.version != 1 || s.port < 1024)
366        {
367            return Err(failure("invalid_state"));
368        }
369        if action != "status" && state.as_ref().is_some_and(|s| s.port != port) {
370            return Err(failure("different_port"));
371        }
372        if token.is_some()
373            && state
374                .as_ref()
375                .is_some_and(|s| Some(s.token.as_str()) != token)
376        {
377            return Err(failure("busy"));
378        }
379        match action {
380            "connect" => {
381                if state.is_some() {
382                    return Err(failure("already_managed"));
383                }
384                let previous = std::env::var("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS").ok();
385                if previous
386                    .as_ref()
387                    .is_some_and(|s| s.contains("--remote-debugging-"))
388                {
389                    return Err(failure("existing_flags"));
390                }
391                let next = State {
392                    version: 1,
393                    port,
394                    phase: "starting".into(),
395                    token: token.unwrap_or_default().into(),
396                    previous,
397                    owners: vec![],
398                };
399                save(&path, &next)?;
400                launch(&format!(
401                    "{} --remote-debugging-port={port} --remote-debugging-address=127.0.0.1",
402                    next.previous.as_deref().unwrap_or_default()
403                ))
404                .await?;
405                state = Some(next);
406            }
407            "finish" => {
408                let current = state.as_mut().ok_or_else(|| failure("missing_state"))?;
409                if current.phase != "starting" {
410                    return Err(failure("busy"));
411                }
412                current.owners = owners().await?;
413                if current.owners.is_empty() {
414                    return Err(failure("session_changed"));
415                }
416                current.phase = "active".into();
417                save(&path, current)?;
418            }
419            "disconnect" => {
420                let Some(current) = state.as_ref() else {
421                    if restart {
422                        return Err(failure("not_managed"));
423                    }
424                    return Ok(
425                        json!({"managed":false,"port":null,"phase":"idle","settings_pending":false,"needs_recovery":false}),
426                    );
427                };
428                if current.phase == "starting" {
429                    return Err(failure("busy"));
430                }
431                if restart {
432                    if current.owners.is_empty() || current.owners != owners().await? {
433                        return Err(failure("session_changed"));
434                    }
435                    launch(current.previous.as_deref().unwrap_or_default()).await?;
436                }
437                std::fs::remove_file(&path)?;
438                state = None;
439            }
440            "recover" => {
441                if let Some(current) = state.as_mut()
442                    && current.phase == "starting"
443                {
444                    current.phase = "interrupted".into();
445                    save(&path, current)?;
446                }
447            }
448            "status" => {}
449            _ => return Err(failure("invalid_action")),
450        }
451        Ok(
452            json!({"managed":state.is_some(),"port":state.as_ref().map(|s|s.port),"phase":state.as_ref().map(|s|s.phase.as_str()).unwrap_or("idle"),"settings_pending":false,"needs_recovery":state.as_ref().is_some_and(|s|s.phase != "active")}),
453        )
454    }
455    #[cfg(test)]
456    mod tests {
457        use super::*;
458        fn interrupted(port: u16) -> State {
459            State {
460                version: 1,
461                port,
462                phase: "starting".into(),
463                token: "first".into(),
464                previous: None,
465                owners: vec![],
466            }
467        }
468        #[tokio::test]
469        async fn inherited_descriptors_do_not_extend_the_operation_lock() {
470            let temp = tempfile::tempdir().unwrap();
471            let path = temp.path().join("desktop-connection.lock");
472            let file = OpenOptions::new()
473                .create(true)
474                .truncate(false)
475                .read(true)
476                .write(true)
477                .open(&path)
478                .unwrap();
479            file.try_lock().unwrap();
480            let inherited = file.try_clone().unwrap();
481            drop(LockGuard(file));
482            let next = OpenOptions::new()
483                .read(true)
484                .write(true)
485                .open(&path)
486                .unwrap();
487            next.try_lock()
488                .expect("the operation ended even while another descriptor survives");
489            next.unlock().unwrap();
490            drop(inherited);
491        }
492        #[tokio::test]
493        async fn disconnect_is_idempotent_but_never_restarts_an_unmanaged_process() {
494            let temp = tempfile::tempdir().unwrap();
495            assert_eq!(
496                manage_at(temp.path(), "disconnect", 19222, false, None)
497                    .await
498                    .unwrap()["managed"],
499                false
500            );
501            assert!(
502                manage_at(temp.path(), "disconnect", 19222, true, None)
503                    .await
504                    .is_err()
505            );
506            assert!(!temp.path().join("desktop-connection.json").exists());
507        }
508        #[tokio::test]
509        async fn recovery_is_local_and_does_not_adopt_an_unrecorded_process() {
510            let temp = tempfile::tempdir().unwrap();
511            let path = temp.path().join("desktop-connection.json");
512            save(&path, &interrupted(19222)).unwrap();
513            let result = manage_at(temp.path(), "recover", 19222, false, None)
514                .await
515                .unwrap();
516            assert_eq!(result["phase"], "interrupted");
517            assert_eq!(result["settings_pending"], false);
518            assert!(
519                manage_at(temp.path(), "disconnect", 19222, true, None)
520                    .await
521                    .is_err()
522            );
523            assert!(path.exists());
524            assert_eq!(
525                manage_at(temp.path(), "disconnect", 19222, false, None)
526                    .await
527                    .unwrap()["managed"],
528                false
529            );
530        }
531        #[tokio::test]
532        async fn competing_owner_wrong_port_and_corrupt_records_are_preserved() {
533            let temp = tempfile::tempdir().unwrap();
534            let path = temp.path().join("desktop-connection.json");
535            save(&path, &interrupted(19222)).unwrap();
536            let original = std::fs::read(&path).unwrap();
537            assert!(
538                manage_at(temp.path(), "recover", 19222, false, Some("second"))
539                    .await
540                    .is_err()
541            );
542            assert!(
543                manage_at(temp.path(), "recover", 19223, false, None)
544                    .await
545                    .is_err()
546            );
547            assert!(
548                manage_at(temp.path(), "disconnect", 19222, false, None)
549                    .await
550                    .is_err()
551            );
552            assert_eq!(std::fs::read(&path).unwrap(), original);
553            std::fs::write(&path, b"not-json").unwrap();
554            assert!(
555                manage_at(temp.path(), "recover", 19222, false, None)
556                    .await
557                    .is_err()
558            );
559            assert_eq!(std::fs::read(&path).unwrap(), b"not-json");
560        }
561        #[tokio::test]
562        async fn lock_contention_never_changes_a_record() {
563            let temp = tempfile::tempdir().unwrap();
564            let path = temp.path().join("desktop-connection.json");
565            save(&path, &interrupted(19222)).unwrap();
566            let original = std::fs::read(&path).unwrap();
567            let lock = File::create(temp.path().join("desktop-connection.lock")).unwrap();
568            lock.try_lock().unwrap();
569            assert!(
570                manage_at(temp.path(), "recover", 19222, false, None)
571                    .await
572                    .is_err()
573            );
574            assert_eq!(std::fs::read(&path).unwrap(), original);
575        }
576    }
577}