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