Skip to main content

agentmux_launcher/splash_linux/
mod.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Session-aware Linux startup splash.
5//!
6//! The launcher is up in milliseconds; `agentmux-cef` *is* the multi-second cold
7//! start (CEF init + GPU channel + page load). This paints a pulsing brain logo
8//! over a dark backdrop in that gap — the Linux analogue of `splash.rs` (Win32)
9//! and `splash_mac.rs` (Cocoa).
10//!
11//! The backend is chosen for **centering fidelity**, not to mirror the host's
12//! ozone choice: the X11 backend draws an `override_redirect` window it positions
13//! itself (centered on the primary monitor) and XWayland honors that, so it
14//! centers under both X11 and native-Wayland sessions. A native-Wayland
15//! `xdg_toplevel`, by contrast, cannot self-position — Mutter drops small
16//! toplevels at the top-left, not centered. XWayland is present in nearly every
17//! Wayland session, so we prefer the X11 backend whenever an X server is reachable
18//! and fall back to the Wayland backend only when there is no X display at all.
19//!
20//! Dismiss protocol is the macOS one: `spawn()` sets `AGENTMUX_SPLASH_READY_FILE`
21//! (the host inherits it and writes the file on first paint); the splash thread
22//! polls for that file and tears down, with a safety timeout for a host that
23//! crashes before painting.
24//!
25//! Spec: docs/specs/SPEC_LINUX_SPLASH_SESSION_AWARE_2026_06_20.md
26
27mod wayland;
28mod x11;
29
30use std::sync::mpsc::Receiver;
31use std::time::{Duration, Instant};
32
33use crate::splash_core::{tally_label, trunc, visible_rows, RowKind, StageTimeline};
34use crate::startup_events::{StartupEvent, StartupStatus};
35
36// ── Shared look (matches splash_mac.rs / splash.rs) ─────────────────────────
37/// Opaque dark backdrop RGB(26, 26, 31) = #1A1A1F.
38pub(crate) const BG_R: u8 = 0x1A;
39pub(crate) const BG_G: u8 = 0x1A;
40pub(crate) const BG_B: u8 = 0x1F;
41/// Backdrop padding around the brain, in px.
42pub(crate) const PADDING: i32 = 28;
43/// ~60 fps animation tick.
44pub(crate) const FRAME_MS: u64 = 16;
45const FADE_IN_S: f32 = 0.2;
46const PULSE_HZ: f32 = 1.1;
47const PULSE_MIN: f32 = 0.73;
48const PULSE_MAX: f32 = 1.0;
49/// Self-dismiss if the host never signals first paint (crash before frame).
50pub(crate) const DISMISS_TIMEOUT: Duration = Duration::from_secs(10);
51/// Rounded-corner radius for the backdrop (px). Matches splash_mac.rs.
52pub(crate) const CORNER_RADIUS_PX: f32 = 16.0;
53/// Opacity fade-out duration on dismiss. Matches splash_mac.rs.
54pub(crate) const FADE_OUT: Duration = Duration::from_millis(160);
55
56// Darkened window-edge border — SPEC_SPLASH_SCREEN_BORDER_2026_08_25.md.
57// Roughly half BG's RGB values, a straightforward "darker than the
58// backdrop" reading; not yet confirmed against a real display. Matches
59// splash.rs / splash_mac.rs.
60pub(crate) const BORDER_R: u8 = 0x0D;
61pub(crate) const BORDER_G: u8 = 0x0D;
62pub(crate) const BORDER_B: u8 = 0x10;
63pub(crate) const BORDER_WIDTH_PX: i32 = 2;
64
65/// Brain logo as straight (non-pre-multiplied) RGBA8, emitted by build.rs.
66pub(crate) static BRAIN_RGBA: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/brain_rgba.bin"));
67include!(concat!(env!("OUT_DIR"), "/brain_dims.rs")); // pub const BRAIN_W / BRAIN_H (i32)
68
69// ── Stage list band (startup telemetry between brain and footer) ─────────────
70/// Muted stage text color — slightly brighter than the footer for readability.
71pub(crate) const STAGE_COLOR: [u8; 3] = [0xA0, 0xA0, 0xAA];
72/// Vertical padding above the first stage line.
73pub(crate) const STAGE_PAD_TOP: i32 = 10;
74/// Vertical gap between stage lines.
75pub(crate) const STAGE_LINE_GAP: i32 = 4;
76/// Maximum number of stage lines shown simultaneously.
77pub(crate) const STAGE_MAX_LINES: usize = 4;
78/// Height of the stage band.
79pub(crate) const STAGE_H: i32 = STAGE_PAD_TOP
80    + STAGE_MAX_LINES as i32 * crate::splash_font::GLYPH_H as i32
81    + (STAGE_MAX_LINES as i32 - 1) * STAGE_LINE_GAP;
82/// Left margin for stage lines (px from card edge).
83pub(crate) const STAGE_X: i32 = 20;
84
85// ── Footer (identity strip near the bottom) ─────────────────────────────────
86/// Muted footer text color (#8A8A93) on the #1A1A1F backdrop.
87pub(crate) const FOOTER_COLOR: [u8; 3] = [0x8A, 0x8A, 0x93];
88pub(crate) const FOOTER_PAD_TOP: i32 = 12;
89pub(crate) const FOOTER_LINE_GAP: i32 = 3;
90pub(crate) const FOOTER_PAD_BOTTOM: i32 = 12;
91/// Bottom band height: top pad + 2 glyph rows + inter-line gap + bottom pad.
92pub(crate) const FOOTER_H: i32 =
93    FOOTER_PAD_TOP + 2 * crate::splash_font::GLYPH_H as i32 + FOOTER_LINE_GAP + FOOTER_PAD_BOTTOM;
94/// Full card dimensions: brain + padding, stage band, footer band.
95pub(crate) const CARD_W: i32 = BRAIN_W + PADDING * 2;
96pub(crate) const CARD_H: i32 = BRAIN_H + PADDING * 2 + STAGE_H + FOOTER_H;
97
98// ── Startup telemetry stage list ─────────────────────────────────────────────
99// Row model, event application (with same-tick count-up deferral), and the
100// frozen-row finalizer now live in `splash_core`, shared with `splash.rs`
101// (Windows) and `splash_mac.rs` (macOS) — see that module's doc comment.
102// Only this band's own line-formatting stays here: Linux's tight
103// `STAGE_MAX_LINES` (4, vs. the other two platforms' 12) makes the
104// mac/Windows "other: Xms" / "total: Xms" reconciliation rows a poor fit —
105// they'd frequently crowd out real stage content in a 4-line budget — so
106// that part of the mac/Windows parity work is deliberately not ported here;
107// see docs/reports/REPORT_SPLASH_SCREEN_ARCHITECTURE_RETHINK_2026_09_14.md.
108
109/// Accumulates startup events and emits rendered text lines for the stage band.
110pub(super) struct StageList {
111    timeline: StageTimeline,
112}
113
114impl StageList {
115    pub(super) fn new() -> Self {
116        Self { timeline: StageTimeline::new() }
117    }
118
119    /// Batch-apply one tick's worth of freshly-drained events. Must be
120    /// called with a whole batch, not one event at a time — see
121    /// `StageTimeline::apply_tick`'s doc comment: the same-tick count-up
122    /// deferral only recognizes a Begin+End pair as "same tick" when both
123    /// arrive in the same call.
124    pub(super) fn apply_tick(&mut self, fresh: Vec<StartupEvent>) -> bool {
125        self.timeline.apply_tick(fresh)
126    }
127
128    /// Call once, at the instant dismiss becomes eligible (fade about to
129    /// start), so a row with no matching End — e.g. the host stage if the
130    /// splash times out before the host ever reports it — freezes with a
131    /// definite outcome instead of staying visually "running" for the rest
132    /// of the fade. See `StageTimeline::finalize_running`'s doc comment.
133    pub(super) fn finalize_running(&mut self, at: Instant) {
134        self.timeline.finalize_running(at);
135    }
136
137    /// Up to `STAGE_MAX_LINES` formatted lines for the stage band.
138    ///
139    /// Row selection — which sub-items are shown in detail, which collapse
140    /// into a tally, and which rows survive when the band overflows — is
141    /// `splash_core::visible_rows`, shared with the Windows and macOS
142    /// renderers. This file owns only the formatting. Before that, this
143    /// method showed `subs.last()` and nothing else, so a migration batch
144    /// was one ever-changing line with no sense of how far along it was,
145    /// and the stage loop stopped at the cap oldest-first, which could hide
146    /// the stage actually running.
147    pub(super) fn lines(&self) -> Vec<String> {
148        let mut out = Vec::new();
149        for entry in visible_rows(&self.timeline.stages, STAGE_MAX_LINES) {
150            match entry.kind {
151                RowKind::Stage(s) => {
152            let pfx = match &s.done {
153                None => ">> ",
154                Some((_, status, _)) => match status {
155                    StartupStatus::Ok => "ok ",
156                    _ => "!! ",
157                },
158            };
159            let ms = match &s.done {
160                Some((ms, ..)) => *ms,
161                None => s.started_at.elapsed().as_millis() as u64,
162            };
163            // "ok  Saga recovery       42ms"
164            //
165            // Truncated to the SAME width as the `{:<N}` padding below, not
166            // just an unbounded min-width pad: `label` is `s.label`, a
167            // fixed, short, engineer-chosen string, so this rarely bites —
168            // but `sub.label` below is a migration's free-text
169            // `description()` (since PR #3223, real ones up to 48-90 chars),
170            // and this card is only ~29 chars wide at GLYPH_W=10px
171            // (CARD_W=312px). An unclamped label pushes the whole duration
172            // column off the visible card edge — the label consumes the row
173            // before the live duration is even drawn, so the feature this
174            // PR adds would be invisible on Linux specifically (codex P2 on
175            // #3223). `splash_core::trunc` is the same char-safe truncation
176            // Windows/macOS already apply to their own stage/sub labels.
177            out.push(format!("{pfx}{:<18}{:>5}ms", trunc(s.label, 18), ms));
178                }
179                // Collapsed older sub-items. No duration column: the
180                // per-item times are exactly what this row stands in for.
181                RowKind::SubTally { done, total } => {
182                    out.push(format!("   >{:<16}", trunc(&tally_label(done, total), 16)));
183                }
184                RowKind::Sub(sub) => {
185                    let sub_ms = match &sub.done {
186                        Some((ms, ..)) => *ms,
187                        None => sub.started_at.elapsed().as_millis() as u64,
188                    };
189                    out.push(format!("   >{:<16}{:>5}ms", trunc(&sub.label, 16), sub_ms));
190                }
191            }
192        }
193        out
194    }
195}
196
197enum Session {
198    Wayland,
199    X11,
200}
201
202/// Pick the splash backend by **centering ability**. The X11 backend self-centers
203/// (override-redirect, positioned on the RANDR primary) and XWayland honors that,
204/// so prefer it whenever an X server is actually reachable — including under a
205/// native-Wayland session, where the Wayland backend can't self-center and Mutter
206/// drops the splash top-left. Only with no usable X display do we fall back to the
207/// best-effort Wayland backend.
208///
209/// An explicit `AGENTMUX_OZONE_PLATFORM=x11|wayland` still pins a specific backend
210/// (escape hatch / for exercising the Wayland path itself); note that pinning
211/// `wayland` re-introduces the top-left placement Mutter gives uncentered toplevels.
212fn detect() -> Session {
213    match std::env::var("AGENTMUX_OZONE_PLATFORM").ok().as_deref() {
214        Some("x11") => return Session::X11,
215        Some("wayland") => return Session::Wayland,
216        _ => {}
217    }
218    // Without XAUTHORITY (a login entry, the AppImage binfmt path or an agent
219    // shell doesn't get Mutter's cookie, #4011) XWayland refuses us and we'd fall
220    // back to the uncentered Wayland splash, so find the cookie first. Called from
221    // `spawn()`, where the launcher is still single-threaded.
222    if std::env::var_os("WAYLAND_DISPLAY").is_some_and(|v| !v.is_empty()) {
223        agentmux_common::xauthority::ensure_xauthority();
224    }
225    // `DISPLAY` being set doesn't prove a server is live (stale env / no
226    // XWayland), so probe a real connection — a failed handshake is fast.
227    if x11::server_reachable() {
228        Session::X11
229    } else {
230        Session::Wayland
231    }
232}
233
234/// Minimum on-screen time before the splash is allowed to dismiss, so a fast
235/// cold start can't produce a sub-perceptible flash (worse than no splash).
236/// Overridable via `AGENTMUX_SPLASH_HOLD_MS` — also handy for eyeballing the
237/// splash during testing (e.g. `AGENTMUX_SPLASH_HOLD_MS=3000`).
238pub(crate) fn min_hold() -> Duration {
239    std::env::var("AGENTMUX_SPLASH_HOLD_MS")
240        .ok()
241        .and_then(|s| s.parse::<u64>().ok())
242        .map(Duration::from_millis)
243        .unwrap_or(Duration::from_millis(450))
244}
245
246/// Pulse alpha in 0..=1 for elapsed seconds since the splash appeared: a linear
247/// ramp 0→1 over `FADE_IN_S`, then a `PULSE_HZ` sine between `PULSE_MIN..PULSE_MAX`
248/// (the backdrop stays opaque; only the brain's alpha breathes). Shared by both
249/// backends.
250pub(crate) fn pulse_alpha(t: f32) -> f32 {
251    if t < FADE_IN_S {
252        return t / FADE_IN_S;
253    }
254    let s = (((t - FADE_IN_S) * std::f32::consts::TAU * PULSE_HZ).sin() + 1.0) * 0.5;
255    PULSE_MIN + s * (PULSE_MAX - PULSE_MIN)
256}
257
258/// Global fade-out opacity in 0..=1. Returns 1.0 until `fade_start` is set, then
259/// ramps linearly to 0.0 over `FADE_OUT`. Shared by both backends.
260pub(crate) fn fade_alpha(fade_start: Option<Instant>, now: Instant) -> f32 {
261    match fade_start {
262        None => 1.0,
263        Some(s) => (1.0 - now.duration_since(s).as_secs_f32() / FADE_OUT.as_secs_f32())
264            .clamp(0.0, 1.0),
265    }
266}
267
268/// Rounded-rect coverage (0..=1) at pixel (x, y) for a `w`×`h` rect with corner
269/// `radius` (0 = square), anti-aliased over a 1-px band on the corner arcs.
270fn corner_coverage(x: i32, y: i32, w: i32, h: i32, radius: f32) -> f32 {
271    let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
272    if radius <= 0.0 {
273        // No rounding to compute -- but still bounds-check (x, y) against
274        // the w×h rect rather than unconditionally returning 1.0. The old
275        // unconditional shortcut was only ever safe because render_frame's
276        // one call site always passed in-bounds coordinates by
277        // construction (x in 0..w, y in 0..h); the border-band check below
278        // calls this a second time with coordinates shifted relative to a
279        // SHRUNK inner rect, which are legitimately out-of-bounds for
280        // border-band pixels. Under the square X11-fallback path
281        // (radius=0, no compositor), inner_radius also clamps to 0
282        // (BORDER_WIDTH_PX - BORDER_WIDTH_PX), so without this bounds
283        // check interior_t was always 1.0 and the border never rendered
284        // at all in that fallback (Codex P2, PR #2804 review).
285        return if fx >= 0.0 && fx <= w as f32 && fy >= 0.0 && fy <= h as f32 {
286            1.0
287        } else {
288            0.0
289        };
290    }
291    // Clamp to the corner-arc center; in the straight edges/interior this yields
292    // dist 0 → full coverage. Only the four corner boxes produce a nonzero dist.
293    let cx = fx.clamp(radius, w as f32 - radius);
294    let cy = fy.clamp(radius, h as f32 - radius);
295    let dist = ((fx - cx).powi(2) + (fy - cy).powi(2)).sqrt();
296    if dist <= radius - 0.5 {
297        1.0
298    } else if dist >= radius + 0.5 {
299        0.0
300    } else {
301        radius + 0.5 - dist
302    }
303}
304
305/// Composite one frame, **pre-multiplied**, into a 4-byte-per-pixel buffer: the
306/// dark backdrop with the centered brain blended at `brain_alpha`, the stage list
307/// band, and the footer identity strip — all masked to a `radius`-rounded rect
308/// and scaled by the global `window_alpha` (fade-out). Pre-multiplied output is
309/// what both X Render compositors and `wl_shm` ARGB8888 expect. `bgr` selects
310/// byte order: `true` → B,G,R,A (X11 ARGB / Wayland ARGB8888 on LE), `false` →
311/// R,G,B,A. Shared by both backends.
312///
313/// `radius=0` + `window_alpha=1` yields a fully-opaque square (the X11
314/// no-compositor fallback path). `stages` may be empty (no events received).
315pub(crate) fn render_frame(
316    buf: &mut [u8],
317    w: i32,
318    h: i32,
319    brain_alpha: f32,
320    window_alpha: f32,
321    radius: f32,
322    bgr: bool,
323    footer: &[String],
324    stages: &[String],
325) {
326    let ox = (w - BRAIN_W) / 2;
327    // Center the brain in the brain region (above stage band and footer), not the
328    // whole card — otherwise the stage band + footer would push it off-center.
329    let oy = (h - FOOTER_H - STAGE_H - BRAIN_H) / 2;
330    for y in 0..h {
331        for x in 0..w {
332            // Backdrop, in RGB.
333            let mut rr = BG_R as f32;
334            let mut gg = BG_G as f32;
335            let mut bb = BG_B as f32;
336            // Brain over backdrop (straight alpha).
337            let (bx, by) = (x - ox, y - oy);
338            if bx >= 0 && bx < BRAIN_W && by >= 0 && by < BRAIN_H {
339                let si = ((by * BRAIN_W + bx) * 4) as usize;
340                let ba = (BRAIN_RGBA[si + 3] as f32 / 255.0) * brain_alpha;
341                rr = rr * (1.0 - ba) + BRAIN_RGBA[si] as f32 * ba;
342                gg = gg * (1.0 - ba) + BRAIN_RGBA[si + 1] as f32 * ba;
343                bb = bb * (1.0 - ba) + BRAIN_RGBA[si + 2] as f32 * ba;
344            }
345            // Darkened border band: blend toward BORDER_* the further a pixel
346            // falls outside the shrunk "interior" rect (inset by
347            // BORDER_WIDTH_PX on all sides, radius reduced to match) versus
348            // the outer rect — same anti-aliased coverage math as the corner
349            // rounding itself, just against two nested rects instead of one.
350            // The brain only ever occupies well-interior pixels (PADDING=28
351            // >> BORDER_WIDTH_PX), so blending the already-brain-composited
352            // color here is safe. SPEC_SPLASH_SCREEN_BORDER_2026_08_25.md.
353            let outer_cov = corner_coverage(x, y, w, h, radius);
354            let inner_radius = (radius - BORDER_WIDTH_PX as f32).max(0.0);
355            let inner_cov = corner_coverage(
356                x - BORDER_WIDTH_PX,
357                y - BORDER_WIDTH_PX,
358                w - 2 * BORDER_WIDTH_PX,
359                h - 2 * BORDER_WIDTH_PX,
360                inner_radius,
361            );
362            let interior_t = if outer_cov > 0.0 { (inner_cov / outer_cov).clamp(0.0, 1.0) } else { 0.0 };
363            rr = BORDER_R as f32 * (1.0 - interior_t) + rr * interior_t;
364            gg = BORDER_G as f32 * (1.0 - interior_t) + gg * interior_t;
365            bb = BORDER_B as f32 * (1.0 - interior_t) + bb * interior_t;
366
367            // Coverage (rounded corners) × global fade → pre-multiplied alpha.
368            let a = outer_cov * window_alpha;
369            let di = ((y * w + x) * 4) as usize;
370            let (o0, o2) = if bgr { (bb, rr) } else { (rr, bb) };
371            buf[di] = (o0 * a) as u8;
372            buf[di + 1] = (gg * a) as u8;
373            buf[di + 2] = (o2 * a) as u8;
374            buf[di + 3] = (a * 255.0) as u8;
375        }
376    }
377
378    // Stage list: startup telemetry lines above the footer.
379    let stage_top = h - FOOTER_H - STAGE_H + STAGE_PAD_TOP;
380    for (i, line) in stages.iter().enumerate() {
381        let y = stage_top + i as i32 * (crate::splash_font::GLYPH_H as i32 + STAGE_LINE_GAP);
382        crate::splash_text::draw_text(buf, w, h, STAGE_X, y, line, STAGE_COLOR, window_alpha, bgr);
383    }
384
385    // Footer: muted identity lines in the bottom band (static; fades with the card).
386    let footer_top = h - FOOTER_H + FOOTER_PAD_TOP;
387    for (i, line) in footer.iter().enumerate() {
388        let y = footer_top + i as i32 * (crate::splash_font::GLYPH_H as i32 + FOOTER_LINE_GAP);
389        crate::splash_text::draw_text_centered(buf, w, h, y, line, FOOTER_COLOR, window_alpha, bgr);
390    }
391}
392
393/// Spawn the startup splash. Sets `AGENTMUX_SPLASH_READY_FILE` (inherited by the
394/// host, which writes it on first paint) and runs the matching backend on a
395/// dedicated thread, so the launcher's main thread continues to start srv + host.
396/// The thread self-terminates on first-paint or the safety timeout. Best-effort:
397/// any failure (no display, protocol error) just means no splash — the launcher
398/// is unaffected.
399///
400/// `startup_rx` is the receiver end of the startup event bus. The splash thread
401/// drains it every animation frame to update the stage list. Pass the receiver
402/// from `startup_events::StartupEventSink::new()` created in `main()` before
403/// the Tokio runtime is started.
404pub fn spawn(startup_rx: Receiver<StartupEvent>) {
405    let ready_file =
406        std::env::temp_dir().join(format!("agentmux-splash-ready-{}", std::process::id()));
407    let _ = std::fs::remove_file(&ready_file);
408    // Set before any host is spawned so the host inherits it (same trick as
409    // splash_mac.rs). Safe: the launcher is single-threaded at this point.
410    std::env::set_var("AGENTMUX_SPLASH_READY_FILE", &ready_file);
411
412    // Footer identity, gathered once and clamped to the card width.
413    let info = crate::splash_info::SplashInfo::gather();
414    let max_chars = ((CARD_W - 24) / crate::splash_font::GLYPH_W as i32).max(8) as usize;
415    let footer = info.footer_lines(max_chars);
416
417    match detect() {
418        Session::X11 => {
419            std::thread::Builder::new()
420                .name("agentmux-splash".into())
421                .spawn(move || {
422                    if let Err(e) = x11::run(&ready_file, footer, startup_rx) {
423                        eprintln!("[splash] x11 backend disabled: {e}");
424                    }
425                })
426                .ok();
427        }
428        Session::Wayland => {
429            std::thread::Builder::new()
430                .name("agentmux-splash".into())
431                .spawn(move || {
432                    if let Err(e) = wayland::run(&ready_file, footer, startup_rx) {
433                        eprintln!("[splash] wayland backend disabled: {e}");
434                    }
435                })
436                .ok();
437        }
438    }
439}