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