1use super::{ConnectionMetadata, ProbeStatus, cdp::Cdp, connection::Action, session::Verifier};
6use crate::{config::DesktopAccount, error::AppError};
7use serde::{Deserialize, Serialize};
8use serde_json::{Value, json};
9use std::{
10 ffi::OsString,
11 fs::{File, OpenOptions},
12 io::{Read, Seek, Write},
13 os::unix::{ffi::OsStringExt, fs::OpenOptionsExt, process::CommandExt},
14 path::{Path, PathBuf},
15 process::{Command, Stdio},
16 time::Duration,
17};
18
19#[path = "browser_children.rs"]
20mod children;
21#[path = "browser_lifetime.rs"]
22pub mod lifetime;
23
24pub struct BrowserSystem {
25 pub core: bool,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29struct Identity {
30 pid: u32,
31 started: String,
32 executable: PathBuf,
33}
34
35#[derive(Debug, Serialize, Deserialize)]
36struct State {
37 version: u8,
38 port: u16,
39 owner: Option<Identity>,
40 visible: bool,
41 #[serde(default)]
42 core: bool,
43 phase: String,
44 #[serde(default)]
46 lifetime: lifetime::Lifetime,
47 #[serde(default)]
48 children: Vec<children::Child>,
49}
50
51fn failed(message: &str) -> AppError {
52 AppError::Desktop(message.into())
53}
54
55pub(super) fn data_directory() -> Result<PathBuf, AppError> {
56 let base = match std::env::var_os("TEAMS_DESKTOP_BROWSER_DATA_DIR") {
57 Some(path) => {
58 let path = PathBuf::from(path);
59 if !path.is_absolute() {
60 return Err(failed(
61 "TEAMS_DESKTOP_BROWSER_DATA_DIR must be an absolute path",
62 ));
63 }
64 path
65 }
66 None => dirs::data_local_dir()
67 .ok_or_else(|| failed("could not locate the browser profile directory"))?
68 .join("teams-cli/browser"),
69 };
70 Ok(base)
71}
72
73fn directory(port: u16) -> Result<PathBuf, AppError> {
74 Ok(data_directory()?.join(port.to_string()))
75}
76
77pub fn has_saved_data(port: u16) -> Result<bool, AppError> {
79 Ok(directory(port)?.try_exists()?)
80}
81
82fn profile(dir: &Path) -> PathBuf {
83 dir.join("profile")
84}
85
86pub fn executable() -> Result<PathBuf, AppError> {
87 if let Some(path) = std::env::var_os("TEAMS_DESKTOP_BROWSER") {
88 let path = PathBuf::from(path);
89 if !path.is_absolute() {
90 return Err(failed(
91 "TEAMS_DESKTOP_BROWSER must be an absolute path to the Chrome/Chromium/Edge executable (not a shell wrapper)",
92 ));
93 }
94 return std::fs::canonicalize(path)
95 .map_err(|_| failed("TEAMS_DESKTOP_BROWSER does not exist; fix or unset this override, then retry setup"));
96 }
97 let candidates = if cfg!(target_os = "macos") {
98 vec![
99 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
100 "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
101 "/Applications/Chromium.app/Contents/MacOS/Chromium",
102 ]
103 } else {
104 vec![
105 "/opt/google/chrome/chrome",
106 "/opt/microsoft/msedge/msedge",
107 "/opt/microsoft/msedge-beta/msedge",
108 "/usr/lib/chromium/chromium",
109 "/usr/lib64/chromium-browser/chromium-browser",
110 "/usr/lib/chromium-browser/chromium-browser",
111 ]
112 };
113 candidates.iter().find_map(|p| std::fs::canonicalize(p).ok())
114 .ok_or_else(|| failed("install Chrome, Chromium or Edge, or set TEAMS_DESKTOP_BROWSER to its executable; WSL2 uses a Linux browser"))
115}
116
117pub fn prerequisites() -> Result<(), AppError> {
118 prerequisites_for(false)
119}
120
121pub fn prerequisites_for(core: bool) -> Result<(), AppError> {
122 if core {
123 super::browser_install::installed().map_err(|error| failed(&format!("The lightweight browser is not ready: {error}. Run `teams desktop login --desktop-client browser-core` to set it up.")))?;
124 } else {
125 executable()?;
126 }
127 verification_prerequisites()
128}
129
130fn verification_prerequisites() -> Result<(), AppError> {
131 #[cfg(target_os = "macos")]
132 if !Path::new("/usr/sbin/lsof").is_file() {
133 return Err(failed("lsof is required to verify the browser listener"));
134 }
135 #[cfg(target_os = "linux")]
136 if !Path::new("/proc/net/tcp").is_file() {
137 return Err(failed(
138 "Linux /proc is required to verify the browser listener",
139 ));
140 }
141 Ok(())
142}
143
144fn display_prerequisites() -> Result<(), AppError> {
145 #[cfg(target_os = "linux")]
146 if !["DISPLAY", "WAYLAND_DISPLAY"]
147 .iter()
148 .any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty()))
149 {
150 return Err(failed(
151 "Browser sign-in needs a display. Enable WSLg on WSL2, or run from a Linux desktop session.",
152 ));
153 }
154 Ok(())
155}
156
157pub async fn preflight(core: bool) -> Result<bool, AppError> {
159 display_prerequisites()?;
160 verification_prerequisites()?;
161 let base = data_directory()?;
162 if base.try_exists()? {
163 validate_directory(&base)?;
164 }
165 if core {
166 super::browser_install::preflight().await
167 } else {
168 browser_version(&executable()?)?;
169 Ok(false)
170 }
171}
172
173fn session_executable(core: bool, visible: bool) -> Result<PathBuf, AppError> {
174 if core {
175 let (chrome, shell) = super::browser_install::installed()?;
176 Ok(if visible { chrome } else { shell })
177 } else {
178 executable()
179 }
180}
181
182pub(super) fn browser_version(path: &Path) -> Result<String, AppError> {
183 browser_version_with_timeout(path, Duration::from_secs(5))
184}
185
186fn browser_version_with_timeout(path: &Path, timeout: Duration) -> Result<String, AppError> {
187 let mut stdout = tempfile::tempfile()?;
189 let mut stderr = tempfile::tempfile()?;
190 let mut child = Command::new(path)
191 .arg("--version")
192 .stdin(Stdio::null())
193 .stdout(stdout.try_clone()?)
194 .stderr(stderr.try_clone()?)
195 .spawn()?;
196 let deadline = std::time::Instant::now() + timeout;
197 let status = loop {
198 match child.try_wait() {
199 Ok(Some(status)) => break status,
200 Ok(None) if std::time::Instant::now() < deadline => {
201 std::thread::sleep(Duration::from_millis(20));
202 }
203 result => {
204 let _ = child.kill();
205 let _ = child.wait();
206 return Err(match result {
207 Err(error) => error.into(),
208 _ => failed(
209 "Checking the browser version timed out. Check its executable or unset the browser overrides, then retry setup.",
210 ),
211 });
212 }
213 }
214 };
215 if !status.success() {
216 stderr.rewind()?;
217 let mut detail = Vec::new();
218 stderr.take(400).read_to_end(&mut detail)?;
219 return Err(failed(&format!(
220 "The browser could not start: {}. On Linux, install the Chrome system libraries named in this error and retry setup.",
221 String::from_utf8_lossy(&detail).trim()
222 )));
223 }
224 stdout.rewind()?;
225 let mut version = Vec::new();
226 stdout.take(4096).read_to_end(&mut version)?;
227 String::from_utf8_lossy(&version)
228 .split_whitespace()
229 .find(|s| s.split('.').count() == 4 && s.bytes().all(|b| b.is_ascii_digit() || b == b'.'))
230 .map(str::to_owned)
231 .ok_or_else(|| failed("unrecognized browser version"))
232}
233
234fn validate_core(account: DesktopAccount) -> Result<(), AppError> {
235 if account != DesktopAccount::Personal {
236 return Err(failed(
237 "browser-core currently requires --desktop-account personal",
238 ));
239 }
240 if browser_version(&session_executable(true, true)?)?
241 != browser_version(&session_executable(true, false)?)?
242 {
243 return Err(failed(
244 "visible browser and headless shell must have exactly matching versions",
245 ));
246 }
247 Ok(())
248}
249
250fn load(dir: &Path, port: u16) -> Result<Option<State>, AppError> {
251 match std::fs::read(dir.join("connection.json")) {
252 Ok(bytes) => {
253 let state: State = serde_json::from_slice(&bytes)
254 .map_err(|_| failed("invalid browser connection record; preserved for recovery"))?;
255 if state.version != 1
256 || state.port != port
257 || !matches!(state.phase.as_str(), "starting" | "active" | "core_ready")
258 {
259 return Err(failed(
260 "invalid browser connection record; preserved for recovery",
261 ));
262 }
263 Ok(Some(state))
264 }
265 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
266 Err(error) => Err(error.into()),
267 }
268}
269
270fn save(dir: &Path, state: &State) -> Result<(), AppError> {
271 let mut file = tempfile::NamedTempFile::new_in(dir)?;
272 file.write_all(&serde_json::to_vec(state).map_err(|_| failed("invalid browser state"))?)?;
273 file.as_file().sync_all()?;
274 file.persist(dir.join("connection.json"))
275 .map_err(|e| e.error)?;
276 Ok(())
277}
278
279fn arguments_match(args: &[OsString], profile: &Path, port: u16) -> bool {
280 let mut expected = OsString::from("--user-data-dir=");
281 expected.push(profile);
282 let debug = OsString::from(format!("--remote-debugging-port={port}"));
283 args.iter()
284 .filter(|a| a.to_string_lossy().starts_with("--user-data-dir"))
285 .count()
286 == 1
287 && args.contains(&expected)
288 && args
289 .iter()
290 .filter(|a| a.to_string_lossy().starts_with("--remote-debugging-port"))
291 .count()
292 == 1
293 && args.contains(&debug)
294 && !args
295 .iter()
296 .any(|a| a == "--remote-debugging-pipe" || a.to_string_lossy().starts_with("--type="))
297}
298
299fn owned(state: &State, dir: &Path) -> bool {
300 if validate_directory(dir).is_err() || validate_directory(&profile(dir)).is_err() {
301 return false;
302 }
303 state.owner.as_ref().is_some_and(|owner| {
304 system::process(owner.pid).is_ok_and(|(identity, args)| {
305 identity == *owner && arguments_match(&args, &profile(dir), state.port)
306 })
307 })
308}
309
310impl Verifier for BrowserSystem {
311 fn core_only(&self, port: u16) -> bool {
312 self.core
313 && directory(port)
314 .and_then(|d| load(&d, port))
315 .map_or(true, |state| state.is_none_or(|s| !s.visible))
316 }
317
318 async fn verify(&self, port: u16) -> Result<(ConnectionMetadata, Vec<Value>), AppError> {
319 prerequisites_for(self.core)?;
320 let mut metadata = ConnectionMetadata::empty(port);
321 let sockets = system::listeners(port)?;
322 if sockets.is_empty() {
323 return Ok((metadata, vec![]));
324 }
325 metadata.loopback_only = Some(sockets.iter().all(|s| s.loopback));
326 if metadata.loopback_only != Some(true) {
327 metadata.status = ProbeStatus::UnsafeListenerRefused;
328 return Ok((metadata, vec![]));
329 }
330 let dir = directory(port)?;
331 let state = load(&dir, port)?;
332 let trusted = state.as_ref().is_some_and(|s| {
333 s.core == self.core
334 && owned(s, &dir)
335 && s.owner.as_ref().is_some_and(|o| {
336 session_executable(s.core, s.visible).is_ok_and(|e| e == o.executable)
337 && system::owns_sockets(o.pid, &sockets)
338 })
339 && owned(s, &dir)
340 });
341 metadata.teams_owned = Some(trusted);
342 if !trusted {
343 metadata.status = ProbeStatus::NonTeamsListenerRefused;
344 return Ok((metadata, vec![]));
345 }
346 let targets = super::fill_metadata(&mut metadata).await;
347 if state
348 .as_ref()
349 .is_some_and(|s| s.core && !s.visible && s.phase != "core_ready")
350 {
351 return Ok((metadata, vec![]));
352 }
353 Ok((metadata, targets))
354 }
355}
356
357fn launch_args(
358 dir: &Path,
359 port: u16,
360 account: DesktopAccount,
361 visible: bool,
362 core: bool,
363) -> Vec<OsString> {
364 let mut data = OsString::from("--user-data-dir=");
365 data.push(profile(dir));
366 let mut args = vec![
367 data,
368 format!("--remote-debugging-port={port}").into(),
369 "--remote-debugging-address=127.0.0.1".into(),
370 "--no-first-run".into(),
371 "--no-default-browser-check".into(),
372 ];
373 if core && cfg!(target_os = "macos") {
374 args.push("--use-mock-keychain".into());
375 }
376 #[cfg(target_os = "linux")]
377 if visible && std::env::var_os("WAYLAND_DISPLAY").is_some() {
378 args.push("--ozone-platform-hint=auto".into());
379 }
380 if !visible {
381 args.push("--headless=new".into());
382 if core {
383 args.push("--disable-gpu".into());
384 args.push("--js-flags=--lite-mode --optimize-for-size".into());
385 }
386 }
387 let url = if account == DesktopAccount::Personal {
388 "https://teams.live.com"
389 } else {
390 "https://teams.cloud.microsoft"
391 };
392 args.push(if core && !visible {
393 "about:blank".into()
394 } else {
395 format!("--app={url}").into()
396 });
397 args
398}
399
400struct Guard(File);
401impl Drop for Guard {
402 fn drop(&mut self) {
403 let _ = self.0.unlock();
404 }
405}
406
407pub(super) fn prepare_directory(dir: &Path) -> Result<(), AppError> {
408 use std::os::unix::fs::DirBuilderExt;
409 std::fs::DirBuilder::new()
410 .recursive(true)
411 .mode(0o700)
412 .create(dir)?;
413 validate_directory(dir)
414}
415
416pub(super) fn validate_directory(dir: &Path) -> Result<(), AppError> {
417 use std::os::unix::fs::MetadataExt;
418 let metadata = std::fs::symlink_metadata(dir)?;
419 let me = system::uid()?;
420 if !metadata.is_dir() || metadata.uid() != me || metadata.mode() & 0o077 != 0 {
421 return Err(failed(
422 "the dedicated browser directory must be owned by you with permissions 0700",
423 ));
424 }
425 Ok(())
426}
427
428trait LoginSession {
432 async fn connect(&mut self, visible: bool) -> Result<(), AppError>;
433 async fn disconnect(&mut self) -> Result<(), AppError>;
434 async fn wait_read(&mut self, timeout: Duration) -> Result<(), AppError>;
435 async fn stays_signed_in(&mut self, period: Duration) -> Result<bool, AppError>;
437}
438
439async fn login_flow(session: &mut impl LoginSession) -> Result<bool, AppError> {
441 session.connect(true).await?;
442 session
443 .wait_read(Duration::from_secs(300))
444 .await
445 .map_err(|error| {
446 error.with_hint(
447 "The visible browser and its profile were preserved; sign-in has not completed there yet.")
448 })?;
449 session.disconnect().await?;
450 session.connect(false).await?;
451 session
452 .wait_read(Duration::from_secs(60))
453 .await
454 .map_err(|error| {
455 error.with_hint(
456 "The headless browser and saved profile were preserved; headless chat access is not ready.")
457 })?;
458 if session.stays_signed_in(Duration::from_secs(60)).await? {
459 return Ok(false);
460 }
461 session.disconnect().await?;
465 session.connect(true).await?;
466 session.wait_read(Duration::from_secs(300)).await.map_err(|error| error.with_hint(
467 "Your organisation's sign-in needs the visible browser; sign-in has not completed there yet."))?;
468 Ok(true)
469}
470
471async fn resume_flow(session: &mut impl LoginSession, visible: bool) -> Result<bool, AppError> {
472 session.connect(visible).await?;
473 session.wait_read(Duration::from_secs(60)).await?;
474 Ok(visible)
475}
476
477async fn check_resumed_session(session: &mut impl LoginSession) -> Result<(), AppError> {
478 if session.stays_signed_in(Duration::from_secs(60)).await? {
479 Ok(())
480 } else {
481 Err(AppError::Auth("Teams needs its browser window to stay signed in. Reopen sign-in to continue; your saved session is preserved.".into()))
482 }
483}
484
485pub async fn resume(
487 port: u16,
488 account: DesktopAccount,
489 core: bool,
490 quiet: bool,
491) -> Result<(bool, Option<super::ConnectionWatch>), AppError> {
492 let status = manage(port, account, Action::Status, false, core).await?;
493 let already_running = status["endpoint_available"] == true;
494 let visible = status["management"]["visible"].as_bool().unwrap_or(false);
495 let mut session = BrowserLogin {
496 port,
497 account,
498 core,
499 quiet,
500 resume_only: true,
501 };
502 let result = resume_flow(&mut session, visible).await;
503 if let Err(error) = result {
504 let metadata = (BrowserSystem { core }).probe(port).await?;
507 if metadata.signed_out() {
508 return Err(AppError::Auth(
509 "Your saved Teams session needs sign-in again.".into(),
510 ));
511 }
512 return Err(error);
513 }
514 let visible = result?;
515 let watch = if !visible && !already_running && account != DesktopAccount::Personal {
516 let (sender, receiver) = std::sync::mpsc::channel();
517 let task = tokio::spawn(async move {
518 session.quiet = true;
519 let result = check_resumed_session(&mut session).await;
520 let _ = sender.send(result);
521 });
522 Some(super::ConnectionWatch { receiver, task })
523 } else {
524 None
525 };
526 Ok((visible, watch))
527}
528
529struct BrowserLogin {
530 port: u16,
531 account: DesktopAccount,
532 core: bool,
533 quiet: bool,
534 resume_only: bool,
535}
536
537impl LoginSession for BrowserLogin {
538 async fn connect(&mut self, visible: bool) -> Result<(), AppError> {
539 let dir = directory(self.port)?;
542 if let Some(state) = load(&dir, self.port)?
543 && state.core == self.core
544 && !owned(&state, &dir)
545 && system::listeners(self.port)?.is_empty()
546 {
547 manage(self.port, self.account, Action::Recover, false, self.core).await?;
548 }
549 if let Some(state) = load(&dir, self.port)?
550 && state.visible != visible
551 {
552 self.disconnect().await?;
553 }
554 manage(self.port, self.account, Action::Connect, visible, self.core).await?;
555 Ok(())
556 }
557
558 async fn disconnect(&mut self) -> Result<(), AppError> {
559 manage(
560 self.port,
561 self.account,
562 Action::Disconnect,
563 false,
564 self.core,
565 )
566 .await?;
567 Ok(())
568 }
569
570 async fn wait_read(&mut self, timeout: Duration) -> Result<(), AppError> {
571 tokio::time::timeout(timeout, async {
572 loop {
573 let mut session = super::session::Session::new(BrowserSystem { core: self.core });
575 let mut request = super::read_request(self.port, None, 1, None)?;
576 request["account_type"] = json!(self.account);
577 let reply = session.read(&request).await?;
578 if login_read_ready(&reply)? {
579 return Ok(());
580 }
581 if self.resume_only
582 && (BrowserSystem { core: self.core })
583 .probe(self.port)
584 .await?
585 .signed_out()
586 {
587 return Err(AppError::Auth(
588 "Your saved Teams session needs sign-in again.".into(),
589 ));
590 }
591 tokio::time::sleep(Duration::from_secs(1)).await;
592 }
593 })
594 .await
595 .map_err(|_| failed("Timed out waiting for a readable Teams chat cache"))?
596 }
597
598 async fn stays_signed_in(&mut self, period: Duration) -> Result<bool, AppError> {
599 if self.account == DesktopAccount::Personal {
601 return Ok(true);
602 }
603 if !self.quiet {
604 eprintln!("Checking that Teams stays signed in without its window (up to a minute).");
605 }
606 if !watch_sign_in(&BrowserSystem { core: self.core }, self.port, period).await? {
607 return Ok(false);
608 }
609 self.wait_read(Duration::from_secs(60)).await?;
610 Ok(true)
611 }
612}
613
614async fn watch_sign_in(
615 system: &impl Verifier,
616 port: u16,
617 period: Duration,
618) -> Result<bool, AppError> {
619 let deadline = tokio::time::Instant::now() + period;
620 while tokio::time::Instant::now() < deadline {
621 let (metadata, _) = system.verify(port).await?;
622 if !metadata.endpoint_available() {
623 return Err(failed(&format!(
624 "The dedicated browser became unavailable while checking sign-in. {}",
625 metadata.detail()
626 )));
627 }
628 if metadata.signed_out() {
629 return Ok(false);
630 }
631 tokio::time::sleep(Duration::from_secs(2)).await;
632 }
633 Ok(true)
634}
635
636fn login_read_ready(reply: &Value) -> Result<bool, AppError> {
637 if matches!(reply["error"].as_str(), Some("no_target" | "read_failed")) {
638 return Ok(false);
639 }
640 if reply["result"].get("exceptionDetails").is_some() {
643 return Ok(false);
644 }
645 if let Some(raw) = reply["result"]["result"]["value"].as_str()
646 && let Ok(value) = serde_json::from_str::<Value>(raw)
647 && matches!(value["error"].as_str(), Some("layout" | "core_not_ready"))
648 {
649 return Ok(false);
650 }
651 super::read_page(reply).map(|_| true)
652}
653
654pub async fn login(port: u16, account: DesktopAccount, core: bool) -> Result<Value, AppError> {
655 login_with_progress(port, account, core, false).await
656}
657
658pub async fn login_with_progress(
659 port: u16,
660 account: DesktopAccount,
661 core: bool,
662 quiet: bool,
663) -> Result<Value, AppError> {
664 login_with_recovery(port, account, core, quiet, true).await
665}
666
667pub(super) async fn login_with_recovery(
668 port: u16,
669 account: DesktopAccount,
670 core: bool,
671 quiet: bool,
672 recovery: bool,
673) -> Result<Value, AppError> {
674 display_prerequisites()?;
675 if core {
676 super::browser_install::ensure_with_progress(quiet).await?;
677 }
678 prerequisites_for(core)?;
679 let dir = directory(port)?;
680 prepare_directory(&dir)?;
681 let lock = OpenOptions::new()
682 .create(true)
683 .truncate(false)
684 .read(true)
685 .write(true)
686 .mode(0o600)
687 .open(dir.join("login.lock"))?;
688 lock.try_lock()
689 .map_err(|_| failed("another browser login is running"))?;
690 let _guard = Guard(lock);
691 if !quiet {
692 eprintln!(
693 "Opening Teams sign-in. Complete sign-in in the window; it will hide automatically once your chats are ready."
694 );
695 }
696 let visible = login_flow(&mut BrowserLogin {
697 port,
698 account,
699 core,
700 quiet,
701 resume_only: false,
702 })
703 .await.map_err(|error| if recovery {
704 error.with_hint(&format!("Retry sign-in: `teams desktop login --desktop-client {} --port {port}`. To close this dedicated browser: `teams desktop disconnect --desktop-client {} --port {port}`.", if core { "browser-core" } else { "browser" }, if core { "browser-core" } else { "browser" }))
705 } else { error })?;
706 if visible && !quiet {
707 eprintln!(
708 "Your organisation's sign-in does not allow Teams to stay signed in without its window, so the browser stays visible. Minimise it, but keep it open."
709 );
710 }
711 let mut value = manage(port, account, Action::Status, false, core).await?;
712 if value["endpoint_available"] != true {
713 return Err(failed(
714 "The dedicated browser closed or changed after sign-in; retry the sign-in command.",
715 ));
716 }
717 value["session_verified"] = json!(true);
718 Ok(value)
719}
720
721pub async fn manage(
722 port: u16,
723 account: DesktopAccount,
724 action: Action,
725 visible: bool,
726 core: bool,
727) -> Result<Value, AppError> {
728 manage_inner(port, account, action, visible, core, false).await
729}
730
731async fn manage_inner(
732 port: u16,
733 account: DesktopAccount,
734 action: Action,
735 visible: bool,
736 core: bool,
737 cleanup: bool,
738) -> Result<Value, AppError> {
739 if core && matches!(action, Action::Connect) {
740 super::browser_install::ensure().await?;
741 }
742 let dir = directory(port)?;
743 prepare_directory(&dir)?;
744 prepare_directory(&profile(&dir))?;
745 let lock = OpenOptions::new()
746 .create(true)
747 .truncate(false)
748 .read(true)
749 .write(true)
750 .mode(0o600)
751 .open(dir.join("connection.lock"))?;
752 let deadline = tokio::time::Instant::now() + Duration::from_secs(25);
753 loop {
754 match lock.try_lock() {
755 Ok(()) => break,
756 Err(std::fs::TryLockError::WouldBlock) if cleanup => {
757 return Ok(json!({"pending": true}));
758 }
759 Err(std::fs::TryLockError::WouldBlock) if tokio::time::Instant::now() < deadline => {
760 tokio::time::sleep(Duration::from_millis(20)).await
761 }
762 Err(std::fs::TryLockError::WouldBlock) => {
763 return Err(failed(
764 "another browser connection command is running; retry shortly",
765 ));
766 }
767 Err(std::fs::TryLockError::Error(error)) => return Err(error.into()),
768 }
769 }
770 let _guard = Guard(lock);
771 let mut state = load(&dir, port)?;
772 if cleanup {
773 let Some(current) = state.as_mut() else {
774 return Ok(json!({}));
775 };
776 current.lifetime.prune()?;
777 if !current.lifetime.auto_close || !current.lifetime.clients.is_empty() {
778 return Ok(json!({}));
779 }
780 if !current.lifetime.users.is_empty() {
781 return Ok(json!({"pending": true}));
782 }
783 let now = std::time::SystemTime::now()
786 .duration_since(std::time::UNIX_EPOCH)
787 .map_err(|_| failed("could not establish the browser shutdown grace period"))?
788 .as_secs();
789 if current.lifetime.idle_since.is_none() {
790 current.lifetime.idle_since = Some(now);
791 save(&dir, current)?;
792 }
793 let idle = current.lifetime.idle_since.expect("saved idle time");
794 if now.saturating_sub(idle) < 15 {
795 return Ok(json!({"pending": true, "grace": true}));
796 }
797 } else if matches!(action, Action::Status | Action::Connect) {
798 let client = lifetime::current_client()?;
799 if let Some(current) = state.as_mut() {
800 let before =
801 serde_json::to_value(&*current).map_err(|_| failed("invalid browser state"))?;
802 current.lifetime.touch(client.as_ref())?;
803 if matches!(action, Action::Connect) && client.is_none() {
805 current.lifetime.auto_close = false;
806 }
807 if serde_json::to_value(&*current).map_err(|_| failed("invalid browser state"))?
808 != before
809 {
810 save(&dir, current)?;
811 }
812 }
813 }
814 prerequisites_for(core)?;
815 let system = BrowserSystem { core };
816 let recover = format!(
817 "teams desktop recover --desktop-client {} --port {port}",
818 if core { "browser-core" } else { "browser" }
819 );
820 if state.as_ref().is_some_and(|s| s.core != core) {
821 return Err(failed(
822 "this port uses another browser mode; use its original --desktop-client to disconnect it first",
823 ));
824 }
825 match action {
826 Action::Status => {}
827 Action::Connect => {
828 if core {
829 validate_core(account)?;
830 }
831 if state
834 .as_ref()
835 .is_some_and(|s| s.owner.is_some() && !owned(s, &dir))
836 && system::listeners(port)?.is_empty()
837 {
838 children::finish(state.as_ref().expect("saved state")).await?;
839 std::fs::remove_file(dir.join("connection.json"))?;
840 state = None;
841 }
842 if let Some(current) = state.as_ref() {
843 if !owned(current, &dir) {
844 return Err(failed(&format!(
845 "the saved browser session is no longer owned; run `{recover}` before connecting"
846 )));
847 }
848 if current.visible != visible {
849 return Err(failed(
850 "disconnect the browser before changing between visible and headless mode",
851 ));
852 }
853 } else {
854 let (metadata, _) = system.verify(port).await?;
855 if metadata.status != ProbeStatus::NoListener {
856 return Err(failed(
857 "the browser debugging port is already in use; its listener was left untouched",
858 ));
859 }
860 let mut next = State {
861 version: 1,
862 port,
863 owner: None,
864 visible,
865 core,
866 phase: "starting".into(),
867 lifetime: lifetime::Lifetime::for_launch()?,
868 children: vec![],
869 };
870 save(&dir, &next)?;
871 let mut child = Command::new(session_executable(core, visible)?)
872 .args(launch_args(&dir, port, account, visible, core))
873 .stdin(Stdio::null())
874 .stdout(Stdio::null())
875 .stderr(Stdio::null())
876 .process_group(0)
877 .spawn()
878 .map_err(|error| {
879 failed(&format!(
880 "could not launch the Teams browser: {error}; run `{recover}`"
881 ))
882 })?;
883 let pid = child.id();
884 let owner = system::process(pid).map(|(identity, _)| identity);
886 std::thread::spawn(move || {
887 let _ = child.wait();
888 });
889 next.owner = Some(owner?);
890 save(&dir, &next)?;
891 state = Some(next);
892 }
893 let mut ready = false;
894 for _ in 0..100 {
895 let current = state.as_ref().expect("browser state");
896 if !owned(current, &dir) {
897 break;
898 }
899 if system.probe(port).await?.endpoint_available() {
900 ready = true;
901 break;
902 }
903 tokio::time::sleep(Duration::from_millis(200)).await;
904 }
905 if !ready {
906 return Err(failed(&format!(
907 "the browser did not open its debugging endpoint; its recovery record was preserved. Inspect it with `teams desktop status`, or run `{recover}`."
908 )));
909 }
910 let current = state.as_mut().expect("browser state");
911 if core && !visible && current.phase != "core_ready" {
912 let targets = super::fill_metadata(&mut ConnectionMetadata::empty(port)).await;
913 let pages: Vec<_> = targets.iter().filter(|t| t["type"] == "page").collect();
914 let target = pages
915 .first()
916 .filter(|_| pages.len() == 1)
917 .and_then(|t| t["id"].as_str().filter(|id| super::valid_id(id)))
918 .ok_or_else(|| {
919 failed("core startup requires exactly one managed browser page")
920 })?;
921 super::browser_core::start(port, target).await?;
922 current.phase = "core_ready".into();
923 } else if !core || visible {
924 current.phase = "active".into();
925 }
926 save(&dir, current)?;
927 }
928 Action::Disconnect | Action::Recover => {
929 if matches!(action, Action::Recover)
930 && let Some(current) = state.as_mut()
931 && current.owner.is_none()
932 {
933 let expected = session_executable(current.core, current.visible)?;
934 let mut candidates = system::pids()?
935 .into_iter()
936 .filter_map(|pid| system::process(pid).ok())
937 .filter(|(identity, args)| {
938 identity.executable == expected
939 && arguments_match(args, &profile(&dir), port)
940 });
941 if let Some((identity, _)) = candidates.next() {
942 if candidates.next().is_some() {
943 return Err(failed(
944 "multiple browser processes match the interrupted startup; they were left untouched",
945 ));
946 }
947 current.owner = Some(identity);
948 save(&dir, current)?;
949 }
950 }
951 if let Some(current) = state.as_mut()
952 && owned(current, &dir)
953 {
954 let captured = children::capture(current, &dir)?;
955 for child in captured {
956 if !current.children.contains(&child) {
957 current.children.push(child);
958 }
959 }
960 save(&dir, current)?;
963 }
964 if let Some(current) = state.as_ref() {
965 if owned(current, &dir) && system::listeners(port)?.is_empty() {
966 stop(current, &dir).await?;
969 } else if owned(current, &dir) {
970 let (metadata, _) = system.verify(port).await?;
971 if metadata.teams_owned != Some(true) || metadata.loopback_only != Some(true) {
972 return Err(failed(&format!(
973 "browser ownership could not be verified; close the dedicated browser manually, then run `{recover}`"
974 )));
975 }
976 let close = async {
980 if !metadata.endpoint_available() {
981 return Err(failed("the verified browser endpoint is unresponsive"));
982 }
983 let (version, _) = super::read_metadata(port).await?;
984 let id = browser_id(&version, port)?;
985 if !owned(current, &dir) {
986 return Err(failed(
987 "the browser process changed; it was left untouched",
988 ));
989 }
990 Cdp::close_browser(port, &id).await
991 }
992 .await;
993 if !exited(current, &dir, Duration::from_secs(10)).await {
994 stop(current, &dir).await?;
995 }
996 if close.is_err() && !system::listeners(port)?.is_empty() {
997 return Err(failed(
998 "browser shutdown could not be confirmed; its recovery record was preserved",
999 ));
1000 }
1001 } else if current.owner.is_some() {
1002 if !matches!(action, Action::Recover) || !system::listeners(port)?.is_empty() {
1005 return Err(failed(
1006 "the saved browser process changed; it was left untouched. Close the dedicated browser, then recover",
1007 ));
1008 }
1009 } else if std::fs::symlink_metadata(profile(&dir).join("SingletonLock")).is_ok()
1010 || !system::listeners(port)?.is_empty()
1011 {
1012 return Err(failed(
1013 "browser startup was interrupted before ownership was saved; close the dedicated browser manually, then recover",
1014 ));
1015 }
1016 children::finish(current).await?;
1017 std::fs::remove_file(dir.join("connection.json"))?;
1018 state = None;
1019 } else if !system::listeners(port)?.is_empty() {
1020 return Err(failed(
1021 "this browser is not managed by teams-cli; it was left untouched",
1022 ));
1023 }
1024 }
1025 }
1026 let metadata = system.probe(port).await?;
1027 let live = state.as_ref().is_some_and(|s| owned(s, &dir));
1028 Ok(
1029 json!({"port":port,"endpoint_available":metadata.endpoint_available(),"session_verified":false,
1030 "connection":metadata,"browser_profile":profile(&dir),
1031 "management":{"managed":state.is_some(),"port":state.as_ref().map(|s|s.port),
1032 "phase":state.as_ref().map(|s|s.phase.as_str()).unwrap_or("idle"),
1033 "visible":state.as_ref().map(|s|s.visible),"core_only":core,"settings_pending":false,
1034 "auto_close":state.as_ref().is_some_and(|s|s.lifetime.auto_close),
1035 "needs_recovery":state.as_ref().is_some_and(|s|!matches!(s.phase.as_str(), "active" | "core_ready") || !live)}}),
1036 )
1037}
1038
1039async fn exited(state: &State, dir: &Path, timeout: Duration) -> bool {
1041 let deadline = tokio::time::Instant::now() + timeout;
1042 while owned(state, dir) {
1043 if tokio::time::Instant::now() >= deadline {
1044 return false;
1045 }
1046 tokio::time::sleep(Duration::from_millis(100)).await;
1047 }
1048 true
1049}
1050
1051async fn stop(state: &State, dir: &Path) -> Result<(), AppError> {
1055 if let Some(owner) = state.owner.as_ref()
1056 && owned(state, dir)
1057 {
1058 unsafe { libc::kill(owner.pid as libc::pid_t, libc::SIGKILL) };
1060 }
1061 if exited(state, dir, Duration::from_secs(5)).await {
1062 return Ok(());
1063 }
1064 Err(failed(
1065 "the browser has not closed; its recovery record was preserved. Close the dedicated browser manually, then recover.",
1066 ))
1067}
1068
1069impl BrowserSystem {
1070 async fn probe(&self, port: u16) -> Result<ConnectionMetadata, AppError> {
1071 Ok(self.verify(port).await?.0)
1072 }
1073}
1074
1075fn browser_id(version: &Value, port: u16) -> Result<String, AppError> {
1076 let url = version["webSocketDebuggerUrl"]
1077 .as_str()
1078 .and_then(|s| url::Url::parse(s).ok())
1079 .ok_or_else(|| failed("the browser did not advertise a valid control socket"))?;
1080 let id = url.path().strip_prefix("/devtools/browser/").unwrap_or("");
1081 if url.scheme() != "ws"
1082 || !matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "[::1]"))
1083 || url.port() != Some(port)
1084 || !url.username().is_empty()
1085 || url.password().is_some()
1086 || url.query().is_some()
1087 || url.fragment().is_some()
1088 || !(8..=64).contains(&id.len())
1089 || !id.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-')
1090 {
1091 return Err(failed("the browser advertised an unsafe control socket"));
1092 }
1093 Ok(id.into())
1094}
1095
1096#[cfg(target_os = "linux")]
1097mod system {
1098 use super::*;
1099 use std::{collections::HashSet, os::unix::fs::MetadataExt};
1100 #[derive(Debug)]
1101 pub struct Socket {
1102 pub loopback: bool,
1103 inode: u64,
1104 uid: u32,
1105 }
1106
1107 pub fn parse_sockets(text: &str, port: u16, ipv6: bool) -> Result<Vec<Socket>, AppError> {
1108 let mut sockets = vec![];
1109 for row in text.lines().skip(1) {
1110 let fields: Vec<_> = row.split_whitespace().collect();
1111 if fields.len() < 10 {
1112 return Err(failed("invalid Linux socket metadata"));
1113 }
1114 let (address, number) = fields[1]
1115 .split_once(':')
1116 .ok_or_else(|| failed("invalid Linux socket address"))?;
1117 let number =
1118 u16::from_str_radix(number, 16).map_err(|_| failed("invalid Linux socket port"))?;
1119 if number != port || fields[3] != "0A" {
1120 continue;
1121 }
1122 sockets.push(Socket {
1123 loopback: address
1124 == if ipv6 {
1125 "00000000000000000000000001000000"
1126 } else {
1127 "0100007F"
1128 },
1129 uid: fields[7]
1130 .parse()
1131 .map_err(|_| failed("invalid Linux socket owner"))?,
1132 inode: fields[9]
1133 .parse()
1134 .map_err(|_| failed("invalid Linux socket inode"))?,
1135 });
1136 }
1137 Ok(sockets)
1138 }
1139 pub fn listeners(port: u16) -> Result<Vec<Socket>, AppError> {
1140 let mut sockets = parse_sockets(&std::fs::read_to_string("/proc/net/tcp")?, port, false)?;
1141 match std::fs::read_to_string("/proc/net/tcp6") {
1142 Ok(text) => sockets.extend(parse_sockets(&text, port, true)?),
1143 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1144 Err(e) => return Err(e.into()),
1145 }
1146 Ok(sockets)
1147 }
1148 pub fn lease_process(pid: u32) -> Result<(Identity, Vec<OsString>), AppError> {
1149 let stat = std::fs::read_to_string(format!("/proc/{pid}/stat"))?;
1152 if stat
1153 .rsplit_once(") ")
1154 .and_then(|(_, rest)| rest.split_whitespace().next())
1155 .is_some_and(|state| matches!(state, "Z" | "X"))
1156 {
1157 return Err(failed("lease owner has exited"));
1158 }
1159 Ok((
1160 Identity {
1161 pid,
1162 started: start_time(&stat)?,
1163 executable: PathBuf::new(),
1164 },
1165 vec![],
1166 ))
1167 }
1168 pub fn zombie(pid: u32) -> Result<bool, AppError> {
1169 let stat = std::fs::read_to_string(format!("/proc/{pid}/stat"))?;
1170 let state = stat
1171 .rsplit_once(") ")
1172 .and_then(|(_, rest)| rest.split_whitespace().next())
1173 .ok_or_else(|| failed("invalid Linux process state"))?;
1174 Ok(matches!(state, "Z" | "X"))
1175 }
1176 pub fn process(pid: u32) -> Result<(Identity, Vec<OsString>), AppError> {
1177 let dir = PathBuf::from(format!("/proc/{pid}"));
1178 let uid = std::fs::metadata("/proc/self")?.uid();
1179 if std::fs::metadata(&dir)?.uid() != uid {
1180 return Err(failed("browser belongs to another user"));
1181 }
1182 let before = std::fs::read_to_string(dir.join("stat"))?;
1183 let args = arguments(&std::fs::read(dir.join("cmdline"))?);
1184 let exe = std::fs::read_link(dir.join("exe"))?;
1185 let after = std::fs::read_to_string(dir.join("stat"))?;
1186 let started = start_time(&before)?;
1187 if start_time(&after)? != started {
1188 return Err(failed("browser process changed during verification"));
1189 }
1190 Ok((
1191 Identity {
1192 pid,
1193 started,
1194 executable: exe,
1195 },
1196 args,
1197 ))
1198 }
1199 pub fn arguments(cmdline: &[u8]) -> Vec<OsString> {
1202 let args: Vec<_> = cmdline
1203 .split(|b| *b == 0)
1204 .filter(|a| !a.is_empty())
1205 .collect();
1206 let parts = match args[..] {
1207 [title] => {
1208 let mut parts = vec![];
1209 let mut start = 0;
1210 for i in 0..title.len().saturating_sub(2) {
1211 if &title[i..i + 3] == b" --" {
1212 parts.push(&title[start..i]);
1213 start = i + 1;
1214 }
1215 }
1216 parts.push(&title[start..]);
1217 parts
1218 }
1219 _ => args,
1220 };
1221 parts
1222 .into_iter()
1223 .map(|a| OsString::from_vec(a.into()))
1224 .collect()
1225 }
1226 pub fn start_time(stat: &str) -> Result<String, AppError> {
1227 stat.rsplit_once(") ")
1228 .and_then(|(_, rest)| rest.split_whitespace().nth(19))
1229 .filter(|s| s.bytes().all(|b| b.is_ascii_digit()))
1230 .map(String::from)
1231 .ok_or_else(|| failed("invalid Linux process identity"))
1232 }
1233 pub fn uid() -> Result<u32, AppError> {
1234 Ok(std::fs::metadata("/proc/self")?.uid())
1235 }
1236 pub fn pids() -> Result<Vec<u32>, AppError> {
1237 Ok(std::fs::read_dir("/proc")?
1238 .filter_map(Result::ok)
1239 .filter_map(|e| e.file_name().to_str()?.parse().ok())
1240 .collect())
1241 }
1242 pub fn owns_sockets(pid: u32, sockets: &[Socket]) -> bool {
1243 let Ok(fds) = std::fs::read_dir(format!("/proc/{pid}/fd")) else {
1244 return false;
1245 };
1246 let inodes: HashSet<_> = fds
1247 .filter_map(Result::ok)
1248 .filter_map(|e| std::fs::read_link(e.path()).ok())
1249 .filter_map(|p| {
1250 p.to_str()?
1251 .strip_prefix("socket:[")?
1252 .strip_suffix(']')?
1253 .parse::<u64>()
1254 .ok()
1255 })
1256 .collect();
1257 let Ok(me) = std::fs::metadata("/proc/self") else {
1258 return false;
1259 };
1260 sockets
1261 .iter()
1262 .all(|s| s.uid == me.uid() && inodes.contains(&s.inode))
1263 }
1264}
1265
1266#[cfg(target_os = "macos")]
1267mod system {
1268 use super::*;
1269 #[derive(Debug)]
1270 pub struct Socket {
1271 pub loopback: bool,
1272 pid: u32,
1273 }
1274 #[link(name = "proc")]
1275 unsafe extern "C" {
1276 fn proc_pidpath(pid: libc::c_int, buffer: *mut libc::c_void, size: u32) -> libc::c_int;
1277 }
1278 fn run(path: &str, args: &[&str]) -> Result<String, AppError> {
1279 let output = Command::new(path)
1280 .args(args)
1281 .stdin(Stdio::null())
1282 .output()?;
1283 if !output.status.success() {
1284 return Err(failed("macOS browser process inspection failed"));
1285 }
1286 String::from_utf8(output.stdout).map_err(|_| failed("invalid macOS process metadata"))
1287 }
1288 pub fn listeners(port: u16) -> Result<Vec<Socket>, AppError> {
1289 let output = Command::new("/usr/sbin/lsof")
1290 .args(["-nP", &format!("-iTCP:{port}"), "-sTCP:LISTEN", "-Fpn"])
1291 .stdin(Stdio::null())
1292 .output()?;
1293 if !output.status.success() && output.status.code() != Some(1) {
1294 return Err(failed("browser listener inspection failed"));
1295 }
1296 super::super::macos::parse_listeners(&String::from_utf8_lossy(&output.stdout)).map(|list| {
1297 list.into_iter()
1298 .map(|(pid, address)| Socket {
1299 pid,
1300 loopback: super::super::macos::loopback_address(&address, port),
1301 })
1302 .collect()
1303 })
1304 }
1305 pub fn lease_process(pid: u32) -> Result<(Identity, Vec<OsString>), AppError> {
1306 let output = Command::new("/bin/ps")
1309 .args(["-p", &pid.to_string(), "-o", "uid=,stat=,lstart="])
1310 .env("LC_ALL", "C")
1311 .env("TZ", "UTC")
1312 .stdin(Stdio::null())
1313 .output()?;
1314 if !output.status.success() {
1315 return Err(failed("macOS lease process inspection failed"));
1316 }
1317 let text = String::from_utf8(output.stdout)
1318 .map_err(|_| failed("invalid lease process identity"))?;
1319 let (_uid, rest) = text
1320 .trim()
1321 .split_once(char::is_whitespace)
1322 .ok_or_else(|| failed("invalid lease process identity"))?;
1323 let (state, started) = rest
1324 .trim_start()
1325 .split_once(char::is_whitespace)
1326 .ok_or_else(|| failed("invalid lease process state"))?;
1327 if state.starts_with('Z') {
1328 return Err(failed("lease owner has exited"));
1329 }
1330 Ok((
1331 Identity {
1332 pid,
1333 started: started.trim().into(),
1334 executable: PathBuf::new(),
1335 },
1336 vec![],
1337 ))
1338 }
1339 pub fn zombie(pid: u32) -> Result<bool, AppError> {
1340 Ok(run("/bin/ps", &["-p", &pid.to_string(), "-o", "stat="])?
1341 .trim_start()
1342 .starts_with('Z'))
1343 }
1344 pub fn process(pid: u32) -> Result<(Identity, Vec<OsString>), AppError> {
1345 let snapshot = run("/bin/ps", &["-p", &pid.to_string(), "-o", "uid=,lstart="])?;
1346 let (uid, stamp) = snapshot
1347 .trim()
1348 .split_once(char::is_whitespace)
1349 .ok_or_else(|| failed("invalid browser process metadata"))?;
1350 if uid.parse::<u32>().ok() != Some(unsafe { libc::geteuid() }) {
1352 return Err(failed("browser belongs to another user"));
1353 }
1354 let mut path = vec![0u8; 4096];
1355 let n = unsafe { proc_pidpath(pid as i32, path.as_mut_ptr().cast(), path.len() as u32) };
1356 if n <= 0 {
1357 return Err(failed("could not inspect the browser executable"));
1358 }
1359 let executable = PathBuf::from(OsString::from_vec(
1360 path.split(|b| *b == 0).next().unwrap_or_default().into(),
1361 ));
1362 let mut mib = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid as i32];
1363 let mut bytes = vec![0u8; 1024 * 1024];
1364 let mut len = bytes.len();
1365 if unsafe {
1366 libc::sysctl(
1367 mib.as_mut_ptr(),
1368 3,
1369 bytes.as_mut_ptr().cast(),
1370 &mut len,
1371 std::ptr::null_mut(),
1372 0,
1373 )
1374 } != 0
1375 {
1376 return Err(failed("could not inspect the browser launch arguments"));
1377 }
1378 bytes.truncate(len);
1379 let args = parse_args(&bytes)?;
1380 if run("/bin/ps", &["-p", &pid.to_string(), "-o", "uid=,lstart="])? != snapshot {
1381 return Err(failed("browser process changed"));
1382 }
1383 Ok((
1384 Identity {
1385 pid,
1386 started: stamp.trim().into(),
1387 executable,
1388 },
1389 args,
1390 ))
1391 }
1392 pub fn parse_args(bytes: &[u8]) -> Result<Vec<OsString>, AppError> {
1393 let count = bytes
1394 .get(..4)
1395 .map(|b| i32::from_ne_bytes(b.try_into().expect("four bytes")))
1396 .filter(|n| *n > 0 && *n <= 4096)
1397 .ok_or_else(|| failed("invalid browser arguments"))? as usize;
1398 let end = bytes[4..]
1399 .iter()
1400 .position(|b| *b == 0)
1401 .ok_or_else(|| failed("invalid browser arguments"))?
1402 + 4;
1403 let args = bytes[end..]
1404 .split(|b| *b == 0)
1405 .skip_while(|a| a.is_empty())
1406 .take(count)
1407 .map(|a| OsString::from_vec(a.into()))
1408 .collect::<Vec<_>>();
1409 if args.len() != count {
1410 return Err(failed("invalid browser arguments"));
1411 }
1412 Ok(args)
1413 }
1414 pub fn uid() -> Result<u32, AppError> {
1415 Ok(unsafe { libc::geteuid() })
1416 }
1417 pub fn pids() -> Result<Vec<u32>, AppError> {
1418 Ok(run("/bin/ps", &["-axo", "pid="])?
1419 .lines()
1420 .filter_map(|line| line.trim().parse().ok())
1421 .collect())
1422 }
1423 pub fn owns_sockets(pid: u32, sockets: &[Socket]) -> bool {
1424 sockets.iter().all(|s| s.pid == pid)
1425 }
1426}
1427
1428#[cfg(test)]
1429mod tests {
1430 use super::*;
1431
1432 #[derive(Default)]
1433 struct FakeLogin {
1434 events: Vec<&'static str>,
1435 fail_visible: bool,
1436 fail_headless: bool,
1437 fail_close: bool,
1438 signs_out_headless: bool,
1439 }
1440 impl LoginSession for FakeLogin {
1441 async fn connect(&mut self, visible: bool) -> Result<(), AppError> {
1442 self.events
1443 .push(if visible { "visible" } else { "headless" });
1444 Ok(())
1445 }
1446 async fn disconnect(&mut self) -> Result<(), AppError> {
1447 self.events.push("close");
1448 if self.fail_close {
1449 return Err(failed("ownership changed"));
1450 }
1451 Ok(())
1452 }
1453 async fn wait_read(&mut self, timeout: Duration) -> Result<(), AppError> {
1454 let visible = timeout.as_secs() == 300;
1455 self.events.push(if visible {
1456 "read-visible"
1457 } else {
1458 "read-headless"
1459 });
1460 if (visible && self.fail_visible) || (!visible && self.fail_headless) {
1461 return Err(failed("not signed in"));
1462 }
1463 Ok(())
1464 }
1465 async fn stays_signed_in(&mut self, _period: Duration) -> Result<bool, AppError> {
1466 self.events.push("watch-headless");
1467 Ok(!self.signs_out_headless)
1468 }
1469 }
1470
1471 #[tokio::test]
1472 async fn resume_preserves_visible_sessions_and_skips_repeated_work_sign_in_checks() {
1473 for visible in [false, true] {
1474 let mut session = FakeLogin::default();
1475 assert_eq!(resume_flow(&mut session, visible).await.unwrap(), visible);
1476 assert_eq!(
1477 session.events,
1478 [
1479 if visible { "visible" } else { "headless" },
1480 "read-headless"
1481 ]
1482 );
1483 }
1484 }
1485
1486 #[tokio::test]
1487 async fn cold_resume_opens_a_verified_inbox_without_blocking_on_the_background_watch() {
1488 let mut session = FakeLogin {
1489 signs_out_headless: true,
1490 ..Default::default()
1491 };
1492 assert!(!resume_flow(&mut session, false).await.unwrap());
1493 assert_eq!(session.events, ["headless", "read-headless"]);
1494 let mut refused = FakeLogin {
1495 fail_headless: true,
1496 ..Default::default()
1497 };
1498 assert!(resume_flow(&mut refused, false).await.is_err());
1499 assert_eq!(refused.events, ["headless", "read-headless"]);
1500 }
1501
1502 #[tokio::test]
1503 async fn the_background_watch_reports_sign_in_loss_without_restarting_the_browser() {
1504 let mut session = FakeLogin {
1505 signs_out_headless: true,
1506 ..Default::default()
1507 };
1508 assert!(matches!(
1509 check_resumed_session(&mut session).await,
1510 Err(AppError::Auth(_))
1511 ));
1512 assert_eq!(session.events, ["watch-headless"]);
1513 }
1514
1515 #[tokio::test]
1516 async fn login_verifies_reads_before_and_after_hiding() {
1517 let mut session = FakeLogin::default();
1518 assert!(!login_flow(&mut session).await.unwrap());
1519 assert_eq!(
1520 session.events,
1521 [
1522 "visible",
1523 "read-visible",
1524 "close",
1525 "headless",
1526 "read-headless",
1527 "watch-headless"
1528 ]
1529 );
1530 }
1531
1532 #[tokio::test]
1533 async fn sign_in_that_needs_the_window_reopens_it_visible() {
1534 let mut session = FakeLogin {
1535 signs_out_headless: true,
1536 ..Default::default()
1537 };
1538 assert!(login_flow(&mut session).await.unwrap());
1539 assert_eq!(
1540 session.events,
1541 [
1542 "visible",
1543 "read-visible",
1544 "close",
1545 "headless",
1546 "read-headless",
1547 "watch-headless",
1548 "close",
1549 "visible",
1550 "read-visible"
1551 ]
1552 );
1553 }
1554
1555 #[tokio::test]
1556 async fn sign_in_watch_refuses_a_closed_or_unsafe_browser() {
1557 use super::super::owner::tests::FakeSystem;
1558 for listeners in [vec![], vec![(30, false)]] {
1559 let system = FakeSystem {
1560 listeners,
1561 ..FakeSystem::teams()
1562 };
1563 let error = watch_sign_in(&system, 9224, Duration::from_secs(1))
1564 .await
1565 .unwrap_err();
1566 assert!(error.to_string().contains("became unavailable"));
1567 }
1568 }
1569
1570 #[tokio::test]
1571 async fn failed_visible_login_keeps_window_open() {
1572 let mut session = FakeLogin {
1573 fail_visible: true,
1574 ..Default::default()
1575 };
1576 let error = login_flow(&mut session).await.unwrap_err();
1577 assert!(
1578 error
1579 .to_string()
1580 .contains("visible browser and its profile were preserved")
1581 );
1582 assert_eq!(session.events, ["visible", "read-visible"]);
1583 }
1584
1585 #[tokio::test]
1586 async fn headless_sign_in_is_verified_before_success() {
1587 let mut session = FakeLogin {
1588 fail_headless: true,
1589 ..Default::default()
1590 };
1591 let error = login_flow(&mut session).await.unwrap_err();
1592 assert!(
1593 error
1594 .to_string()
1595 .contains("headless browser and saved profile were preserved")
1596 );
1597 assert_eq!(session.events.last(), Some(&"read-headless"));
1598 }
1599
1600 #[tokio::test]
1601 async fn failed_verified_close_prevents_headless_restart() {
1602 let mut session = FakeLogin {
1603 fail_close: true,
1604 ..Default::default()
1605 };
1606 assert!(login_flow(&mut session).await.is_err());
1607 assert_eq!(session.events, ["visible", "read-visible", "close"]);
1608 }
1609
1610 #[test]
1611 fn login_retries_loading_but_refuses_unsafe_or_ambiguous_sessions() {
1612 for code in ["no_target", "read_failed"] {
1613 assert!(!login_read_ready(&json!({"error":code})).unwrap());
1614 }
1615 assert!(!login_read_ready(&json!({"result":{"exceptionDetails":{}}})).unwrap());
1616 for code in ["account_ambiguous", "not_loopback", "metadata_probe_failed"] {
1617 assert!(login_read_ready(&json!({"error":code})).is_err());
1618 }
1619 assert!(
1620 login_read_ready(
1621 &json!({"result":{"result":{"value":"{\"items\":[],\"next\":null}"}}})
1622 )
1623 .unwrap()
1624 );
1625 }
1626
1627 #[test]
1628 fn browser_directories_refuse_shared_permissions_and_symlinks() {
1629 use std::os::unix::fs::{PermissionsExt, symlink};
1630 let temp = tempfile::tempdir().unwrap();
1631 let private = temp.path().join("private");
1632 prepare_directory(&private).unwrap();
1633 validate_directory(&private).unwrap();
1634 let link = temp.path().join("link");
1635 symlink(&private, &link).unwrap();
1636 assert!(prepare_directory(&link).is_err());
1637 std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o755)).unwrap();
1638 assert!(validate_directory(&private).is_err());
1639 }
1640
1641 #[test]
1642 fn core_launch_preserves_sign_in_and_skips_ui_startup() {
1643 let dir = Path::new("/tmp/core profile");
1644 let args = launch_args(dir, 9333, DesktopAccount::Personal, false, true);
1645 assert!(args.contains(&OsString::from("about:blank")));
1646 assert!(args.contains(&OsString::from(
1647 "--js-flags=--lite-mode --optimize-for-size"
1648 )));
1649 assert!(arguments_match(&args, &profile(dir), 9333));
1650 let visible = launch_args(dir, 9333, DesktopAccount::Personal, true, true);
1651 assert!(!visible.contains(&OsString::from("--headless=new")));
1652 if cfg!(target_os = "macos") {
1653 assert!(visible.contains(&OsString::from("--use-mock-keychain")));
1654 }
1655 }
1656
1657 #[test]
1658 fn launch_is_isolated_and_headless_unless_signing_in() {
1659 let dir = Path::new("/tmp/teams profile");
1660 let headless = launch_args(dir, 9333, DesktopAccount::Personal, false, false);
1661 assert!(headless.contains(&OsString::from("--headless=new")));
1662 assert!(headless.contains(&OsString::from("--app=https://teams.live.com")));
1663 assert!(arguments_match(&headless, &profile(dir), 9333));
1664 assert!(!arguments_match(
1665 &headless,
1666 Path::new("/tmp/normal-browser"),
1667 9333
1668 ));
1669 assert!(!arguments_match(&headless, &profile(dir), 9222));
1670 let visible = launch_args(dir, 9333, DesktopAccount::WorkSchool, true, false);
1671 assert!(!visible.contains(&OsString::from("--headless=new")));
1672 assert!(visible.contains(&OsString::from("--app=https://teams.cloud.microsoft")));
1673 let mut conflicting = headless.clone();
1674 conflicting.push("--user-data-dir=/tmp/normal-browser".into());
1675 assert!(!arguments_match(&conflicting, &profile(dir), 9333));
1676 let mut renderer = headless;
1677 renderer.push("--type=renderer".into());
1678 assert!(!arguments_match(&renderer, &profile(dir), 9333));
1679 }
1680
1681 #[test]
1682 fn a_hung_browser_version_check_times_out_and_reaps_its_process() {
1683 use std::os::unix::fs::PermissionsExt;
1684 let dir = tempfile::tempdir().unwrap();
1685 let browser = dir.path().join("browser");
1686 std::fs::write(
1687 &browser,
1688 "#!/bin/sh\nprintf '%s' \"$$\" > \"${0}.pid\"\nexec sleep 30\n",
1689 )
1690 .unwrap();
1691 std::fs::set_permissions(&browser, std::fs::Permissions::from_mode(0o700)).unwrap();
1692 let started = std::time::Instant::now();
1693 let error = browser_version_with_timeout(&browser, Duration::from_millis(100)).unwrap_err();
1694 assert!(error.to_string().contains("timed out"));
1695 assert!(started.elapsed() < Duration::from_secs(2));
1696 let pid: i32 = std::fs::read_to_string(dir.path().join("browser.pid"))
1697 .unwrap()
1698 .parse()
1699 .unwrap();
1700 assert_eq!(unsafe { libc::kill(pid, 0) }, -1);
1701 assert_eq!(
1702 std::io::Error::last_os_error().raw_os_error(),
1703 Some(libc::ESRCH)
1704 );
1705 }
1706
1707 #[test]
1708 fn browser_control_socket_never_follows_an_advertised_remote_host() {
1709 let socket = |url: &str| json!({"webSocketDebuggerUrl":url});
1710 assert_eq!(
1711 browser_id(
1712 &socket("ws://localhost:9333/devtools/browser/abcdef12-3456"),
1713 9333
1714 )
1715 .unwrap(),
1716 "abcdef12-3456"
1717 );
1718 for url in [
1719 "ws://example.com:9333/devtools/browser/abcdef12",
1720 "ws://127.0.0.1:9222/devtools/browser/abcdef12",
1721 "ws://user@127.0.0.1:9333/devtools/browser/abcdef12",
1722 "ws://127.0.0.1:9333/devtools/page/abcdef12",
1723 "ws://127.0.0.1:9333/devtools/browser/../../x",
1724 "ws://127.0.0.1:9333/devtools/browser/abcdef12?secret=x",
1725 ] {
1726 assert!(browser_id(&socket(url), 9333).is_err(), "{url}");
1727 }
1728 }
1729
1730 #[test]
1731 fn corrupt_or_wrong_port_state_is_preserved() {
1732 let dir = tempfile::tempdir().unwrap();
1733 let path = dir.path().join("connection.json");
1734 std::fs::write(&path, b"corrupt").unwrap();
1735 assert!(load(dir.path(), 9333).is_err());
1736 assert_eq!(std::fs::read(&path).unwrap(), b"corrupt");
1737 let state = State {
1738 version: 1,
1739 port: 9333,
1740 owner: None,
1741 visible: true,
1742 core: false,
1743 phase: "starting".into(),
1744 lifetime: lifetime::Lifetime::default(),
1745 children: vec![],
1746 };
1747 save(dir.path(), &state).unwrap();
1748 assert!(load(dir.path(), 9222).is_err());
1749 assert!(load(dir.path(), 9333).unwrap().is_some());
1750 }
1751
1752 #[cfg(target_os = "linux")]
1753 #[test]
1754 fn linux_sockets_include_unsafe_and_foreign_listeners() {
1755 let table = "header\n 0: 0100007F:2475 00000000:0000 0A 0:0 0:0 0 1000 0 42\n 1: 00000000:2475 00000000:0000 0A 0:0 0:0 0 2000 0 43\n 2: 0100007F:2475 00000000:0000 01 0:0 0:0 0 1000 0 44\n";
1756 let sockets = system::parse_sockets(table, 9333, false).unwrap();
1757 assert_eq!(sockets.len(), 2);
1758 assert!(sockets[0].loopback);
1759 assert!(!sockets[1].loopback);
1760 assert!(system::parse_sockets("header\nbroken", 9333, false).is_err());
1761 let v6 = "header\n 0: 00000000000000000000000001000000:2475 0:0 0A 0:0 0:0 0 1000 0 42\n";
1762 assert!(system::parse_sockets(v6, 9333, true).unwrap()[0].loopback);
1763 }
1764
1765 #[cfg(target_os = "linux")]
1766 #[test]
1767 fn linux_identity_and_live_socket_ownership_are_checked() {
1768 let pid = std::process::id();
1769 let (identity, _) = system::process(pid).unwrap();
1770 assert_eq!(identity.executable, std::env::current_exe().unwrap());
1771 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1772 let sockets = system::listeners(listener.local_addr().unwrap().port()).unwrap();
1773 assert!(!sockets.is_empty());
1774 assert!(system::owns_sockets(pid, &sockets));
1775 assert!(!system::owns_sockets(0, &sockets));
1776 assert!(system::start_time("broken stat").is_err());
1777 let stat = format!("1 (browser ) name) S {} 1234 0", "0 ".repeat(18));
1779 assert_eq!(system::start_time(&stat).unwrap(), "1234");
1780 }
1781
1782 #[cfg(target_os = "linux")]
1783 #[tokio::test]
1784 async fn a_verified_browser_that_hangs_is_stopped() {
1785 let base = tempfile::tempdir().unwrap();
1786 let dir = base.path().join("9333");
1787 prepare_directory(&dir).unwrap();
1788 prepare_directory(&profile(&dir)).unwrap();
1789 let mut data = OsString::from("--user-data-dir=");
1790 data.push(profile(&dir));
1791 let mut child = Command::new("/bin/sh")
1793 .args(["-c", "sleep 30; :", "browser"])
1794 .arg(data)
1795 .arg("--remote-debugging-port=9333")
1796 .spawn()
1797 .unwrap();
1798 let (owner, _) = system::process(child.id()).unwrap();
1799 let state = State {
1800 version: 1,
1801 port: 9333,
1802 owner: Some(owner),
1803 visible: false,
1804 core: false,
1805 phase: "active".into(),
1806 lifetime: lifetime::Lifetime::default(),
1807 children: vec![],
1808 };
1809 assert!(owned(&state, &dir));
1810 let reaper = std::thread::spawn(move || child.wait());
1811 stop(&state, &dir).await.unwrap();
1812 assert!(!owned(&state, &dir));
1813 assert!(!reaper.join().unwrap().unwrap().success());
1814 }
1815
1816 #[cfg(target_os = "linux")]
1817 #[test]
1818 fn linux_arguments_survive_chrome_process_title_rewrite() {
1819 let profile = Path::new("/home/me/teams data/9333/profile");
1820 let launched = b"/opt/my chrome/chrome\0--user-data-dir=/home/me/teams data/9333/profile\0--remote-debugging-port=9333\0--app=https://teams.live.com\0";
1821 let mut title = launched.map(|b| if b == 0 { b' ' } else { b });
1822 *title.last_mut().unwrap() = 0;
1823 for cmdline in [launched.as_slice(), title.as_slice()] {
1824 let args = system::arguments(cmdline);
1825 assert_eq!(args[0], "/opt/my chrome/chrome");
1826 assert!(arguments_match(&args, profile, 9333));
1827 assert!(!arguments_match(&args, profile, 9222));
1828 }
1829 }
1830
1831 #[cfg(target_os = "macos")]
1832 #[test]
1833 fn macos_launch_arguments_keep_paths_with_spaces_and_exclude_environment() {
1834 let args = [
1835 b"Chrome".as_slice(),
1836 b"--user-data-dir=/tmp/teams profile",
1837 b"--remote-debugging-port=9333",
1838 ];
1839 let mut bytes = (args.len() as i32).to_ne_bytes().to_vec();
1840 bytes.extend_from_slice(b"/Applications/Google Chrome\0\0\0");
1841 for arg in args {
1842 bytes.extend_from_slice(arg);
1843 bytes.push(0);
1844 }
1845 bytes.extend_from_slice(b"SECRET=not-an-argument\0");
1846 let parsed = system::parse_args(&bytes).unwrap();
1847 assert_eq!(parsed.len(), 3);
1848 assert!(arguments_match(
1849 &parsed,
1850 Path::new("/tmp/teams profile"),
1851 9333
1852 ));
1853 assert!(system::parse_args(b"bad").is_err());
1854 }
1855}