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            "Existing remote-debugging flags were preserved; close that debugging session before using managed connect"
28        }
29        "settings_changed" => {
30            "Debugging settings changed outside this CLI and were preserved; restore the prior settings manually before retrying recovery"
31        }
32        "not_managed" | "session_changed" => {
33            "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"
34        }
35        "invalid_state" | "missing_state" => {
36            "The desktop recovery record is missing or invalid; no settings were changed. Close debugging manually using `teams doctor`"
37        }
38        _ => {
39            "Desktop connection operation failed; run `teams desktop recover` to restore interrupted setup settings, then `teams doctor`"
40        }
41    };
42    AppError::Desktop(message.into())
43}
44
45/// Includes metadata only. An available endpoint does not establish sign-in.
46pub async fn manage(client: &DesktopClient, action: Action) -> Result<Value, AppError> {
47    super::platform()?;
48    let token = format!("{:032x}", rand::random::<u128>());
49    if matches!(action, Action::Recover) {
50        let state = local("recover", client.port, false, None).await?;
51        return Ok(
52            json!({"port":client.port,"endpoint_available":null,"session_verified":false,"management":state,"connection":null}),
53        );
54    }
55    let metadata = client.probe().await?;
56    if matches!(action, Action::Connect) && !metadata.endpoint_available() {
57        if metadata.status != ProbeStatus::NoListener {
58            return Err(AppError::Desktop(metadata.status.detail().into()));
59        }
60        // Even if starting or waiting fails, preserve a recovery record and try to
61        // restore the temporary user variable. A killed process can recover next run.
62        let result = async {
63            local("connect", client.port, false, Some(&token)).await?;
64            for _ in 0..20 {
65                tokio::time::sleep(Duration::from_secs(1)).await;
66                let current = client.probe().await?;
67                if current.endpoint_available() {
68                    local("finish", client.port, false, Some(&token)).await?;
69                    return Ok(());
70                }
71                if current.status != ProbeStatus::NoListener {
72                    return Err(AppError::Desktop(current.status.detail().into()));
73                }
74            }
75            Err(AppError::Desktop("Teams has not opened its endpoint; run `teams desktop status` and `teams desktop recover` before trying again".into()))
76        }.await;
77        if let Err(error) = result {
78            if let Err(recovery) = local("recover", client.port, false, Some(&token)).await {
79                return Err(AppError::Desktop(format!("{error}. Recovery: {recovery}")));
80            }
81            return Err(error);
82        }
83    } else if matches!(action, Action::Disconnect) {
84        if !metadata.endpoint_available() && metadata.status != ProbeStatus::NoListener {
85            return Err(AppError::Desktop(metadata.status.detail().into()));
86        }
87        local(
88            "disconnect",
89            client.port,
90            metadata.endpoint_available(),
91            None,
92        )
93        .await?;
94    }
95    let state = local("status", client.port, false, None).await?;
96    let metadata = if matches!(action, Action::Status) {
97        metadata
98    } else {
99        client.probe().await?
100    };
101    Ok(
102        json!({"port":client.port,"endpoint_available":metadata.endpoint_available(),"session_verified":false,"management":state,"connection":metadata}),
103    )
104}
105
106async fn local(
107    action: &str,
108    port: u16,
109    restart: bool,
110    token: Option<&str>,
111) -> Result<Value, AppError> {
112    #[cfg(target_os = "macos")]
113    {
114        mac::manage(action, port, restart, token).await
115    }
116    #[cfg(not(target_os = "macos"))]
117    {
118        powershell(action, port, restart, token).await
119    }
120}
121
122#[cfg_attr(target_os = "macos", allow(dead_code))]
123fn powershell_path() -> Result<PathBuf, AppError> {
124    let mut candidates = Vec::new();
125    #[cfg(windows)]
126    if let Some(root) = std::env::var_os("SystemRoot") {
127        candidates.push(PathBuf::from(root).join("System32/WindowsPowerShell/v1.0/powershell.exe"));
128    }
129    candidates.push(PathBuf::from(
130        "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe",
131    ));
132    if let Some(path) = std::env::var_os("PATH") {
133        candidates.extend(std::env::split_paths(&path).map(|dir| dir.join("powershell.exe")));
134    }
135    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()))
136}
137
138#[cfg_attr(target_os = "macos", allow(dead_code))]
139async fn powershell(
140    action: &str,
141    port: u16,
142    restart: bool,
143    token: Option<&str>,
144) -> Result<Value, AppError> {
145    let request = json!({"action":action,"port":port,"restart":restart,"token":token});
146    let script = format!(
147        "$request = ConvertFrom-Json '{request}';\n{}",
148        include_str!("connection.ps1")
149    );
150    let encoded = STANDARD.encode(
151        script
152            .encode_utf16()
153            .flat_map(u16::to_le_bytes)
154            .collect::<Vec<_>>(),
155    );
156    let mut command = Command::new(powershell_path()?);
157    command.args(["-NoProfile", "-NonInteractive", "-EncodedCommand", &encoded]);
158    let bytes = run(command).await?;
159    let value: Value = serde_json::from_slice(&bytes).map_err(|_| failure("operation_failed"))?;
160    if let Some(code) = value["error"].as_str() {
161        return Err(failure(code));
162    }
163    Ok(value)
164}
165
166async fn run(mut command: Command) -> Result<Vec<u8>, AppError> {
167    command
168        .stdin(Stdio::null())
169        .stdout(Stdio::piped())
170        .stderr(Stdio::null())
171        .kill_on_drop(true);
172    let output = tokio::time::timeout(Duration::from_secs(30), command.output())
173        .await
174        .map_err(|_| {
175            AppError::Desktop(
176                "Desktop connection operation timed out; run `teams desktop recover`".into(),
177            )
178        })?
179        .map_err(|_| failure("operation_failed"))?;
180    if !output.status.success() || output.stdout.len() > 1024 * 1024 {
181        return Err(failure("operation_failed"));
182    }
183    Ok(output.stdout)
184}
185
186#[cfg(target_os = "macos")]
187mod mac {
188    use super::*;
189    use serde::{Deserialize, Serialize};
190    use std::{
191        fs::{File, OpenOptions},
192        io::Write,
193        os::unix::fs::OpenOptionsExt,
194    };
195
196    #[derive(Serialize, Deserialize)]
197    struct State {
198        version: u8,
199        port: u16,
200        phase: String,
201        token: String,
202        previous: Option<String>,
203        owners: Vec<String>,
204    }
205
206    struct LockGuard(File);
207
208    impl Drop for LockGuard {
209        fn drop(&mut self) {
210            // Another thread can spawn a child while this descriptor is open.
211            // Unlock explicitly so an inherited descriptor cannot extend the lock
212            // until that child reaches exec and closes its copy.
213            let _ = self.0.unlock();
214        }
215    }
216
217    fn save(path: &std::path::Path, state: &State) -> Result<(), AppError> {
218        let mut file = tempfile::NamedTempFile::new_in(path.parent().expect("state directory"))?;
219        file.write_all(&serde_json::to_vec(state).map_err(|_| failure("invalid_state"))?)?;
220        file.as_file().sync_all()?;
221        file.persist(path).map_err(|e| e.error)?;
222        Ok(())
223    }
224    async fn owners() -> Result<Vec<String>, AppError> {
225        let mut command = Command::new("/bin/ps");
226        command.args(["-axo", "pid=,lstart=,comm="]);
227        let text =
228            String::from_utf8(run(command).await?).map_err(|_| failure("operation_failed"))?;
229        Ok(text
230            .lines()
231            .filter(|line| {
232                line.trim_end()
233                    .ends_with("/Microsoft Teams.app/Contents/MacOS/MSTeams")
234            })
235            .map(|s| s.trim().to_string())
236            .collect())
237    }
238    async fn launch(value: &str) -> Result<(), AppError> {
239        let mut quit = Command::new("/usr/bin/osascript");
240        quit.args(["-e", "tell application id \"com.microsoft.teams2\" to quit"]);
241        run(quit).await?;
242        tokio::time::sleep(Duration::from_secs(2)).await;
243        let mut open = Command::new("/usr/bin/open");
244        open.args([
245            "-b",
246            "com.microsoft.teams2",
247            "--env",
248            &format!("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS={value}"),
249        ]);
250        run(open).await?;
251        Ok(())
252    }
253    pub async fn manage(
254        action: &str,
255        port: u16,
256        restart: bool,
257        token: Option<&str>,
258    ) -> Result<Value, AppError> {
259        let dir = dirs::data_local_dir()
260            .ok_or_else(|| failure("invalid_state"))?
261            .join("teams-cli");
262        manage_at(&dir, action, port, restart, token).await
263    }
264    async fn manage_at(
265        dir: &std::path::Path,
266        action: &str,
267        port: u16,
268        restart: bool,
269        token: Option<&str>,
270    ) -> Result<Value, AppError> {
271        std::fs::create_dir_all(dir)?;
272        let lock: File = OpenOptions::new()
273            .create(true)
274            .truncate(false)
275            .read(true)
276            .write(true)
277            .mode(0o600)
278            .open(dir.join("desktop-connection.lock"))?;
279        lock.try_lock().map_err(|_| failure("busy"))?;
280        let _lock = LockGuard(lock);
281        let path = dir.join("desktop-connection.json");
282        let mut state: Option<State> = match std::fs::read(&path) {
283            Ok(bytes) => {
284                Some(serde_json::from_slice(&bytes).map_err(|_| failure("invalid_state"))?)
285            }
286            Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
287            Err(e) => return Err(e.into()),
288        };
289        if state
290            .as_ref()
291            .is_some_and(|s| s.version != 1 || s.port < 1024)
292        {
293            return Err(failure("invalid_state"));
294        }
295        if action != "status" && state.as_ref().is_some_and(|s| s.port != port) {
296            return Err(failure("different_port"));
297        }
298        if token.is_some()
299            && state
300                .as_ref()
301                .is_some_and(|s| Some(s.token.as_str()) != token)
302        {
303            return Err(failure("busy"));
304        }
305        match action {
306            "connect" => {
307                if state.is_some() {
308                    return Err(failure("already_managed"));
309                }
310                let previous = std::env::var("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS").ok();
311                if previous
312                    .as_ref()
313                    .is_some_and(|s| s.contains("--remote-debugging-"))
314                {
315                    return Err(failure("existing_flags"));
316                }
317                let next = State {
318                    version: 1,
319                    port,
320                    phase: "starting".into(),
321                    token: token.unwrap_or_default().into(),
322                    previous,
323                    owners: vec![],
324                };
325                save(&path, &next)?;
326                launch(&format!(
327                    "{} --remote-debugging-port={port} --remote-debugging-address=127.0.0.1",
328                    next.previous.as_deref().unwrap_or_default()
329                ))
330                .await?;
331                state = Some(next);
332            }
333            "finish" => {
334                let current = state.as_mut().ok_or_else(|| failure("missing_state"))?;
335                if current.phase != "starting" {
336                    return Err(failure("busy"));
337                }
338                current.owners = owners().await?;
339                if current.owners.is_empty() {
340                    return Err(failure("session_changed"));
341                }
342                current.phase = "active".into();
343                save(&path, current)?;
344            }
345            "disconnect" => {
346                let Some(current) = state.as_ref() else {
347                    if restart {
348                        return Err(failure("not_managed"));
349                    }
350                    return Ok(
351                        json!({"managed":false,"port":null,"phase":"idle","settings_pending":false,"needs_recovery":false}),
352                    );
353                };
354                if current.phase == "starting" {
355                    return Err(failure("busy"));
356                }
357                if restart {
358                    if current.owners.is_empty() || current.owners != owners().await? {
359                        return Err(failure("session_changed"));
360                    }
361                    launch(current.previous.as_deref().unwrap_or_default()).await?;
362                }
363                std::fs::remove_file(&path)?;
364                state = None;
365            }
366            "recover" => {
367                if let Some(current) = state.as_mut()
368                    && current.phase == "starting"
369                {
370                    current.phase = "interrupted".into();
371                    save(&path, current)?;
372                }
373            }
374            "status" => {}
375            _ => return Err(failure("invalid_action")),
376        }
377        Ok(
378            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")}),
379        )
380    }
381    #[cfg(test)]
382    mod tests {
383        use super::*;
384        fn interrupted(port: u16) -> State {
385            State {
386                version: 1,
387                port,
388                phase: "starting".into(),
389                token: "first".into(),
390                previous: None,
391                owners: vec![],
392            }
393        }
394        #[tokio::test]
395        async fn inherited_descriptors_do_not_extend_the_operation_lock() {
396            let temp = tempfile::tempdir().unwrap();
397            let path = temp.path().join("desktop-connection.lock");
398            let file = OpenOptions::new()
399                .create(true)
400                .truncate(false)
401                .read(true)
402                .write(true)
403                .open(&path)
404                .unwrap();
405            file.try_lock().unwrap();
406            let inherited = file.try_clone().unwrap();
407            drop(LockGuard(file));
408            let next = OpenOptions::new()
409                .read(true)
410                .write(true)
411                .open(&path)
412                .unwrap();
413            next.try_lock()
414                .expect("the operation ended even while another descriptor survives");
415            next.unlock().unwrap();
416            drop(inherited);
417        }
418        #[tokio::test]
419        async fn disconnect_is_idempotent_but_never_restarts_an_unmanaged_process() {
420            let temp = tempfile::tempdir().unwrap();
421            assert_eq!(
422                manage_at(temp.path(), "disconnect", 19222, false, None)
423                    .await
424                    .unwrap()["managed"],
425                false
426            );
427            assert!(
428                manage_at(temp.path(), "disconnect", 19222, true, None)
429                    .await
430                    .is_err()
431            );
432            assert!(!temp.path().join("desktop-connection.json").exists());
433        }
434        #[tokio::test]
435        async fn recovery_is_local_and_does_not_adopt_an_unrecorded_process() {
436            let temp = tempfile::tempdir().unwrap();
437            let path = temp.path().join("desktop-connection.json");
438            save(&path, &interrupted(19222)).unwrap();
439            let result = manage_at(temp.path(), "recover", 19222, false, None)
440                .await
441                .unwrap();
442            assert_eq!(result["phase"], "interrupted");
443            assert_eq!(result["settings_pending"], false);
444            assert!(
445                manage_at(temp.path(), "disconnect", 19222, true, None)
446                    .await
447                    .is_err()
448            );
449            assert!(path.exists());
450            assert_eq!(
451                manage_at(temp.path(), "disconnect", 19222, false, None)
452                    .await
453                    .unwrap()["managed"],
454                false
455            );
456        }
457        #[tokio::test]
458        async fn competing_owner_wrong_port_and_corrupt_records_are_preserved() {
459            let temp = tempfile::tempdir().unwrap();
460            let path = temp.path().join("desktop-connection.json");
461            save(&path, &interrupted(19222)).unwrap();
462            let original = std::fs::read(&path).unwrap();
463            assert!(
464                manage_at(temp.path(), "recover", 19222, false, Some("second"))
465                    .await
466                    .is_err()
467            );
468            assert!(
469                manage_at(temp.path(), "recover", 19223, false, None)
470                    .await
471                    .is_err()
472            );
473            assert!(
474                manage_at(temp.path(), "disconnect", 19222, false, None)
475                    .await
476                    .is_err()
477            );
478            assert_eq!(std::fs::read(&path).unwrap(), original);
479            std::fs::write(&path, b"not-json").unwrap();
480            assert!(
481                manage_at(temp.path(), "recover", 19222, false, None)
482                    .await
483                    .is_err()
484            );
485            assert_eq!(std::fs::read(&path).unwrap(), b"not-json");
486        }
487        #[tokio::test]
488        async fn lock_contention_never_changes_a_record() {
489            let temp = tempfile::tempdir().unwrap();
490            let path = temp.path().join("desktop-connection.json");
491            save(&path, &interrupted(19222)).unwrap();
492            let original = std::fs::read(&path).unwrap();
493            let lock = File::create(temp.path().join("desktop-connection.lock")).unwrap();
494            lock.try_lock().unwrap();
495            assert!(
496                manage_at(temp.path(), "recover", 19222, false, None)
497                    .await
498                    .is_err()
499            );
500            assert_eq!(std::fs::read(&path).unwrap(), original);
501        }
502    }
503}