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}