Skip to main content

agentmux_launcher/
splash_info.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Identity shown in the splash footer: `user@host` and `v<version>` (+ a dev
5//! label on non-stable builds). Gathered once in the launcher and handed to each
6//! platform's splash backend, so the three render identical content.
7//!
8//! All sourcing is dependency-light and best-effort — a missing field falls back
9//! to a placeholder and never blocks or crashes the splash.
10
11#[cfg(windows)]
12use agentmux_common::win32::NoWindow;
13use std::process::Command;
14
15pub struct SplashInfo {
16    pub user: String,
17    pub host: String,
18    pub version: String,
19    /// `Some` only on non-stable builds (dev / explicit channel); `None` on a
20    /// stable release (footer then shows just the version).
21    pub dev_label: Option<String>,
22}
23
24impl SplashInfo {
25    pub fn gather() -> Self {
26        SplashInfo {
27            user: username(),
28            host: hostname(),
29            version: env!("CARGO_PKG_VERSION").to_string(),
30            dev_label: dev_label(),
31        }
32    }
33
34    /// The footer lines to render (each clamped to `max_chars`):
35    /// line 1 = `user@host`, line 2 = `v<version>` (+ ` DEV` on dev builds).
36    pub fn footer_lines(&self, max_chars: usize) -> Vec<String> {
37        let l1 = ellipsize(&format!("{}@{}", self.user, self.host), max_chars);
38        let l2 = match &self.dev_label {
39            Some(d) => format!("v{} {}", self.version, d.to_uppercase()),
40            None => format!("v{}", self.version),
41        };
42        vec![l1, ellipsize(&l2, max_chars)]
43    }
44}
45
46fn username() -> String {
47    for k in ["USER", "USERNAME", "LOGNAME"] {
48        if let Ok(v) = std::env::var(k) {
49            let v = sanitize(v.trim());
50            if !v.is_empty() {
51                return v;
52            }
53        }
54    }
55    cmd_first_line("whoami", &[])
56        .map(|s| sanitize(s.trim()))
57        .filter(|s| !s.is_empty())
58        .unwrap_or_else(|| "user".into())
59}
60
61fn hostname() -> String {
62    // Prefer env (no subprocess); COMPUTERNAME on Windows, HOSTNAME elsewhere.
63    for k in ["HOSTNAME", "COMPUTERNAME", "HOST"] {
64        if let Ok(v) = std::env::var(k) {
65            let v = short_host(v.trim());
66            if !v.is_empty() {
67                return v;
68            }
69        }
70    }
71    cmd_first_line("hostname", &[])
72        .map(|s| short_host(s.trim()))
73        .filter(|s| !s.is_empty())
74        .unwrap_or_else(|| "host".into())
75}
76
77/// `None` on a stable release; otherwise a short badge.
78/// - `AGENTMUX_DEV=1` (set by `task dev`) → `dev`.
79/// - explicit `AGENTMUX_CHANNEL` other than `stable` → that channel.
80/// (Baked per-build local channels without an env override are treated as stable
81/// for the footer in v1 — see SPEC §3 "dev_label".)
82fn dev_label() -> Option<String> {
83    if std::env::var("AGENTMUX_DEV")
84        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
85        .unwrap_or(false)
86    {
87        return Some("dev".into());
88    }
89    if let Ok(ch) = std::env::var("AGENTMUX_CHANNEL") {
90        let ch = sanitize(ch.trim());
91        if !ch.is_empty() && ch != "stable" {
92            return Some(ch);
93        }
94    }
95    None
96}
97
98/// First non-empty stdout line of `cmd args`, trimmed. `None` on any failure.
99fn cmd_first_line(cmd: &str, args: &[&str]) -> Option<String> {
100    let mut command = Command::new(cmd);
101    command.args(args);
102    #[cfg(windows)]
103    {
104        // CREATE_NO_WINDOW: console-flash suppression.
105        command.no_window();
106    }
107    let out = command.output().ok()?;
108    if !out.status.success() {
109        return None;
110    }
111    String::from_utf8_lossy(&out.stdout)
112        .lines()
113        .map(|l| l.trim().to_string())
114        .find(|l| !l.is_empty())
115}
116
117/// Keep printable ASCII (0x20..0x7E); replace anything else with '?'. The splash
118/// font only carries ASCII, so this keeps the footer legible for odd names.
119fn sanitize(s: &str) -> String {
120    s.chars()
121        .map(|c| if (' '..='~').contains(&c) { c } else { '?' })
122        .collect()
123}
124
125/// Short hostname: first dot-segment, sanitized (`devbox.local` → `devbox`).
126fn short_host(s: &str) -> String {
127    sanitize(s.split('.').next().unwrap_or(s))
128}
129
130/// Middle-ellipsize `s` to at most `max` chars so the footer never widens the card.
131fn ellipsize(s: &str, max: usize) -> String {
132    let chars: Vec<char> = s.chars().collect();
133    if chars.len() <= max {
134        return s.to_string();
135    }
136    if max <= 3 {
137        return chars.iter().take(max).collect();
138    }
139    let keep = max - 3;
140    let left = keep.div_ceil(2);
141    let right = keep - left;
142    let head: String = chars[..left].iter().collect();
143    let tail: String = chars[chars.len() - right..].iter().collect();
144    format!("{head}...{tail}")
145}