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 "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
45pub 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 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 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}