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/retros/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.
50const SPLASH_PADDING: i32 = 12;
51const SPLASH_SIZE: i32 = BRAIN_W + SPLASH_PADDING * 2;
52const BRAIN_X: i32 = SPLASH_PADDING;
53const BRAIN_Y: i32 = SPLASH_PADDING;
54
55// Background color (B, G, R) — dark app background. Stored as
56// channels rather than a packed COLORREF because the compositor
57// reads them per-pixel.
58const BG_B: u8 = 0x1F;
59const BG_G: u8 = 0x1A;
60const BG_R: u8 = 0x1A;
61
62// Brain pixels — pre-multiplied BGRA bytes generated by build.rs from
63// `resources/brain.png`. Length = BRAIN_W * BRAIN_H * 4.
64static BRAIN_BGRA: &[u8] =
65    include_bytes!(concat!(env!("OUT_DIR"), "/brain_bgra.bin"));
66
67// HANDLE is a raw pointer; wrap it to cross the thread boundary safely.
68// Use `.take()` (not `.0`) inside move closures: Rust 2021 precise
69// capture would otherwise capture the field `*mut c_void` directly,
70// bypassing the `Send` impl.
71struct SendHandle(HANDLE);
72unsafe impl Send for SendHandle {}
73impl SendHandle {
74    fn take(self) -> HANDLE { self.0 }
75}
76
77/// Spawn the pre-splash thread and return the named Win32 event name
78/// to pass to the CEF host as `AGENTMUX_SPLASH_EVENT`.
79/// Returns `None` if OS calls fail (non-fatal — launcher continues).
80pub fn spawn_splash(dir_hash: &str) -> Option<String> {
81    let event_name = format!("AgentMuxSplash-{}", dir_hash);
82    let nul_name: Vec<u16> = format!("{}\0", event_name)
83        .encode_utf16()
84        .collect();
85
86    let ev = unsafe {
87        CreateEventW(
88            std::ptr::null(), // default security
89            1,                // manual-reset
90            0,                // not signaled
91            nul_name.as_ptr(),
92        )
93    };
94    if ev.is_null() {
95        crate::log("splash: CreateEventW failed — skipping splash");
96        return None;
97    }
98
99    let handle = SendHandle(ev);
100    thread::spawn(move || unsafe { run_splash(handle.take()) });
101    Some(event_name)
102}
103
104unsafe fn run_splash(dismiss_ev: HANDLE) {
105    let class: Vec<u16> = "AgentMuxSplash\0".encode_utf16().collect();
106    let hinst = GetModuleHandleW(std::ptr::null());
107
108    let wc = WNDCLASSEXW {
109        cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
110        style: 0,
111        lpfnWndProc: Some(DefWindowProcW),
112        cbClsExtra: 0,
113        cbWndExtra: 0,
114        hInstance: hinst,
115        hIcon: std::ptr::null_mut(),
116        hCursor: std::ptr::null_mut(),
117        hbrBackground: std::ptr::null_mut(),
118        lpszMenuName: std::ptr::null(),
119        lpszClassName: class.as_ptr(),
120        hIconSm: std::ptr::null_mut(),
121    };
122    // Silently tolerate ERROR_CLASS_ALREADY_EXISTS in dev hot-reload.
123    RegisterClassExW(&wc);
124
125    let sw = GetSystemMetrics(SM_CXSCREEN);
126    let sh = GetSystemMetrics(SM_CYSCREEN);
127    let x = (sw - SPLASH_SIZE) / 2;
128    let y = (sh - SPLASH_SIZE) / 2;
129
130    let hwnd = CreateWindowExW(
131        WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE,
132        class.as_ptr(),
133        std::ptr::null(),     // no title
134        WS_POPUP,
135        x, y, SPLASH_SIZE, SPLASH_SIZE,
136        std::ptr::null_mut(), // no parent
137        std::ptr::null_mut(), // no menu
138        hinst,
139        std::ptr::null(),     // no CREATESTRUCT data
140    );
141    if hwnd.is_null() {
142        CloseHandle(dismiss_ev);
143        return;
144    }
145
146    // Build the 32-bpp top-down DIB section we composite into each
147    // frame. UpdateLayeredWindow takes the DIB's HBITMAP via a memory
148    // DC, so we need a paired CompatibleDC + DIB section that lives
149    // for the splash's whole lifetime.
150    let screen_dc = GetDC(std::ptr::null_mut());
151    let mem_dc = CreateCompatibleDC(screen_dc);
152    let mut bmi: BITMAPINFO = std::mem::zeroed();
153    bmi.bmiHeader.biSize = std::mem::size_of::<BITMAPINFOHEADER>() as u32;
154    bmi.bmiHeader.biWidth = SPLASH_SIZE;
155    // Negative height = top-down rows (matches our pixel layout).
156    bmi.bmiHeader.biHeight = -SPLASH_SIZE;
157    bmi.bmiHeader.biPlanes = 1;
158    bmi.bmiHeader.biBitCount = 32;
159    bmi.bmiHeader.biCompression = BI_RGB as u32;
160
161    let mut dib_pixels_raw: *mut core::ffi::c_void = std::ptr::null_mut();
162    let dib = CreateDIBSection(
163        mem_dc,
164        &bmi,
165        DIB_RGB_COLORS,
166        &mut dib_pixels_raw,
167        std::ptr::null_mut(),
168        0,
169    );
170    if dib.is_null() || dib_pixels_raw.is_null() {
171        ReleaseDC(std::ptr::null_mut(), screen_dc);
172        DeleteDC(mem_dc);
173        DestroyWindow(hwnd);
174        CloseHandle(dismiss_ev);
175        return;
176    }
177    let old_obj = SelectObject(mem_dc, dib as _);
178
179    let dib_pixels = std::slice::from_raw_parts_mut(
180        dib_pixels_raw as *mut u8,
181        (SPLASH_SIZE * SPLASH_SIZE * 4) as usize,
182    );
183
184    ShowWindow(hwnd, SW_SHOWNOACTIVATE);
185
186    let start = std::time::Instant::now();
187
188    // Animation: brain alpha 0→220 over the first 200 ms, then sine
189    // pulse 160..220 at 1.1 Hz. Background stays opaque throughout.
190    loop {
191        // Non-blocking dismiss check — fires when on_load_end signals.
192        if WaitForSingleObject(dismiss_ev, 0) == WAIT_OBJECT_0 {
193            fade_out(hwnd, mem_dc, dib_pixels);
194            break;
195        }
196
197        let t = start.elapsed().as_secs_f32();
198        let brain_alpha: u8 = if t < 0.2 {
199            (t / 0.2 * 220.0) as u8
200        } else {
201            let pulse = (((t - 0.2) * std::f32::consts::TAU * 1.1).sin() + 1.0) * 0.5;
202            (160.0 + pulse * 60.0) as u8
203        };
204
205        composite(dib_pixels, brain_alpha);
206        push_layered(hwnd, mem_dc, 255);
207
208        std::thread::sleep(std::time::Duration::from_millis(16)); // ~60 fps
209    }
210
211    SelectObject(mem_dc, old_obj);
212    DeleteObject(dib as _);
213    DeleteDC(mem_dc);
214    ReleaseDC(std::ptr::null_mut(), screen_dc);
215    DestroyWindow(hwnd);
216    CloseHandle(dismiss_ev);
217}
218
219/// Compose one frame into the DIB: solid BG fill, then the brain
220/// blended on top at `brain_alpha`.
221///
222/// Brain bytes are pre-multiplied BGRA (alpha already baked into
223/// RGB by build.rs). Modulating by `brain_alpha / 255` keeps them
224/// pre-multiplied for `UpdateLayeredWindow`'s AC_SRC_ALPHA blend.
225fn composite(dib: &mut [u8], brain_alpha: u8) {
226    // Fast path: fill with opaque BG. Loop unrolled by the compiler.
227    for px in dib.chunks_exact_mut(4) {
228        px[0] = BG_B;
229        px[1] = BG_G;
230        px[2] = BG_R;
231        px[3] = 0xFF;
232    }
233
234    // Composite the brain in the centered region. For each brain
235    // pixel: scale by brain_alpha/255 (still pre-multiplied), then
236    // standard premultiplied OVER onto the BG.
237    let ba = brain_alpha as u16;
238    for y in 0..BRAIN_H {
239        let dib_row = ((BRAIN_Y + y) * SPLASH_SIZE * 4) as usize;
240        let src_row = (y * BRAIN_W * 4) as usize;
241        for x in 0..BRAIN_W {
242            let di = dib_row + ((BRAIN_X + x) * 4) as usize;
243            let si = src_row + (x * 4) as usize;
244            let sb = BRAIN_BGRA[si] as u16;
245            let sg = BRAIN_BGRA[si + 1] as u16;
246            let sr = BRAIN_BGRA[si + 2] as u16;
247            let sa = BRAIN_BGRA[si + 3] as u16;
248            if sa == 0 {
249                continue;
250            }
251            // Modulate pre-multiplied source by brain_alpha.
252            let mb = (sb * ba + 127) / 255;
253            let mg = (sg * ba + 127) / 255;
254            let mr = (sr * ba + 127) / 255;
255            let ma = (sa * ba + 127) / 255;
256            // OVER: out = src + dst * (1 - src.a). dst.a stays 255.
257            let inv = 255u16 - ma;
258            let bb = (dib[di] as u16) * inv / 255 + mb;
259            let bg = (dib[di + 1] as u16) * inv / 255 + mg;
260            let br = (dib[di + 2] as u16) * inv / 255 + mr;
261            dib[di] = bb.min(255) as u8;
262            dib[di + 1] = bg.min(255) as u8;
263            dib[di + 2] = br.min(255) as u8;
264            // dib[di+3] left at 255 — window stays opaque.
265        }
266    }
267}
268
269unsafe fn push_layered(hwnd: HWND, mem_dc: HDC, source_alpha: u8) {
270    let mut sz = SIZE {
271        cx: SPLASH_SIZE,
272        cy: SPLASH_SIZE,
273    };
274    let mut src_pt = POINT { x: 0, y: 0 };
275    let blend = BLENDFUNCTION {
276        BlendOp: AC_SRC_OVER as u8,
277        BlendFlags: 0,
278        SourceConstantAlpha: source_alpha,
279        AlphaFormat: AC_SRC_ALPHA as u8,
280    };
281    UpdateLayeredWindow(
282        hwnd,
283        std::ptr::null_mut(),
284        std::ptr::null(),
285        &mut sz,
286        mem_dc,
287        &mut src_pt,
288        0,
289        &blend,
290        ULW_ALPHA,
291    );
292}
293
294/// Fade the splash window to transparent over ~160 ms then return.
295/// Composite stays the same; only the layered-window constant alpha
296/// ramps down, so the whole splash fades uniformly.
297unsafe fn fade_out(hwnd: HWND, mem_dc: HDC, _dib: &mut [u8]) {
298    let mut alpha: i32 = 255;
299    while alpha > 0 {
300        alpha -= 25;
301        if alpha < 0 {
302            alpha = 0;
303        }
304        push_layered(hwnd, mem_dc, alpha as u8);
305        std::thread::sleep(std::time::Duration::from_millis(16));
306    }
307}