1use 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
48pub 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 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
130pub 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 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}