Skip to main content

agent_seat_linux/seat/
mod.rs

1//! A private Wayland socket through which an automation host launches the
2//! apps it drives. Each connection is transparently proxied to the real
3//! compositor, which keeps the app visible on the user's desktop while giving
4//! the host frames and input at the app boundary.
5
6use 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/// Commands queued from tool threads, executed by the proxy loop.
32#[allow(dead_code)]
33pub(crate) enum Action {
34    /// Placeholder until input injection lands.
35    Ping,
36}
37
38/// One proxied app.
39pub struct SeatApp {
40    /// The app's pid (correlates with the computer-use bound target).
41    #[allow(dead_code)]
42    pub pid: u32,
43    conn: Arc<Mutex<Conn>>,
44    poller: Arc<Poller>,
45    cleanup_paths: Mutex<CleanupPaths>,
46}
47
48/// One authenticated, rootful XWayland server connected through the private
49/// agent seat. It is used as a compatibility bridge only when a native
50/// Wayland client cannot expose a readable application frame.
51pub struct XwaylandBridge {
52    child: Option<Child>,
53    runtime_dir: PathBuf,
54    display: String,
55    xauthority: PathBuf,
56}
57
58impl XwaylandBridge {
59    /// The private X11 display assigned to this bridge.
60    pub fn display(&self) -> &str {
61        &self.display
62    }
63
64    /// The owner-only Xauthority file clients must use.
65    pub fn xauthority(&self) -> &std::path::Path {
66        &self.xauthority
67    }
68
69    /// Configure a child process to connect exclusively to this XWayland
70    /// bridge. Call [`AgentSeat::adopt_xwayland_bridge`] after the client has
71    /// connected successfully.
72    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    /// Queue an action and wake the proxy loop (input-injection entry point).
128    #[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    /// Read the app's current rendered frame (window-scoped capture).
138    #[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    /// True once the client has produced a window-sized frame rather than a
145    /// startup icon or cursor-sized helper surface.
146    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    /// Click at surface-local (x, y). `button` is a Linux input code.
164    #[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    /// Click while holding an optional `+`-separated modifier list.
175    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    /// Scroll at surface-local (x, y) by discrete notches.
193    #[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    /// Press/release a raw keycode.
204    #[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    /// Press a key or `+`-separated key combination in this app.
215    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    /// Drag between two surface-local points in this app.
225    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    /// Type a string into the app (resolves characters via the app's keymap).
241    #[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    /// Remove this exact directory when the proxied connection closes.
252    #[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
272/// Private Wayland proxy that owns its socket, worker threads, and bridges.
273pub struct AgentSeat {
274    socket_name: String,
275    socket_path: PathBuf,
276    listener: UnixListener,
277    upstream_socket: PathBuf,
278    // A process may open several independent Wayland connections. Electron
279    // does this for its browser/helper/renderer roles, sometimes under the
280    // same pid, so a pid-keyed map would silently discard the visible one.
281    apps: Mutex<Vec<Arc<SeatApp>>>,
282    // The exact connection selected for a bound launch. Subsequent click/type
283    // calls must not independently pick different Electron connections that
284    // happen to share the same pid.
285    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
299/// The process-wide agent seat, created on first use. Fails when there is no
300/// Wayland session to proxy into.
301pub 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
311/// Stop the process-wide seat and release all of its sockets and threads.
312/// A later call to [`seat`] creates a fresh seat.
313pub fn shutdown() {
314    // Keep the singleton lock until teardown finishes so a replacement cannot
315    // bind the same path while the old seat is still removing it.
316    let mut slot = seat_slot().lock().unwrap();
317    if let Some(seat) = slot.take() {
318        seat.shutdown_inner();
319    }
320}
321
322/// True when an agent seat can be created in this environment (Wayland
323/// session with a reachable compositor socket).
324#[allow(dead_code)]
325pub fn available() -> bool {
326    seat().is_ok()
327}
328
329/// Remove `agent-seat-<pid>` sockets whose owning process has exited.
330fn 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        // /proc/<pid> exists only while the process is alive.
350        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    /// Create and start an independently owned agent seat.
362    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        // Remove seat sockets left behind by hosts that have since
387        // exited (they otherwise accumulate in XDG_RUNTIME_DIR).
388        cleanup_stale_seat_sockets(&runtime_dir);
389
390        // Per-process socket name; remove a stale one from a crashed run.
391        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    /// The WAYLAND_DISPLAY value agent-launched apps must use.
449    pub fn socket_name(&self) -> String {
450        self.socket_name.clone()
451    }
452
453    /// Configure a process to connect through this private Wayland seat.
454    pub fn configure_command<'a>(&self, command: &'a mut Command) -> &'a mut Command {
455        command.env("WAYLAND_DISPLAY", &self.socket_name)
456    }
457
458    /// Stop this seat, close every proxied connection, and reap bridge
459    /// threads. Calling this more than once is safe.
460    pub fn close(&self) {
461        self.shutdown_inner();
462    }
463
464    /// Start an owner-authenticated XWayland server whose single root window
465    /// is itself a client of this seat. `-shm` makes its pixels readable by the
466    /// window-scoped capture path, while the Xauthority cookie prevents other
467    /// local users from connecting to the temporary X socket.
468    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    /// Transfer a successful bridge to the seat. Closing the seat connection
608    /// makes XWayland exit; the owned reaper then removes only this bridge's
609    /// credential directory.
610    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        // The reaper now owns cleanup. Keep the path on `bridge` until thread
637        // creation succeeds so Drop removes it on every failure path.
638        bridge.runtime_dir.clear();
639        self.bridge_threads.lock().unwrap().push(handle);
640        Ok(())
641    }
642
643    /// The proxied app with the given pid, when connected.
644    #[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        // Prefer the connection that owns a visible frame. `capture_frame`
664        // is non-consuming, so the later screenshot still receives it.
665        candidates
666            .iter()
667            .rev()
668            .find(|app| app.has_interactive_frame())
669            .cloned()
670            .or_else(|| candidates.last().cloned())
671    }
672
673    /// Pin subsequent lookups for the app's pid to this exact connection.
674    pub fn bind_app(&self, app: &Arc<SeatApp>) {
675        self.bind_app_for_pid(app.pid, app);
676    }
677
678    /// Bind an application pid to the seat connection that transports its
679    /// pixels and input. Normally both pids are identical; an XWayland bridge
680    /// deliberately transports an X11 client's window on its behalf.
681    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    /// The most recently connected proxied app. Used as a fallback when the
689    /// bound target's pid does not match the connected peer's pid (apps that
690    /// re-exec or connect from a child process).
691    pub fn most_recent_app(&self) -> Option<Arc<SeatApp>> {
692        self.apps.lock().unwrap().last().cloned()
693    }
694
695    /// The set of currently connected app pids.
696    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    /// Number of connected proxied apps.
706    pub fn app_count(&self) -> usize {
707        self.apps.lock().unwrap().len()
708    }
709
710    /// Wait until a connection from a pid NOT in `before` has a readable
711    /// frame. Electron can open multiple Wayland connections under one pid;
712    /// binding only the first one can select a helper with no visible surface.
713    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    /// Wait until a connection from a pid not present in `before` has a
732    /// readable application-sized frame.
733    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        // Safety: server_fd remains owned by server for the lifetime of the
796        // proxy thread and is removed only after the poller stops using it.
797        let _ = unsafe {
798            poller.add_with_mode(
799                server_fd,
800                polling::Event::readable(1),
801                polling::PollMode::Level,
802            )
803        };
804        // Safety: upstream_fd remains owned by conn for the lifetime of the
805        // proxy thread and is removed only after the poller stops using it.
806        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(&current, 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        // Wake and close current proxies before waiting for an in-flight
877        // accept/setup operation to finish.
878        self.close_active_connections();
879        if let Some(handle) = self.accept_thread.lock().unwrap().take() {
880            join_thread(handle, "accept");
881        }
882
883        // The accept thread is now gone, so no more proxy handles can appear.
884        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
917/// Kernel-authenticated peer credentials of a connecting app.
918fn peer_credentials(stream: &std::os::unix::net::UnixStream) -> Result<PeerCredentials, SeatError> {
919    use std::os::fd::AsFd;
920    // SAFETY: libc::ucred is a plain C output structure and all-zero is a
921    // valid initialization before getsockopt overwrites it.
922    let mut ucred: libc::ucred = unsafe { std::mem::zeroed() };
923    let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
924    // SAFETY: the stream fd is live, ucred is writable and correctly sized,
925    // and len points to its initialized size as required by getsockopt.
926    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    // SAFETY: geteuid takes no arguments, has no memory preconditions, and
972    // simply returns the effective uid of the calling process.
973    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        // End the compositor side even when another component still holds a
987        // SeatApp/Conn Arc after the proxy thread exits.
988        // SAFETY: the fd belongs to the live upstream backend. shutdown does
989        // not take ownership; errors are intentionally ignored during close.
990        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                _ => {} // NOTIFY_KEY: actions drained below
1064            }
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        // Drain queued actions (Phase B/C executes them here).
1101        {
1102            let mut guard = conn.lock().unwrap();
1103            guard.actions.clear();
1104        }
1105
1106        if dbg {
1107            eprintln!("seat LOOP flush");
1108        }
1109        // Flush both directions.
1110        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    /// Dump the globals advertised through the seat proxy, using a raw
1215    /// wayland-backend client (the same view a real app gets).
1216    #[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                // wl_registry.global(name, interface, version)
1241                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        // Pump events for a couple of seconds.
1283        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    /// End-to-end proxy smoke test on a live Wayland session: launch a real
1294    /// GTK app through the seat and verify it completes the protocol handshake
1295    /// (registry -> binds -> surface creation) without dying. Requires a
1296    /// running graphical session, so it is #[ignore]d in normal runs:
1297    /// `cargo test agent_seat -- --ignored --nocapture`
1298    #[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        // Give the app time to connect and build its window.
1320        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        // Wait for at least one surface to be created through the proxy.
1331        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    /// Capture test: launch a software-rendered GTK app through the seat and
1353    /// read its rendered frame back (window-scoped capture, no portal).
1354    #[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            // Software rendering => wl_shm buffers, readable without EGL.
1368            .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        // Give the app time to render, then capture.
1387        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        // Save for visual inspection.
1409        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    /// Input test: click 7 + 3 = on the calculator and confirm the display
1415    /// shows 10. Coordinates are surface-local (match the captured buffer).
1416    #[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        // Wait until a frame is available (app has rendered + input objects).
1448        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        // Button centers (surface-local), detected from the rendered buffer:
1459        // columns x=104,172,240,308,376 ; rows y=379(7s) 427(4s) 475(1s) 523(0s);
1460        // "=" is the tall orange button at column 4 (x=376) spanning rows 475-523.
1461        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        // Let the app settle, then capture the result.
1473        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    /// Text-injection test: type "12+34" then Enter into the calculator and
1484    /// confirm the display shows 46.
1485    #[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        // Wait until a frame is available.
1517        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}