Skip to main content

agentmux_launcher/splash_linux/
x11.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! X11 splash backend — a software-drawn, override-redirect window showing the
5//! pulsing brain over a dark backdrop, dismissed (with a fade-out) when the host
6//! writes the ready-file or after the safety timeout. For X11 / XWayland sessions.
7//!
8//! When a compositor is present we use a 32-bit **ARGB** visual so the backdrop
9//! can have rounded corners and the whole splash can fade out (per-pixel alpha,
10//! pre-multiplied). With no compositor there's no per-pixel alpha, so we fall
11//! back to an opaque depth-24 square that dismisses abruptly. Centering uses the
12//! RANDR primary monitor. No GPU — we composite each frame into an off-screen
13//! pixmap and `CopyArea` it to the window at ~60 fps.
14//!
15//! Spec: docs/specs/SPEC_LINUX_SPLASH_POLISH_2026_06_20.md
16
17use std::error::Error;
18use std::path::Path;
19use std::time::{Duration, Instant};
20
21use x11rb::connection::Connection;
22use x11rb::protocol::randr::ConnectionExt as _;
23use x11rb::protocol::xproto::ConnectionExt as _;
24use x11rb::protocol::xproto::{
25    AtomEnum, ColormapAlloc, CreateGCAux, CreateWindowAux, EventMask, ImageFormat, PropMode, Screen,
26    VisualClass, Window, WindowClass,
27};
28use x11rb::wrapper::ConnectionExt as _;
29use x11rb::NONE;
30
31use super::{
32    fade_alpha, min_hold, pulse_alpha, render_frame, StageList, CARD_H, CARD_W, CORNER_RADIUS_PX,
33    DISMISS_TIMEOUT, FRAME_MS,
34};
35
36/// Cheap reachability probe for the backend selector (`super::detect`): can we
37/// actually open an X11 connection? `DISPLAY` being set doesn't guarantee a live
38/// server, so we test the real handshake (which fails fast when there's none).
39/// Only when this succeeds do we prefer the self-centering X11/XWayland splash
40/// over the native-Wayland fallback.
41pub(super) fn server_reachable() -> bool {
42    x11rb::connect(None).is_ok()
43}
44
45pub(super) fn run(
46    ready_file: &Path,
47    footer: Vec<String>,
48    startup_rx: std::sync::mpsc::Receiver<crate::startup_events::StartupEvent>,
49) -> Result<(), Box<dyn Error>> {
50    let (conn, screen_num) = x11rb::connect(None)?;
51    let screen = conn.setup().roots[screen_num].clone();
52    let root = screen.root;
53
54    let w = CARD_W as u16;
55    let h = CARD_H as u16;
56
57    // Per-pixel alpha (→ fade + rounded corners) needs a compositor + an ARGB
58    // visual. Otherwise fall back to an opaque depth-24 square.
59    let argb_visual = if has_compositor(&conn, screen_num).unwrap_or(false) {
60        find_argb_visual(&screen)
61    } else {
62        None
63    };
64
65    let (depth, visual, colormap) = match argb_visual {
66        Some(vid) => {
67            let cmap = conn.generate_id()?;
68            conn.create_colormap(ColormapAlloc::NONE, cmap, root, vid)?;
69            (32u8, vid, Some(cmap))
70        }
71        None => (screen.root_depth, screen.root_visual, None),
72    };
73    let radius = if colormap.is_some() { CORNER_RADIUS_PX } else { 0.0 };
74
75    // Center on the primary monitor (RANDR), falling back to the whole screen.
76    let (mx, my, mw, mh) = primary_monitor(&conn, root, &screen);
77    let x = (mx + (mw - w as i32) / 2).max(0) as i16;
78    let y = (my + (mh - h as i32) / 2).max(0) as i16;
79
80    let win = conn.generate_id()?;
81    let mut win_aux = CreateWindowAux::new()
82        .override_redirect(1)
83        .event_mask(EventMask::EXPOSURE);
84    win_aux = if let Some(cmap) = colormap {
85        // A window whose depth differs from its parent needs its own colormap
86        // and an explicit border_pixel (else BadMatch). 0 = transparent backdrop.
87        win_aux.colormap(cmap).border_pixel(0).background_pixel(0)
88    } else {
89        win_aux.background_pixel(screen.black_pixel)
90    };
91    conn.create_window(
92        depth,
93        win,
94        root,
95        x,
96        y,
97        w,
98        h,
99        0,
100        WindowClass::INPUT_OUTPUT,
101        visual,
102        &win_aux,
103    )?;
104
105    set_ewmh_hints(&conn, win)?;
106
107    let gc = conn.generate_id()?;
108    conn.create_gc(gc, win, &CreateGCAux::new())?;
109    let pixmap = conn.generate_id()?;
110    conn.create_pixmap(depth, pixmap, win, w, h)?;
111
112    conn.map_window(win)?;
113    conn.flush()?;
114
115    let start = Instant::now();
116    let hold = min_hold();
117    let mut fade_start: Option<Instant> = None;
118    let mut stage_list = StageList::new();
119    // 4 bytes/pixel (depth-24 and depth-32 are both 32 bpp on modern servers).
120    let mut buf = vec![0u8; w as usize * h as usize * 4];
121
122    loop {
123        // Drain all pending startup events before rendering (non-blocking),
124        // as one batch — `StageList::apply_tick` defers a same-tick
125        // Begin+End pair to the next call so every row gets at least one
126        // "running" frame before it can complete, instead of snapping
127        // straight to its final value.
128        let mut fresh = Vec::new();
129        while let Ok(event) = startup_rx.try_recv() {
130            fresh.push(event);
131        }
132        stage_list.apply_tick(fresh);
133
134        let now = Instant::now();
135        let elapsed = now.duration_since(start);
136        let dismiss =
137            (ready_file.exists() && elapsed >= hold) || elapsed >= DISMISS_TIMEOUT;
138        if dismiss {
139            if colormap.is_none() {
140                break; // opaque depth-24: can't alpha-fade, dismiss now
141            }
142            if fade_start.is_none() {
143                // Any row still open at this instant (no matching End ever
144                // arrived) gets a definite outcome now instead of staying
145                // visually "running" for the rest of the fade — see
146                // splash_core::StageTimeline::finalize_running.
147                stage_list.finalize_running(now);
148                fade_start = Some(now);
149            }
150        }
151        let window_alpha = fade_alpha(fade_start, now);
152        if fade_start.is_some() && window_alpha <= 0.0 {
153            break;
154        }
155
156        let brain_alpha = pulse_alpha(elapsed.as_secs_f32());
157        render_frame(
158            &mut buf,
159            w as i32,
160            h as i32,
161            brain_alpha,
162            window_alpha,
163            radius,
164            true,
165            &footer,
166            &stage_list.lines(),
167        );
168
169        // PutImage in horizontal strips so no single request exceeds the server's
170        // max request size (~380 KB frame > the 256 KB non-BIG-REQUESTS limit).
171        let row_bytes = w as usize * 4;
172        let strip_rows = (200_000 / row_bytes).max(1) as u16;
173        let mut row = 0u16;
174        while row < h {
175            let rows = strip_rows.min(h - row);
176            let off = row as usize * row_bytes;
177            let len = rows as usize * row_bytes;
178            conn.put_image(
179                ImageFormat::Z_PIXMAP,
180                pixmap,
181                gc,
182                w,
183                rows,
184                0,
185                row as i16,
186                0,
187                depth,
188                &buf[off..off + len],
189            )?;
190            row += rows;
191        }
192        conn.copy_area(pixmap, win, gc, 0, 0, 0, 0, w, h)?;
193        conn.flush()?;
194
195        while let Ok(Some(_)) = conn.poll_for_event() {}
196        std::thread::sleep(Duration::from_millis(FRAME_MS));
197    }
198
199    let _ = conn.destroy_window(win);
200    let _ = conn.free_pixmap(pixmap);
201    let _ = conn.free_gc(gc);
202    if let Some(cmap) = colormap {
203        let _ = conn.free_colormap(cmap);
204    }
205    let _ = conn.flush();
206    Ok(())
207}
208
209/// True if a compositing manager owns the `_NET_WM_CM_S<screen>` selection
210/// (GNOME/Mutter and XWayland under it always do) — i.e. per-pixel alpha works.
211fn has_compositor<C: Connection>(conn: &C, screen_num: usize) -> Result<bool, Box<dyn Error>> {
212    let name = format!("_NET_WM_CM_S{screen_num}");
213    let atom = conn.intern_atom(false, name.as_bytes())?.reply()?.atom;
214    let owner = conn.get_selection_owner(atom)?.reply()?.owner;
215    Ok(owner != NONE)
216}
217
218/// First 32-bit TrueColor visual on the screen, if any.
219fn find_argb_visual(screen: &Screen) -> Option<u32> {
220    screen
221        .allowed_depths
222        .iter()
223        .filter(|d| d.depth == 32)
224        .flat_map(|d| d.visuals.iter())
225        .find(|v| v.class == VisualClass::TRUE_COLOR)
226        .map(|v| v.visual_id)
227}
228
229/// RANDR primary-monitor geometry `(x, y, w, h)`, falling back to the whole X
230/// screen if RANDR/primary is unavailable.
231fn primary_monitor<C: Connection>(conn: &C, root: Window, screen: &Screen) -> (i32, i32, i32, i32) {
232    let fallback = (
233        0,
234        0,
235        screen.width_in_pixels as i32,
236        screen.height_in_pixels as i32,
237    );
238    let prim = match conn.randr_get_output_primary(root).ok().and_then(|c| c.reply().ok()) {
239        Some(p) if p.output != NONE => p.output,
240        _ => return fallback,
241    };
242    let info = match conn.randr_get_output_info(prim, 0).ok().and_then(|c| c.reply().ok()) {
243        Some(i) if i.crtc != NONE => i,
244        _ => return fallback,
245    };
246    match conn.randr_get_crtc_info(info.crtc, 0).ok().and_then(|c| c.reply().ok()) {
247        Some(c) => (c.x as i32, c.y as i32, c.width as i32, c.height as i32),
248        None => fallback,
249    }
250}
251
252/// Best-effort EWMH hints (harmless under `override_redirect`): mark as a splash,
253/// ask for above + no taskbar/pager entry.
254fn set_ewmh_hints<C: Connection>(conn: &C, win: Window) -> Result<(), Box<dyn Error>> {
255    let atom = |name: &[u8]| -> Result<u32, Box<dyn Error>> {
256        Ok(conn.intern_atom(false, name)?.reply()?.atom)
257    };
258
259    let wm_type = atom(b"_NET_WM_WINDOW_TYPE")?;
260    let wm_type_splash = atom(b"_NET_WM_WINDOW_TYPE_SPLASH")?;
261    conn.change_property32(PropMode::REPLACE, win, wm_type, AtomEnum::ATOM, &[wm_type_splash])?;
262
263    let wm_state = atom(b"_NET_WM_STATE")?;
264    let above = atom(b"_NET_WM_STATE_ABOVE")?;
265    let skip_taskbar = atom(b"_NET_WM_STATE_SKIP_TASKBAR")?;
266    let skip_pager = atom(b"_NET_WM_STATE_SKIP_PAGER")?;
267    conn.change_property32(
268        PropMode::REPLACE,
269        win,
270        wm_state,
271        AtomEnum::ATOM,
272        &[above, skip_taskbar, skip_pager],
273    )?;
274
275    Ok(())
276}