1use std::collections::HashMap;
7use std::os::fd::AsRawFd;
8use std::os::unix::fs::PermissionsExt;
9use std::os::unix::net::UnixListener;
10use std::path::PathBuf;
11use std::process::{Child, Command, Stdio};
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::sync::{Arc, Mutex, OnceLock, Weak};
14use std::thread::JoinHandle;
15use std::time::Duration;
16
17use polling::Poller;
18use wayland_backend::server::Backend as SBackend;
19
20mod capture;
21mod input;
22mod interfaces;
23mod proxy;
24
25pub use capture::CapturedFrame;
26
27use proxy::{Conn, ServerState};
28
29use crate::SeatError;
30
31#[allow(dead_code)]
33pub(crate) enum Action {
34 Ping,
36}
37
38pub struct SeatApp {
40 #[allow(dead_code)]
42 pub pid: u32,
43 conn: Arc<Mutex<Conn>>,
44 poller: Arc<Poller>,
45 cleanup_paths: Mutex<CleanupPaths>,
46}
47
48pub struct XwaylandBridge {
52 child: Option<Child>,
53 runtime_dir: PathBuf,
54 display: String,
55 xauthority: PathBuf,
56}
57
58impl XwaylandBridge {
59 pub fn display(&self) -> &str {
61 &self.display
62 }
63
64 pub fn xauthority(&self) -> &std::path::Path {
66 &self.xauthority
67 }
68
69 pub fn configure_command<'a>(&self, command: &'a mut Command) -> &'a mut Command {
73 command
74 .env("DISPLAY", &self.display)
75 .env("XAUTHORITY", &self.xauthority)
76 .env("XDG_SESSION_TYPE", "x11")
77 .env_remove("WAYLAND_DISPLAY")
78 }
79
80 fn stop(&mut self) {
81 if let Some(child) = self.child.as_mut() {
82 let _ = child.kill();
83 let _ = child.wait();
84 }
85 self.child = None;
86 if !self.runtime_dir.as_os_str().is_empty() {
87 let _ = std::fs::remove_dir_all(&self.runtime_dir);
88 }
89 }
90}
91
92impl Drop for XwaylandBridge {
93 fn drop(&mut self) {
94 self.stop();
95 }
96}
97
98#[derive(Default)]
99struct CleanupPaths {
100 closed: bool,
101 paths: Vec<PathBuf>,
102}
103
104impl CleanupPaths {
105 fn register(&mut self, path: PathBuf) -> Option<PathBuf> {
106 if self.closed {
107 Some(path)
108 } else {
109 self.paths.push(path);
110 None
111 }
112 }
113
114 fn close(&mut self) -> Vec<PathBuf> {
115 self.closed = true;
116 std::mem::take(&mut self.paths)
117 }
118}
119
120fn remove_cleanup_directories(paths: impl IntoIterator<Item = PathBuf>) {
121 for path in paths {
122 let _ = std::fs::remove_dir_all(path);
123 }
124}
125
126impl SeatApp {
127 #[allow(dead_code)]
129 pub(crate) fn send_action(&self, action: Action) {
130 {
131 let mut conn = self.conn.lock().unwrap();
132 conn.actions.push(action);
133 }
134 let _ = self.poller.notify();
135 }
136
137 #[allow(dead_code)]
139 pub fn capture_frame(&self) -> Result<CapturedFrame, SeatError> {
140 let conn = self.conn.lock().unwrap();
141 capture::capture_frame(&conn).map_err(SeatError::Capture)
142 }
143
144 pub fn has_interactive_frame(&self) -> bool {
147 match self.capture_frame() {
148 Ok(frame) => {
149 if std::env::var_os("AGENT_SEAT_DEBUG").is_some() {
150 eprintln!(
151 "agent seat: candidate pid={} primary frame={}x{}",
152 self.pid, frame.width, frame.height
153 );
154 }
155 frame.width >= 160
156 && frame.height >= 120
157 && u64::from(frame.width) * u64::from(frame.height) >= 65_536
158 }
159 Err(_) => false,
160 }
161 }
162
163 #[allow(dead_code)]
165 pub fn inject_click(&self, x: f64, y: f64, button: u32, count: u32) -> Result<(), SeatError> {
166 {
167 let mut conn = self.conn.lock().unwrap();
168 input::inject_click(&mut conn, x, y, button, count).map_err(SeatError::Input)?;
169 }
170 let _ = self.poller.notify();
171 Ok(())
172 }
173
174 pub fn inject_click_with_modifiers(
176 &self,
177 x: f64,
178 y: f64,
179 button: u32,
180 count: u32,
181 modifiers: Option<&str>,
182 ) -> Result<(), SeatError> {
183 {
184 let mut conn = self.conn.lock().unwrap();
185 input::inject_click_with_modifiers(&mut conn, x, y, button, count, modifiers)
186 .map_err(SeatError::Input)?;
187 }
188 let _ = self.poller.notify();
189 Ok(())
190 }
191
192 #[allow(dead_code)]
194 pub fn inject_scroll(&self, x: f64, y: f64, dx: i32, dy: i32) -> Result<(), SeatError> {
195 {
196 let mut conn = self.conn.lock().unwrap();
197 input::inject_scroll(&mut conn, x, y, dx, dy).map_err(SeatError::Input)?;
198 }
199 let _ = self.poller.notify();
200 Ok(())
201 }
202
203 #[allow(dead_code)]
205 pub fn inject_key_raw(&self, keycode: u32, pressed: bool) -> Result<(), SeatError> {
206 {
207 let mut conn = self.conn.lock().unwrap();
208 input::inject_key_raw(&mut conn, keycode, pressed).map_err(SeatError::Input)?;
209 }
210 let _ = self.poller.notify();
211 Ok(())
212 }
213
214 pub fn inject_key_combo(&self, combination: &str) -> Result<(), SeatError> {
216 {
217 let mut conn = self.conn.lock().unwrap();
218 input::inject_key_combo(&mut conn, combination).map_err(SeatError::Input)?;
219 }
220 let _ = self.poller.notify();
221 Ok(())
222 }
223
224 pub fn inject_drag(
226 &self,
227 from_x: f64,
228 from_y: f64,
229 to_x: f64,
230 to_y: f64,
231 ) -> Result<(), SeatError> {
232 {
233 let mut conn = self.conn.lock().unwrap();
234 input::inject_drag(&mut conn, from_x, from_y, to_x, to_y).map_err(SeatError::Input)?;
235 }
236 let _ = self.poller.notify();
237 Ok(())
238 }
239
240 #[allow(dead_code)]
242 pub fn inject_text(&self, text: &str) -> Result<(), SeatError> {
243 {
244 let mut conn = self.conn.lock().unwrap();
245 input::inject_text(&mut conn, text).map_err(SeatError::Input)?;
246 }
247 let _ = self.poller.notify();
248 Ok(())
249 }
250
251 #[allow(dead_code)]
253 pub(crate) fn add_cleanup_path(&self, path: PathBuf) {
254 let remove_now = self.cleanup_paths.lock().unwrap().register(path);
255 if let Some(path) = remove_now {
256 remove_cleanup_directories([path]);
257 }
258 }
259
260 fn cleanup_registered_paths(&self) {
261 let paths = self.cleanup_paths.lock().unwrap().close();
262 remove_cleanup_directories(paths);
263 }
264}
265
266impl Drop for SeatApp {
267 fn drop(&mut self) {
268 self.cleanup_registered_paths();
269 }
270}
271
272pub struct AgentSeat {
274 socket_name: String,
275 socket_path: PathBuf,
276 listener: UnixListener,
277 upstream_socket: PathBuf,
278 apps: Mutex<Vec<Arc<SeatApp>>>,
282 bound_apps: Mutex<HashMap<u32, Weak<SeatApp>>>,
286 stopping: Arc<AtomicBool>,
287 socket_removed: AtomicBool,
288 accept_thread: Mutex<Option<JoinHandle<()>>>,
289 proxy_threads: Mutex<Vec<JoinHandle<()>>>,
290 bridge_threads: Mutex<Vec<JoinHandle<()>>>,
291}
292
293static SEAT: OnceLock<Mutex<Option<Arc<AgentSeat>>>> = OnceLock::new();
294
295fn seat_slot() -> &'static Mutex<Option<Arc<AgentSeat>>> {
296 SEAT.get_or_init(|| Mutex::new(None))
297}
298
299pub fn seat() -> Result<Arc<AgentSeat>, SeatError> {
302 let mut slot = seat_slot().lock().unwrap();
303 if let Some(existing) = slot.as_ref() {
304 return Ok(existing.clone());
305 }
306 let new_seat = AgentSeat::create()?;
307 *slot = Some(new_seat.clone());
308 Ok(new_seat)
309}
310
311pub fn shutdown() {
314 let mut slot = seat_slot().lock().unwrap();
317 if let Some(seat) = slot.take() {
318 seat.shutdown_inner();
319 }
320}
321
322#[allow(dead_code)]
325pub fn available() -> bool {
326 seat().is_ok()
327}
328
329fn cleanup_stale_seat_sockets(runtime_dir: &str) {
331 let Ok(entries) = std::fs::read_dir(runtime_dir) else {
332 return;
333 };
334 let my_pid = std::process::id();
335 for entry in entries.flatten() {
336 let Some(name) = entry.file_name().to_str().map(str::to_string) else {
337 continue;
338 };
339 let Some(remainder) = name.strip_prefix("agent-seat-") else {
340 continue;
341 };
342 let pid_str = remainder.split('-').next().unwrap_or_default();
343 let Ok(pid) = pid_str.parse::<u32>() else {
344 continue;
345 };
346 if pid == my_pid {
347 continue;
348 }
349 if !std::path::Path::new(&format!("/proc/{pid}")).exists() {
351 let _ = std::fs::remove_file(entry.path());
352 }
353 }
354}
355
356fn set_owner_only_socket_permissions(path: &std::path::Path) -> std::io::Result<()> {
357 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
358}
359
360impl AgentSeat {
361 pub fn create() -> Result<Arc<Self>, SeatError> {
363 let seat = Arc::new(Self::new_unstarted()?);
364 seat.start()?;
365 Ok(seat)
366 }
367
368 fn new_unstarted() -> Result<Self, SeatError> {
369 let runtime_dir = std::env::var("XDG_RUNTIME_DIR")
370 .ok()
371 .filter(|v| !v.is_empty())
372 .ok_or(SeatError::MissingRuntimeDir)?;
373 let upstream_display = std::env::var("WAYLAND_DISPLAY")
374 .ok()
375 .filter(|v| !v.is_empty())
376 .ok_or(SeatError::NoWaylandSession)?;
377 let upstream_socket = if upstream_display.starts_with('/') {
378 PathBuf::from(&upstream_display)
379 } else {
380 PathBuf::from(&runtime_dir).join(&upstream_display)
381 };
382 if !upstream_socket.exists() {
383 return Err(SeatError::NoWaylandSession);
384 }
385
386 cleanup_stale_seat_sockets(&runtime_dir);
389
390 let socket_name = format!(
392 "agent-seat-{}-{}",
393 std::process::id(),
394 uuid::Uuid::new_v4().simple()
395 );
396 let socket_path = PathBuf::from(&runtime_dir).join(&socket_name);
397 let _ = std::fs::remove_file(&socket_path);
398 let listener = UnixListener::bind(&socket_path).map_err(|e| {
399 SeatError::SocketCreate(format!("could not bind the agent seat socket: {e}"))
400 })?;
401 if let Err(e) = set_owner_only_socket_permissions(&socket_path) {
402 let _ = std::fs::remove_file(&socket_path);
403 return Err(SeatError::SocketCreate(format!(
404 "could not restrict the agent seat socket permissions: {e}"
405 )));
406 }
407 if let Err(e) = listener.set_nonblocking(true) {
408 let _ = std::fs::remove_file(&socket_path);
409 return Err(SeatError::SocketCreate(format!(
410 "could not make the seat socket nonblocking: {e}"
411 )));
412 }
413
414 Ok(Self {
415 socket_name,
416 socket_path,
417 listener,
418 upstream_socket,
419 apps: Mutex::new(Vec::new()),
420 bound_apps: Mutex::new(HashMap::new()),
421 stopping: Arc::new(AtomicBool::new(false)),
422 socket_removed: AtomicBool::new(false),
423 accept_thread: Mutex::new(None),
424 proxy_threads: Mutex::new(Vec::new()),
425 bridge_threads: Mutex::new(Vec::new()),
426 })
427 }
428
429 fn start(self: &Arc<Self>) -> Result<(), SeatError> {
430 let accept_seat = Arc::downgrade(self);
431 let handle = std::thread::Builder::new()
432 .name("agent-seat-accept".into())
433 .spawn(move || {
434 while let Some(seat) = accept_seat.upgrade() {
435 if seat.stopping.load(Ordering::Acquire) {
436 break;
437 }
438 seat.accept_once();
439 }
440 })
441 .map_err(|e| {
442 SeatError::Process(format!("could not start the seat accept loop: {e}"))
443 })?;
444 *self.accept_thread.lock().unwrap() = Some(handle);
445 Ok(())
446 }
447
448 pub fn socket_name(&self) -> String {
450 self.socket_name.clone()
451 }
452
453 pub fn configure_command<'a>(&self, command: &'a mut Command) -> &'a mut Command {
455 command.env("WAYLAND_DISPLAY", &self.socket_name)
456 }
457
458 pub fn close(&self) {
461 self.shutdown_inner();
462 }
463
464 pub fn start_xwayland_bridge(
469 &self,
470 width: u16,
471 height: u16,
472 ) -> Result<XwaylandBridge, SeatError> {
473 if width < 160 || height < 120 {
474 return Err(SeatError::Xwayland(
475 "bridge geometry must be at least 160x120".to_string(),
476 ));
477 }
478 let xwayland = crate::process::find_executable("Xwayland").ok_or_else(|| {
479 SeatError::Xwayland("XWayland is not installed for compatibility fallback".to_string())
480 })?;
481 let display_num = crate::process::pick_free_display(std::path::Path::new("/tmp/.X11-unix"))
482 .ok_or_else(|| {
483 SeatError::Xwayland(
484 "no free X display number for the compatibility bridge".to_string(),
485 )
486 })?;
487 let display = format!(":{display_num}");
488 let runtime_base = self.socket_path.parent().ok_or_else(|| {
489 SeatError::Custom("agent seat socket has no runtime directory".to_string())
490 })?;
491 let runtime_dir = runtime_base.join(format!(
492 "agent-seat-xwayland-{}-{}",
493 std::process::id(),
494 uuid::Uuid::new_v4()
495 ));
496 std::fs::create_dir(&runtime_dir).map_err(|e| {
497 SeatError::Xwayland(format!("could not create XWayland runtime directory: {e}"))
498 })?;
499 if let Err(error) =
500 std::fs::set_permissions(&runtime_dir, std::fs::Permissions::from_mode(0o700))
501 {
502 let _ = std::fs::remove_dir(&runtime_dir);
503 return Err(SeatError::Xwayland(format!(
504 "could not restrict XWayland runtime directory: {error}"
505 )));
506 }
507
508 let xauthority = runtime_dir.join("Xauthority");
509 let cookie = format!(
510 "{}{}",
511 uuid::Uuid::new_v4().simple(),
512 uuid::Uuid::new_v4().simple()
513 );
514 let auth_status = match Command::new("xauth")
515 .args(["-f", xauthority.to_string_lossy().as_ref(), "add"])
516 .arg(&display)
517 .args(["MIT-MAGIC-COOKIE-1", &cookie])
518 .stdin(Stdio::null())
519 .stdout(Stdio::null())
520 .stderr(Stdio::null())
521 .status()
522 {
523 Ok(status) => status,
524 Err(error) => {
525 let _ = std::fs::remove_dir_all(&runtime_dir);
526 return Err(SeatError::Xauth(format!(
527 "could not create XWayland authority file: {error}"
528 )));
529 }
530 };
531 if !auth_status.success() {
532 let _ = std::fs::remove_dir_all(&runtime_dir);
533 return Err(SeatError::Xauth(format!(
534 "xauth could not create credentials for display {display}"
535 )));
536 }
537 if let Err(error) =
538 std::fs::set_permissions(&xauthority, std::fs::Permissions::from_mode(0o600))
539 {
540 let _ = std::fs::remove_dir_all(&runtime_dir);
541 return Err(SeatError::Xauth(format!(
542 "could not restrict XWayland authority file: {error}"
543 )));
544 }
545
546 let mut command = Command::new(xwayland);
547 let stderr = if std::env::var_os("AGENT_SEAT_DEBUG").is_some() {
548 Stdio::inherit()
549 } else {
550 Stdio::null()
551 };
552 let geometry = format!("{width}x{height}");
553 command
554 .arg(&display)
555 .args([
556 "-auth",
557 xauthority.to_string_lossy().as_ref(),
558 "-nolisten",
559 "tcp",
560 "-terminate",
561 "10",
562 "-shm",
563 "-geometry",
564 &geometry,
565 ])
566 .env("WAYLAND_DISPLAY", &self.socket_name)
567 .env_remove("DISPLAY")
568 .stdin(Stdio::null())
569 .stdout(Stdio::null())
570 .stderr(stderr);
571 let mut child = match crate::process::spawn_owned_child(&mut command) {
572 Ok(child) => child,
573 Err(error) => {
574 let _ = std::fs::remove_dir_all(&runtime_dir);
575 return Err(SeatError::Xwayland(format!(
576 "could not start XWayland compatibility bridge: {error}"
577 )));
578 }
579 };
580 let socket = PathBuf::from(format!("/tmp/.X11-unix/X{display_num}"));
581 let started = std::time::Instant::now();
582 while started.elapsed() < Duration::from_secs(5) {
583 if socket.exists() {
584 return Ok(XwaylandBridge {
585 child: Some(child),
586 runtime_dir,
587 display,
588 xauthority,
589 });
590 }
591 if child.try_wait().ok().flatten().is_some() {
592 let _ = std::fs::remove_dir_all(&runtime_dir);
593 return Err(SeatError::Xwayland(
594 "XWayland compatibility bridge exited during startup".to_string(),
595 ));
596 }
597 std::thread::sleep(Duration::from_millis(50));
598 }
599 let _ = child.kill();
600 let _ = child.wait();
601 let _ = std::fs::remove_dir_all(&runtime_dir);
602 Err(SeatError::Xwayland(
603 "XWayland compatibility bridge did not create its X socket".to_string(),
604 ))
605 }
606
607 pub fn adopt_xwayland_bridge(&self, mut bridge: XwaylandBridge) -> Result<(), SeatError> {
611 let child = bridge.child.take().ok_or_else(|| {
612 SeatError::Xwayland("XWayland bridge process was already transferred".to_string())
613 })?;
614 let runtime_dir = bridge.runtime_dir.clone();
615 let shared = Arc::new(Mutex::new(Some(child)));
616 let reaper_child = shared.clone();
617 let handle = match std::thread::Builder::new()
618 .name("agent-seat-xwayland-reaper".to_string())
619 .spawn(move || {
620 if let Some(mut child) = reaper_child.lock().unwrap().take() {
621 let _ = child.wait();
622 }
623 let _ = std::fs::remove_dir_all(runtime_dir);
624 }) {
625 Ok(handle) => handle,
626 Err(error) => {
627 if let Some(mut child) = shared.lock().unwrap().take() {
628 let _ = child.kill();
629 let _ = child.wait();
630 }
631 return Err(SeatError::Xwayland(format!(
632 "could not start XWayland reaper: {error}"
633 )));
634 }
635 };
636 bridge.runtime_dir.clear();
639 self.bridge_threads.lock().unwrap().push(handle);
640 Ok(())
641 }
642
643 #[allow(dead_code)]
645 pub fn app(&self, pid: u32) -> Option<Arc<SeatApp>> {
646 if let Some(bound) = self
647 .bound_apps
648 .lock()
649 .unwrap()
650 .get(&pid)
651 .and_then(Weak::upgrade)
652 {
653 return Some(bound);
654 }
655 let candidates: Vec<_> = self
656 .apps
657 .lock()
658 .unwrap()
659 .iter()
660 .filter(|app| app.pid == pid)
661 .cloned()
662 .collect();
663 candidates
666 .iter()
667 .rev()
668 .find(|app| app.has_interactive_frame())
669 .cloned()
670 .or_else(|| candidates.last().cloned())
671 }
672
673 pub fn bind_app(&self, app: &Arc<SeatApp>) {
675 self.bind_app_for_pid(app.pid, app);
676 }
677
678 pub fn bind_app_for_pid(&self, application_pid: u32, app: &Arc<SeatApp>) {
682 self.bound_apps
683 .lock()
684 .unwrap()
685 .insert(application_pid, Arc::downgrade(app));
686 }
687
688 pub fn most_recent_app(&self) -> Option<Arc<SeatApp>> {
692 self.apps.lock().unwrap().last().cloned()
693 }
694
695 pub fn connected_pids(&self) -> std::collections::HashSet<u32> {
697 self.apps
698 .lock()
699 .unwrap()
700 .iter()
701 .map(|app| app.pid)
702 .collect()
703 }
704
705 pub fn app_count(&self) -> usize {
707 self.apps.lock().unwrap().len()
708 }
709
710 pub fn new_capturable_app(
714 &self,
715 before: &std::collections::HashSet<u32>,
716 ) -> Option<Arc<SeatApp>> {
717 let candidates: Vec<_> = self
718 .apps
719 .lock()
720 .unwrap()
721 .iter()
722 .filter(|app| !before.contains(&app.pid))
723 .cloned()
724 .collect();
725 candidates
726 .into_iter()
727 .rev()
728 .find(|app| app.has_interactive_frame())
729 }
730
731 pub fn wait_new_capturable_app(
734 &self,
735 before: &std::collections::HashSet<u32>,
736 timeout: Duration,
737 ) -> Option<Arc<SeatApp>> {
738 let start = std::time::Instant::now();
739 while start.elapsed() < timeout {
740 if let Some(app) = self.new_capturable_app(before) {
741 return Some(app);
742 }
743 std::thread::sleep(Duration::from_millis(50));
744 }
745 None
746 }
747
748 fn accept_once(self: &Arc<Self>) {
749 match self.listener.accept() {
750 Ok((stream, _addr)) => {
751 let pid = match trusted_peer_pid(&stream) {
752 Ok(pid) => pid,
753 Err(e) => {
754 eprintln!("agent seat: rejected connection: {e}");
755 return;
756 }
757 };
758 let upstream = match std::os::unix::net::UnixStream::connect(&self.upstream_socket)
759 {
760 Ok(upstream) => upstream,
761 Err(e) => {
762 eprintln!("agent seat: cannot reach compositor: {e}");
763 return;
764 }
765 };
766 match proxy::setup(stream, upstream) {
767 Ok((server, conn)) if !self.stopping.load(Ordering::Acquire) => {
768 self.spawn_proxy_loop(server, conn, pid);
769 }
770 Ok(_) => {}
771 Err(e) => eprintln!("agent seat: proxy setup failed: {e}"),
772 }
773 }
774 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
775 std::thread::sleep(Duration::from_millis(20));
776 }
777 Err(e) => {
778 eprintln!("agent seat: accept failed: {e}");
779 std::thread::sleep(Duration::from_millis(100));
780 }
781 }
782 }
783
784 fn spawn_proxy_loop(
785 self: &Arc<Self>,
786 mut server: SBackend<ServerState>,
787 conn: Arc<Mutex<Conn>>,
788 pid: u32,
789 ) {
790 let Ok(poller) = Poller::new().map(Arc::new) else {
791 return;
792 };
793 let server_fd = server.poll_fd().as_raw_fd();
794 let upstream_fd = conn.lock().unwrap().upstream.poll_fd().as_raw_fd();
795 let _ = unsafe {
798 poller.add_with_mode(
799 server_fd,
800 polling::Event::readable(1),
801 polling::PollMode::Level,
802 )
803 };
804 let _ = unsafe {
807 poller.add_with_mode(
808 upstream_fd,
809 polling::Event::readable(2),
810 polling::PollMode::Level,
811 )
812 };
813
814 let app = Arc::new(SeatApp {
815 pid,
816 conn: conn.clone(),
817 poller: poller.clone(),
818 cleanup_paths: Mutex::new(CleanupPaths::default()),
819 });
820 self.apps.lock().unwrap().push(app.clone());
821
822 let upstream = conn.lock().unwrap().upstream.clone();
823 let stopping = self.stopping.clone();
824 let cleanup = ProxyLoopCleanup {
825 seat: Arc::downgrade(self),
826 app: app.clone(),
827 conn: conn.clone(),
828 poller: poller.clone(),
829 };
830 match std::thread::Builder::new()
831 .name(format!("agent-seat-proxy-{pid}"))
832 .spawn(move || {
833 let cleanup = cleanup;
834 run_loop(
835 &mut server,
836 &cleanup.conn,
837 &upstream,
838 &cleanup.poller,
839 &stopping,
840 );
841 }) {
842 Ok(handle) => self.proxy_threads.lock().unwrap().push(handle),
843 Err(e) => {
844 eprintln!("agent seat: could not start proxy loop for pid {pid}: {e}");
845 close_connection(&conn, &poller);
846 app.cleanup_registered_paths();
847 self.remove_app(&app);
848 }
849 }
850 }
851
852 fn remove_app(&self, expected: &Arc<SeatApp>) {
853 remove_same_arc(&mut self.apps.lock().unwrap(), expected);
854 self.bound_apps.lock().unwrap().retain(|_, bound| {
855 bound
856 .upgrade()
857 .is_some_and(|current| !Arc::ptr_eq(¤t, expected))
858 });
859 }
860
861 fn remove_socket_path(&self) {
862 if self.socket_removed.swap(true, Ordering::AcqRel) {
863 return;
864 }
865 if let Err(e) = std::fs::remove_file(&self.socket_path) {
866 if e.kind() != std::io::ErrorKind::NotFound {
867 eprintln!("agent seat: could not remove socket: {e}");
868 }
869 }
870 }
871
872 fn shutdown_inner(&self) {
873 self.stopping.store(true, Ordering::Release);
874 self.remove_socket_path();
875
876 self.close_active_connections();
879 if let Some(handle) = self.accept_thread.lock().unwrap().take() {
880 join_thread(handle, "accept");
881 }
882
883 self.close_active_connections();
885 let handles = std::mem::take(&mut *self.proxy_threads.lock().unwrap());
886 for handle in handles {
887 join_thread(handle, "proxy");
888 }
889 let bridge_handles = std::mem::take(&mut *self.bridge_threads.lock().unwrap());
890 for handle in bridge_handles {
891 join_thread(handle, "XWayland bridge");
892 }
893 self.apps.lock().unwrap().clear();
894 self.bound_apps.lock().unwrap().clear();
895 }
896
897 fn close_active_connections(&self) {
898 let apps = self.apps.lock().unwrap().clone();
899 for app in apps {
900 close_connection(&app.conn, &app.poller);
901 }
902 }
903}
904
905impl Drop for AgentSeat {
906 fn drop(&mut self) {
907 self.shutdown_inner();
908 }
909}
910
911#[derive(Clone, Copy, Debug, PartialEq, Eq)]
912struct PeerCredentials {
913 pid: u32,
914 uid: libc::uid_t,
915}
916
917fn peer_credentials(stream: &std::os::unix::net::UnixStream) -> Result<PeerCredentials, SeatError> {
919 use std::os::fd::AsFd;
920 let mut ucred: libc::ucred = unsafe { std::mem::zeroed() };
923 let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
924 let ret = unsafe {
927 libc::getsockopt(
928 stream.as_fd().as_raw_fd(),
929 libc::SOL_SOCKET,
930 libc::SO_PEERCRED,
931 &mut ucred as *mut _ as *mut libc::c_void,
932 &mut len,
933 )
934 };
935 if ret != 0 {
936 return Err(SeatError::PeerCredential(format!(
937 "could not read SO_PEERCRED: {}",
938 std::io::Error::last_os_error()
939 )));
940 }
941 if len as usize != std::mem::size_of::<libc::ucred>() || ucred.pid <= 0 {
942 return Err(SeatError::PeerCredential(
943 "SO_PEERCRED returned invalid credentials".to_string(),
944 ));
945 }
946 Ok(PeerCredentials {
947 pid: ucred.pid as u32,
948 uid: ucred.uid,
949 })
950}
951
952fn validate_peer(
953 credentials: PeerCredentials,
954 expected_uid: libc::uid_t,
955) -> Result<u32, SeatError> {
956 if credentials.uid != expected_uid {
957 return Err(SeatError::PeerCredential(format!(
958 "peer uid {} does not match agent-seat owner uid {expected_uid}",
959 credentials.uid
960 )));
961 }
962 Ok(credentials.pid)
963}
964
965fn trusted_peer_pid(stream: &std::os::unix::net::UnixStream) -> Result<u32, SeatError> {
966 let credentials = peer_credentials(stream)?;
967 validate_peer(credentials, effective_uid())
968}
969
970fn effective_uid() -> libc::uid_t {
971 unsafe { libc::geteuid() }
974}
975
976fn remove_same_arc<V>(items: &mut Vec<Arc<V>>, expected: &Arc<V>) -> bool {
977 let before = items.len();
978 items.retain(|current| !Arc::ptr_eq(current, expected));
979 items.len() != before
980}
981
982fn close_connection(conn: &Arc<Mutex<Conn>>, poller: &Poller) {
983 let (server_handle, client_id) = {
984 let mut conn = conn.lock().unwrap();
985 conn.dead = true;
986 let _ = unsafe { libc::shutdown(conn.upstream.poll_fd().as_raw_fd(), libc::SHUT_RDWR) };
991 (conn.server_handle.clone(), conn.client_id.clone())
992 };
993 if let Some(client_id) = client_id {
994 server_handle.kill_client(
995 client_id,
996 wayland_backend::server::DisconnectReason::ConnectionClosed,
997 );
998 }
999 let _ = poller.notify();
1000}
1001
1002fn join_thread(handle: JoinHandle<()>, kind: &str) {
1003 if handle.thread().id() == std::thread::current().id() {
1004 return;
1005 }
1006 if handle.join().is_err() {
1007 eprintln!("agent seat: {kind} thread panicked during shutdown");
1008 }
1009}
1010
1011struct ProxyLoopCleanup {
1012 seat: Weak<AgentSeat>,
1013 app: Arc<SeatApp>,
1014 conn: Arc<Mutex<Conn>>,
1015 poller: Arc<Poller>,
1016}
1017
1018impl Drop for ProxyLoopCleanup {
1019 fn drop(&mut self) {
1020 close_connection(&self.conn, &self.poller);
1021 self.app.cleanup_registered_paths();
1022 if let Some(seat) = self.seat.upgrade() {
1023 seat.remove_app(&self.app);
1024 }
1025 }
1026}
1027
1028fn run_loop(
1029 server: &mut SBackend<ServerState>,
1030 conn: &Arc<Mutex<Conn>>,
1031 upstream: &wayland_backend::client::Backend,
1032 poller: &Poller,
1033 stopping: &AtomicBool,
1034) {
1035 let mut events = polling::Events::new();
1036 let dbg = std::env::var("AGENT_SEAT_DEBUG").is_ok();
1037 while !stopping.load(Ordering::Acquire) {
1038 {
1039 let guard = conn.lock().unwrap();
1040 if dbg {
1041 eprintln!("seat LOOP top dead={}", guard.dead);
1042 }
1043 if guard.dead {
1044 break;
1045 }
1046 }
1047 events.clear();
1048 if dbg {
1049 eprintln!("seat LOOP poll wait");
1050 }
1051 if poller
1052 .wait(&mut events, Some(Duration::from_millis(200)))
1053 .is_err()
1054 {
1055 break;
1056 }
1057 let mut server_ready = false;
1058 let mut upstream_ready = false;
1059 for event in events.iter() {
1060 match event.key {
1061 1 => server_ready = true,
1062 2 => upstream_ready = true,
1063 _ => {} }
1065 }
1066 if dbg {
1067 eprintln!("seat LOOP ready server={server_ready} upstream={upstream_ready}");
1068 }
1069
1070 if server_ready {
1071 let mut state = ServerState;
1072 if let Err(e) = server.dispatch_all_clients(&mut state) {
1073 if !stopping.load(Ordering::Acquire) {
1074 eprintln!("agent seat: server dispatch error: {e}");
1075 }
1076 conn.lock().unwrap().dead = true;
1077 }
1078 }
1079
1080 if upstream_ready {
1081 if let Some(guard) = upstream.prepare_read() {
1082 match guard.read() {
1083 Ok(_) => {}
1084 Err(e) if proxy::is_would_block(&e) => {}
1085 Err(e) => {
1086 if !stopping.load(Ordering::Acquire) {
1087 eprintln!("agent seat: upstream read error: {e}");
1088 }
1089 conn.lock().unwrap().dead = true;
1090 }
1091 }
1092 }
1093 if let Err(e) = upstream.dispatch_inner_queue() {
1094 if !stopping.load(Ordering::Acquire) {
1095 eprintln!("agent seat: upstream dispatch error: {e}");
1096 }
1097 }
1098 }
1099
1100 {
1102 let mut guard = conn.lock().unwrap();
1103 guard.actions.clear();
1104 }
1105
1106 if dbg {
1107 eprintln!("seat LOOP flush");
1108 }
1109 let _ = server.flush(None);
1111 let _ = upstream.flush();
1112 }
1113 if dbg {
1114 eprintln!("seat LOOP exited");
1115 }
1116}
1117
1118#[cfg(test)]
1119mod tests {
1120 use super::*;
1121
1122 fn unique_test_socket(label: &str) -> PathBuf {
1123 let nonce = std::time::SystemTime::now()
1124 .duration_since(std::time::UNIX_EPOCH)
1125 .unwrap()
1126 .as_nanos();
1127 std::env::temp_dir().join(format!(
1128 "agent-seat-{label}-{}-{nonce}.sock",
1129 std::process::id()
1130 ))
1131 }
1132
1133 #[test]
1134 fn socket_permissions_are_owner_only() {
1135 let path = unique_test_socket("permissions");
1136 let listener = UnixListener::bind(&path).expect("bind test socket");
1137 set_owner_only_socket_permissions(&path).expect("restrict test socket");
1138
1139 let mode = std::fs::metadata(&path)
1140 .expect("socket metadata")
1141 .permissions()
1142 .mode();
1143 assert_eq!(mode & 0o777, 0o600);
1144
1145 drop(listener);
1146 let _ = std::fs::remove_file(path);
1147 }
1148
1149 #[test]
1150 fn peer_credentials_match_the_connecting_process() {
1151 let path = unique_test_socket("credentials");
1152 let listener = UnixListener::bind(&path).expect("bind test socket");
1153 let client = std::os::unix::net::UnixStream::connect(&path).expect("connect test socket");
1154 let (server, _) = listener.accept().expect("accept test socket");
1155
1156 let credentials = peer_credentials(&server).expect("read peer credentials");
1157 assert_eq!(credentials.pid, std::process::id());
1158 assert_eq!(credentials.uid, effective_uid());
1159 assert_eq!(
1160 validate_peer(credentials, effective_uid()).unwrap(),
1161 std::process::id()
1162 );
1163
1164 drop((client, server, listener));
1165 let _ = std::fs::remove_file(path);
1166 }
1167
1168 #[test]
1169 fn peer_uid_mismatch_is_rejected() {
1170 let uid = effective_uid();
1171 let credentials = PeerCredentials {
1172 pid: std::process::id(),
1173 uid,
1174 };
1175 assert!(validate_peer(credentials, uid.wrapping_add(1)).is_err());
1176 }
1177
1178 #[test]
1179 fn one_connection_cannot_remove_another_from_the_same_pid() {
1180 let stale = Arc::new("stale");
1181 let replacement = Arc::new("replacement");
1182 let mut apps = vec![stale.clone(), replacement.clone()];
1183
1184 assert!(remove_same_arc(&mut apps, &stale));
1185 assert_eq!(apps.len(), 1);
1186 assert!(Arc::ptr_eq(&apps[0], &replacement));
1187 assert!(!remove_same_arc(&mut apps, &stale));
1188 }
1189
1190 #[test]
1191 fn cleanup_paths_are_exact_and_late_paths_are_returned() {
1192 let root = unique_test_socket("cleanup-root");
1193 let profile = root.join("profile");
1194 let sibling = root.join("keep");
1195 std::fs::create_dir_all(&profile).expect("create profile");
1196 std::fs::create_dir_all(&sibling).expect("create sibling");
1197
1198 let mut cleanup = CleanupPaths::default();
1199 assert!(cleanup.register(profile.clone()).is_none());
1200 remove_cleanup_directories(cleanup.close());
1201 assert!(!profile.exists());
1202 assert!(sibling.exists());
1203
1204 let late = root.join("late-profile");
1205 std::fs::create_dir_all(&late).expect("create late profile");
1206 let remove_now = cleanup.register(late.clone()).expect("closed registry");
1207 remove_cleanup_directories([remove_now]);
1208 assert!(!late.exists());
1209 assert!(sibling.exists());
1210
1211 let _ = std::fs::remove_dir_all(root);
1212 }
1213
1214 #[test]
1217 #[ignore]
1218 fn dump_seat_registry() {
1219 if std::env::var("WAYLAND_DISPLAY").is_err() {
1220 eprintln!("skipping: no Wayland session");
1221 return;
1222 }
1223 let seat = seat().expect("seat must be creatable");
1224 let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap();
1225 let stream =
1226 std::os::unix::net::UnixStream::connect(format!("{runtime}/{}", seat.socket_name()))
1227 .expect("connect to seat");
1228 let backend = wayland_backend::client::Backend::connect(stream).expect("backend");
1229
1230 struct Dump;
1231 impl wayland_backend::client::ObjectData for Dump {
1232 fn event(
1233 self: Arc<Self>,
1234 _b: &wayland_backend::client::Backend,
1235 msg: wayland_backend::protocol::Message<
1236 wayland_backend::client::ObjectId,
1237 std::os::fd::OwnedFd,
1238 >,
1239 ) -> Option<Arc<dyn wayland_backend::client::ObjectData>> {
1240 if msg.opcode == 0 {
1242 if let (
1243 Some(wayland_backend::protocol::Argument::Uint(name)),
1244 Some(wayland_backend::protocol::Argument::Str(iface)),
1245 Some(wayland_backend::protocol::Argument::Uint(version)),
1246 ) = (msg.args.first(), msg.args.get(1), msg.args.get(2))
1247 {
1248 eprintln!(
1249 "GLOBAL {name}: {} v{version}",
1250 iface
1251 .as_ref()
1252 .map(|s| s.to_string_lossy().into_owned())
1253 .unwrap_or_default()
1254 );
1255 }
1256 }
1257 None
1258 }
1259 fn destroyed(&self, _id: wayland_backend::client::ObjectId) {}
1260 }
1261
1262 let mut args = smallvec::SmallVec::new();
1263 args.push(wayland_backend::protocol::Argument::NewId(
1264 wayland_backend::client::ObjectId::null(),
1265 ));
1266 let msg = wayland_backend::protocol::Message {
1267 sender_id: backend.display_id(),
1268 opcode: 1,
1269 args,
1270 };
1271 use wayland_client::protocol::wl_registry::WlRegistry;
1272 use wayland_client::Proxy;
1273 backend
1274 .send_request(
1275 msg,
1276 Some(Arc::new(Dump) as Arc<dyn wayland_backend::client::ObjectData>),
1277 Some((WlRegistry::interface(), 1)),
1278 )
1279 .expect("get_registry");
1280 backend.flush().expect("flush");
1281
1282 let deadline = std::time::Instant::now() + Duration::from_secs(3);
1284 while std::time::Instant::now() < deadline {
1285 if let Some(guard) = backend.prepare_read() {
1286 let _ = guard.read();
1287 }
1288 let _ = backend.dispatch_inner_queue();
1289 std::thread::sleep(Duration::from_millis(50));
1290 }
1291 }
1292
1293 #[test]
1299 #[ignore]
1300 fn proxy_forwards_a_real_gtk_app() {
1301 if std::env::var("WAYLAND_DISPLAY").is_err() {
1302 eprintln!("skipping: no Wayland session");
1303 return;
1304 }
1305 let seat = seat().expect("seat must be creatable in a Wayland session");
1306
1307 let app_bin =
1308 std::env::var("AGENT_SEAT_TEST_APP").unwrap_or_else(|_| "gnome-calculator".into());
1309 let mut cmd = std::process::Command::new(&app_bin);
1310 cmd.env("WAYLAND_DISPLAY", seat.socket_name())
1311 .env("GDK_BACKEND", "wayland")
1312 .stdin(std::process::Stdio::null())
1313 .stdout(std::process::Stdio::null());
1314 let mut child = cmd
1315 .spawn()
1316 .unwrap_or_else(|_| panic!("{app_bin} must launch through the seat"));
1317 let pid = child.id();
1318
1319 let mut app = None;
1321 for _ in 0..50 {
1322 std::thread::sleep(Duration::from_millis(200));
1323 if let Some(a) = seat.app(pid) {
1324 app = Some(a);
1325 break;
1326 }
1327 }
1328 let app = app.expect("the app's connection must reach the seat proxy");
1329
1330 let mut surfaces = 0;
1332 let mut dead = false;
1333 for _ in 0..50 {
1334 std::thread::sleep(Duration::from_millis(200));
1335 let conn = app.conn.lock().unwrap();
1336 surfaces = conn.surfaces.len();
1337 dead = conn.dead;
1338 if surfaces > 0 || dead {
1339 break;
1340 }
1341 }
1342 let _ = child.kill();
1343 let status = child.wait().ok();
1344 eprintln!("test: child exit status = {status:?}, dead={dead}, surfaces={surfaces}");
1345 assert!(!dead, "the proxied connection must stay alive");
1346 assert!(
1347 surfaces > 0,
1348 "the app must create at least one wl_surface through the proxy"
1349 );
1350 }
1351
1352 #[test]
1355 #[ignore]
1356 fn proxy_captures_app_frame() {
1357 if std::env::var("WAYLAND_DISPLAY").is_err() {
1358 eprintln!("skipping: no Wayland session");
1359 return;
1360 }
1361 let seat = seat().expect("seat must be creatable");
1362 let app_bin =
1363 std::env::var("AGENT_SEAT_TEST_APP").unwrap_or_else(|_| "gnome-calculator".into());
1364 let mut cmd = std::process::Command::new(&app_bin);
1365 cmd.env("WAYLAND_DISPLAY", seat.socket_name())
1366 .env("GDK_BACKEND", "wayland")
1367 .env("GSK_RENDERER", "cairo")
1369 .stdin(std::process::Stdio::null())
1370 .stdout(std::process::Stdio::null());
1371 let mut child = cmd
1372 .spawn()
1373 .unwrap_or_else(|_| panic!("{app_bin} must launch through the seat"));
1374 let pid = child.id();
1375
1376 let mut app = None;
1377 for _ in 0..50 {
1378 std::thread::sleep(Duration::from_millis(200));
1379 if let Some(a) = seat.app(pid) {
1380 app = Some(a);
1381 break;
1382 }
1383 }
1384 let app = app.expect("the app's connection must reach the seat proxy");
1385
1386 let mut captured = None;
1388 for _ in 0..50 {
1389 std::thread::sleep(Duration::from_millis(200));
1390 match app.capture_frame() {
1391 Ok(frame) => {
1392 captured = Some(frame);
1393 break;
1394 }
1395 Err(_) => continue,
1396 }
1397 }
1398 let _ = child.kill();
1399 let _ = child.wait();
1400
1401 let frame = captured.expect("must capture a rendered frame from the app");
1402 eprintln!(
1403 "test: captured frame {}x{}",
1404 frame.image.width(),
1405 frame.image.height()
1406 );
1407 assert!(frame.image.width() > 0 && frame.image.height() > 0);
1408 let out = std::env::temp_dir().join("agent-seat-capture.png");
1410 let _ = frame.image.save(&out);
1411 eprintln!("test: saved capture to {}", out.display());
1412 }
1413
1414 #[test]
1417 #[ignore]
1418 fn proxy_injects_clicks_that_the_app_receives() {
1419 if std::env::var("WAYLAND_DISPLAY").is_err() {
1420 eprintln!("skipping: no Wayland session");
1421 return;
1422 }
1423 let seat = seat().expect("seat must be creatable");
1424 let app_bin =
1425 std::env::var("AGENT_SEAT_TEST_APP").unwrap_or_else(|_| "gnome-calculator".into());
1426 let mut cmd = std::process::Command::new(&app_bin);
1427 cmd.env("WAYLAND_DISPLAY", seat.socket_name())
1428 .env("GDK_BACKEND", "wayland")
1429 .env("GSK_RENDERER", "cairo")
1430 .stdin(std::process::Stdio::null())
1431 .stdout(std::process::Stdio::null());
1432 let mut child = cmd
1433 .spawn()
1434 .unwrap_or_else(|_| panic!("{app_bin} must launch through the seat"));
1435 let pid = child.id();
1436
1437 let mut app = None;
1438 for _ in 0..50 {
1439 std::thread::sleep(Duration::from_millis(200));
1440 if let Some(a) = seat.app(pid) {
1441 app = Some(a);
1442 break;
1443 }
1444 }
1445 let app = app.expect("the app's connection must reach the seat proxy");
1446
1447 let mut ready = false;
1449 for _ in 0..50 {
1450 std::thread::sleep(Duration::from_millis(200));
1451 if app.capture_frame().is_ok() {
1452 ready = true;
1453 break;
1454 }
1455 }
1456 assert!(ready, "app must render a frame before injecting input");
1457
1458 let clicks: [(f64, f64, &str); 4] = [
1462 (104.0, 379.0, "7"),
1463 (308.0, 523.0, "+"),
1464 (240.0, 475.0, "3"),
1465 (376.0, 500.0, "="),
1466 ];
1467 for (x, y, label) in clicks {
1468 app.inject_click(x, y, input::BTN_LEFT, 1)
1469 .unwrap_or_else(|e| panic!("click {label} failed: {e}"));
1470 std::thread::sleep(Duration::from_millis(500));
1471 }
1472 std::thread::sleep(Duration::from_millis(500));
1474 let frame = app.capture_frame().expect("capture after clicks");
1475 let out = std::env::temp_dir().join("agent-seat-after-clicks.png");
1476 let _ = frame.image.save(&out);
1477 eprintln!("test: saved post-click capture to {}", out.display());
1478
1479 let _ = child.kill();
1480 let _ = child.wait();
1481 }
1482
1483 #[test]
1486 #[ignore]
1487 fn proxy_injects_typed_text() {
1488 if std::env::var("WAYLAND_DISPLAY").is_err() {
1489 eprintln!("skipping: no Wayland session");
1490 return;
1491 }
1492 let seat = seat().expect("seat must be creatable");
1493 let app_bin =
1494 std::env::var("AGENT_SEAT_TEST_APP").unwrap_or_else(|_| "gnome-calculator".into());
1495 let mut cmd = std::process::Command::new(&app_bin);
1496 cmd.env("WAYLAND_DISPLAY", seat.socket_name())
1497 .env("GDK_BACKEND", "wayland")
1498 .env("GSK_RENDERER", "cairo")
1499 .stdin(std::process::Stdio::null())
1500 .stdout(std::process::Stdio::null());
1501 let mut child = cmd
1502 .spawn()
1503 .unwrap_or_else(|_| panic!("{app_bin} must launch through the seat"));
1504 let pid = child.id();
1505
1506 let mut app = None;
1507 for _ in 0..50 {
1508 std::thread::sleep(Duration::from_millis(200));
1509 if let Some(a) = seat.app(pid) {
1510 app = Some(a);
1511 break;
1512 }
1513 }
1514 let app = app.expect("the app's connection must reach the seat proxy");
1515
1516 let mut ready = false;
1518 for _ in 0..50 {
1519 std::thread::sleep(Duration::from_millis(200));
1520 if app.capture_frame().is_ok() {
1521 ready = true;
1522 break;
1523 }
1524 }
1525 assert!(ready, "app must render a frame before typing");
1526 std::thread::sleep(Duration::from_millis(400));
1527
1528 let text = std::env::var("AGENT_SEAT_TYPE_TEXT").unwrap_or_else(|_| "12+34\n".into());
1529 app.inject_text(&text)
1530 .unwrap_or_else(|e| panic!("typing failed: {e}"));
1531 std::thread::sleep(Duration::from_millis(700));
1532
1533 let frame = app.capture_frame().expect("capture after typing");
1534 let out = std::env::temp_dir().join("agent-seat-after-typing.png");
1535 let _ = frame.image.save(&out);
1536 eprintln!("test: saved post-typing capture to {}", out.display());
1537
1538 let _ = child.kill();
1539 let _ = child.wait();
1540 }
1541}