agentmux_launcher/
splash.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Native pre-splash for Windows: a borderless layered popup showing
5//! the AgentMux brain logo (pulsing) on a solid dark background while
6//! CefInitialize runs (200–600 ms cold start).
7//!
8//! `spawn_splash(dir_hash)` is called right after the single-instance
9//! pipe is claimed — before srv spawn, before CEF init (~10 ms into
10//! the launcher process). The returned event name is passed to the
11//! CEF host as `AGENTMUX_SPLASH_EVENT`; the host signals it from
12//! `on_load_end` to trigger a smooth fade-out.
13//!
14//! ## Layout & animation
15//!
16//! ```
17//! ┌─ SPLASH_SIZE × SPLASH_SIZE ─┐
18//! │ solid BG_COLOR              │
19//! │   ┌─ BRAIN_W × BRAIN_H ─┐   │
20//! │   │ brain glyph        │   │   ← pulsing alpha 160..220
21//! │   │ (transparent png)  │   │
22//! │   └────────────────────┘   │
23//! │                            │
24//! └────────────────────────────┘
25//! ```
26//!
27//! The background is fully opaque and never changes. ONLY the brain
28//! glyph's alpha pulses (sine wave, 1.1 Hz). Painted via
29//! `UpdateLayeredWindow` + a pre-multiplied 32-bpp DIB section, so
30//! per-pixel transparency works correctly (the previous
31//! `SetLayeredWindowAttributes(LWA_ALPHA)` pulsed the whole window
32//! together, which made the background appear to breathe too — see
33//! `docs/retro/2026-05-13-splash-icon-and-pulse-target.md`).
34
35#![cfg(target_os = "windows")]
36
37use std::thread;
38use windows_sys::Win32::Foundation::*;
39use windows_sys::Win32::Graphics::Gdi::*;
40use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW;
41use windows_sys::Win32::System::Threading::*;
42use windows_sys::Win32::UI::WindowsAndMessaging::*;
43
44// Brain bitmap dimensions, generated by build.rs from the actual
45// `resources/brain.png` so swapping the asset can't desync the
46// renderer. Provides `BRAIN_W` / `BRAIN_H` as `i32` consts.
47include!(concat!(env!("OUT_DIR"), "/brain_dims.rs"));
48
49// Splash window is the brain bitmap plus a 12px BG border on each side, with an
50// identity footer band added below (SPEC_SPLASH_USERINFO_AND_DISABLE_2026_06_21).
51const SPLASH_PADDING: i32 = 12;
52/// Brain-region square (brain + border). The card adds the footer band below.
53const SPLASH_SIZE: i32 = BRAIN_W + SPLASH_PADDING * 2;
54const BRAIN_X: i32 = SPLASH_PADDING;
55const BRAIN_Y: i32 = SPLASH_PADDING;
56
57// ── Footer (identity strip near the bottom) ─────────────────────────────────
58/// Muted footer text color (R, G, B) = #8A8A93.
59const FOOTER_COLOR: [u8; 3] = [0x8A, 0x8A, 0x93];
60const FOOTER_PAD_TOP: i32 = 12;
61const FOOTER_LINE_GAP: i32 = 3;
62const FOOTER_PAD_BOTTOM: i32 = 12;
63const FOOTER_H: i32 = FOOTER_PAD_TOP
64    + 2 * crate::splash_font::GLYPH_H as i32
65    + FOOTER_LINE_GAP
66    + FOOTER_PAD_BOTTOM;
67/// Full card: width = brain-region; height = brain-region + footer band.
68const SPLASH_W: i32 = SPLASH_SIZE;
69const SPLASH_H: i32 = SPLASH_SIZE + FOOTER_H;
70
71// Background color (B, G, R) — dark app background. Stored as
72// channels rather than a packed COLORREF because the compositor
73// reads them per-pixel.
74const BG_B: u8 = 0x1F;
75const BG_G: u8 = 0x1A;
76const BG_R: u8 = 0x1A;
77
78// Brain pixels — pre-multiplied BGRA bytes generated by build.rs from
79// `resources/brain.png`. Length = BRAIN_W * BRAIN_H * 4.
80static BRAIN_BGRA: &[u8] =
81    include_bytes!(concat!(env!("OUT_DIR"), "/brain_bgra.bin"));
82
83// HANDLE is a raw pointer; wrap it to cross the thread boundary safely.
84// Use `.take()` (not `.0`) inside move closures: Rust 2021 precise
85// capture would otherwise capture the field `*mut c_void` directly,
86// bypassing the `Send` impl.
87struct SendHandle(HANDLE);
88unsafe impl Send for SendHandle {}
89impl SendHandle {
90    fn take(self) -> HANDLE { self.0 }
91}
92
93/// Spawn the pre-splash thread and return the named Win32 event name
94/// to pass to the CEF host as `AGENTMUX_SPLASH_EVENT`.
95/// Returns `None` if OS calls fail (non-fatal — launcher continues).
96pub fn spawn_splash(dir_hash: &str) -> Option<String> {
97    let event_name = format!("AgentMuxSplash-{}", dir_hash);
98    let nul_name: Vec<u16> = format!("{}\0", event_name)
99        .encode_utf16()
100        .collect();
101
102    let ev = unsafe {
103        CreateEventW(
104            std::ptr::null(), // default security
105            1,                // manual-reset
106            0,                // not signaled
107            nul_name.as_ptr(),
108        )
109    };
110    if ev.is_null() {
111        crate::log("splash: CreateEventW failed — skipping splash");
112        return None;
113    }
114
115    // Per-instance window class so concurrent instances never contend on a
116    // single global RegisterClassExW name — keeps every named OS object keyed
117    // on dir_hash (the splash dismiss event already is). See
118    // docs/specs/SPEC_MULTI_INSTANCE_ISOLATION_HARDENING_2026_06_03.md.
119    let class_name = format!("AgentMuxSplash-{}", dir_hash);
120    let handle = SendHandle(ev);
121    thread::spawn(move || unsafe { run_splash(handle.take(), class_name) });
122    Some(event_name)
123}
124
125unsafe fn run_splash(dismiss_ev: HANDLE, class_name: String) {
126    let class: Vec<u16> = format!("{}\0", class_name).encode_utf16().collect();
127    let hinst = GetModuleHandleW(std::ptr::null());
128
129    let wc = WNDCLASSEXW {
130        cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
131        style: 0,
132        lpfnWndProc: Some(DefWindowProcW),
133        cbClsExtra: 0,
134        cbWndExtra: 0,
135        hInstance: hinst,
136        hIcon: std::ptr::null_mut(),
137        hCursor: std::ptr::null_mut(),
138        hbrBackground: std::ptr::null_mut(),
139        lpszMenuName: std::ptr::null(),
140        lpszClassName: class.as_ptr(),
141        hIconSm: std::ptr::null_mut(),
142    };
143    // Silently tolerate ERROR_CLASS_ALREADY_EXISTS in dev hot-reload.
144    RegisterClassExW(&wc);
145
146    let sw = GetSystemMetrics(SM_CXSCREEN);
147    let sh = GetSystemMetrics(SM_CYSCREEN);
148    let x = (sw - SPLASH_W) / 2;
149    let y = (sh - SPLASH_H) / 2;
150
151    let hwnd = CreateWindowExW(
152        WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE,
153        class.as_ptr(),
154        std::ptr::null(),     // no title
155        WS_POPUP,
156        x, y, SPLASH_W, SPLASH_H,
157        std::ptr::null_mut(), // no parent
158        std::ptr::null_mut(), // no menu
159        hinst,
160        std::ptr::null(),     // no CREATESTRUCT data
161    );
162    if hwnd.is_null() {
163        CloseHandle(dismiss_ev);
164        return;
165    }
166
167    // Build the 32-bpp top-down DIB section we composite into each
168    // frame. UpdateLayeredWindow takes the DIB's HBITMAP via a memory
169    // DC, so we need a paired CompatibleDC + DIB section that lives
170    // for the splash's whole lifetime.
171    let screen_dc = GetDC(std::ptr::null_mut());
172    let mem_dc = CreateCompatibleDC(screen_dc);
173    let mut bmi: BITMAPINFO = std::mem::zeroed();
174    bmi.bmiHeader.biSize = std::mem::size_of::<BITMAPINFOHEADER>() as u32;
175    bmi.bmiHeader.biWidth = SPLASH_W;
176    // Negative height = top-down rows (matches our pixel layout).
177    bmi.bmiHeader.biHeight = -SPLASH_H;
178    bmi.bmiHeader.biPlanes = 1;
179    bmi.bmiHeader.biBitCount = 32;
180    bmi.bmiHeader.biCompression = BI_RGB as u32;
181
182    let mut dib_pixels_raw: *mut core::ffi::c_void = std::ptr::null_mut();
183    let dib = CreateDIBSection(
184        mem_dc,
185        &bmi,
186        DIB_RGB_COLORS,
187        &mut dib_pixels_raw,
188        std::ptr::null_mut(),
189        0,
190    );
191    if dib.is_null() || dib_pixels_raw.is_null() {
192        ReleaseDC(std::ptr::null_mut(), screen_dc);
193        DeleteDC(mem_dc);
194        DestroyWindow(hwnd);
195        CloseHandle(dismiss_ev);
196        return;
197    }
198    let old_obj = SelectObject(mem_dc, dib as _);
199
200    let dib_pixels = std::slice::from_raw_parts_mut(
201        dib_pixels_raw as *mut u8,
202        (SPLASH_W * SPLASH_H * 4) as usize,
203    );
204
205    ShowWindow(hwnd, SW_SHOWNOACTIVATE);
206
207    // Footer identity, gathered once and clamped to the card width.
208    let info = crate::splash_info::SplashInfo::gather();
209    let max_chars = ((SPLASH_W - 24) / crate::splash_font::GLYPH_W as i32).max(8) as usize;
210    let footer = info.footer_lines(max_chars);
211
212    let start = std::time::Instant::now();
213
214    // Animation: brain alpha 0→220 over the first 200 ms, then sine
215    // pulse 160..220 at 1.1 Hz. Background stays opaque throughout.
216    loop {
217        // Non-blocking dismiss check — fires when on_load_end signals.
218        if WaitForSingleObject(dismiss_ev, 0) == WAIT_OBJECT_0 {
219            fade_out(hwnd, mem_dc, dib_pixels);
220            break;
221        }
222
223        let t = start.elapsed().as_secs_f32();
224        let brain_alpha: u8 = if t < 0.2 {
225            (t / 0.2 * 220.0) as u8
226        } else {
227            let pulse = (((t - 0.2) * std::f32::consts::TAU * 1.1).sin() + 1.0) * 0.5;
228            (160.0 + pulse * 60.0) as u8
229        };
230
231        composite(dib_pixels, brain_alpha, &footer);
232        push_layered(hwnd, mem_dc, 255);
233
234        std::thread::sleep(std::time::Duration::from_millis(16)); // ~60 fps
235    }
236
237    SelectObject(mem_dc, old_obj);
238    DeleteObject(dib as _);
239    DeleteDC(mem_dc);
240    ReleaseDC(std::ptr::null_mut(), screen_dc);
241    DestroyWindow(hwnd);
242    CloseHandle(dismiss_ev);
243}
244
245/// Compose one frame into the DIB: solid BG fill, then the brain
246/// blended on top at `brain_alpha`.
247///
248/// Brain bytes are pre-multiplied BGRA (alpha already baked into
249/// RGB by build.rs). Modulating by `brain_alpha / 255` keeps them
250/// pre-multiplied for `UpdateLayeredWindow`'s AC_SRC_ALPHA blend.
251fn composite(dib: &mut [u8], brain_alpha: u8, footer: &[String]) {
252    // Fast path: fill with opaque BG. Loop unrolled by the compiler.
253    for px in dib.chunks_exact_mut(4) {
254        px[0] = BG_B;
255        px[1] = BG_G;
256        px[2] = BG_R;
257        px[3] = 0xFF;
258    }
259
260    // Composite the brain in the centered region. For each brain
261    // pixel: scale by brain_alpha/255 (still pre-multiplied), then
262    // standard premultiplied OVER onto the BG.
263    let ba = brain_alpha as u16;
264    for y in 0..BRAIN_H {
265        let dib_row = ((BRAIN_Y + y) * SPLASH_W * 4) as usize;
266        let src_row = (y * BRAIN_W * 4) as usize;
267        for x in 0..BRAIN_W {
268            let di = dib_row + ((BRAIN_X + x) * 4) as usize;
269            let si = src_row + (x * 4) as usize;
270            let sb = BRAIN_BGRA[si] as u16;
271            let sg = BRAIN_BGRA[si + 1] as u16;
272            let sr = BRAIN_BGRA[si + 2] as u16;
273            let sa = BRAIN_BGRA[si + 3] as u16;
274            if sa == 0 {
275                continue;
276            }
277            // Modulate pre-multiplied source by brain_alpha.
278            let mb = (sb * ba + 127) / 255;
279            let mg = (sg * ba + 127) / 255;
280            let mr = (sr * ba + 127) / 255;
281            let ma = (sa * ba + 127) / 255;
282            // OVER: out = src + dst * (1 - src.a). dst.a stays 255.
283            let inv = 255u16 - ma;
284            let bb = (dib[di] as u16) * inv / 255 + mb;
285            let bg = (dib[di + 1] as u16) * inv / 255 + mg;
286            let br = (dib[di + 2] as u16) * inv / 255 + mr;
287            dib[di] = bb.min(255) as u8;
288            dib[di + 1] = bg.min(255) as u8;
289            dib[di + 2] = br.min(255) as u8;
290            // dib[di+3] left at 255 — window stays opaque.
291        }
292    }
293
294    // Footer: muted identity lines in the bottom band. window_alpha = 1.0 — the
295    // whole window fades uniformly via the layered-window constant alpha
296    // (fade_out / push_layered), so the footer needs no per-pixel fade.
297    let footer_top = SPLASH_H - FOOTER_H + FOOTER_PAD_TOP;
298    for (i, line) in footer.iter().enumerate() {
299        let y = footer_top + i as i32 * (crate::splash_font::GLYPH_H as i32 + FOOTER_LINE_GAP);
300        crate::splash_text::draw_text_centered(dib, SPLASH_W, SPLASH_H, y, line, FOOTER_COLOR, 1.0, true);
301    }
302}
303
304unsafe fn push_layered(hwnd: HWND, mem_dc: HDC, source_alpha: u8) {
305    let mut sz = SIZE {
306        cx: SPLASH_W,
307        cy: SPLASH_H,
308    };
309    let mut src_pt = POINT { x: 0, y: 0 };
310    let blend = BLENDFUNCTION {
311        BlendOp: AC_SRC_OVER as u8,
312        BlendFlags: 0,
313        SourceConstantAlpha: source_alpha,
314        AlphaFormat: AC_SRC_ALPHA as u8,
315    };
316    UpdateLayeredWindow(
317        hwnd,
318        std::ptr::null_mut(),
319        std::ptr::null(),
320        &mut sz,
321        mem_dc,
322        &mut src_pt,
323        0,
324        &blend,
325        ULW_ALPHA,
326    );
327}
328
329/// Fade the splash window to transparent over ~160 ms then return.
330/// Composite stays the same; only the layered-window constant alpha
331/// ramps down, so the whole splash fades uniformly.
332unsafe fn fade_out(hwnd: HWND, mem_dc: HDC, _dib: &mut [u8]) {
333    let mut alpha: i32 = 255;
334    while alpha > 0 {
335        alpha -= 25;
336        if alpha < 0 {
337            alpha = 0;
338        }
339        push_layered(hwnd, mem_dc, alpha as u8);
340        std::thread::sleep(std::time::Duration::from_millis(16));
341    }
342}