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 "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
48pub 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
85pub 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 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
167pub 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 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}