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