fix(installer): strip ANSI escapes from live log events
The macOS setup app drives a shell install script whose output carries SGR styling, cursor movement, and OSC title commands; the Live output pane renders log lines as plain text, so those bytes showed up as mojibake (#112675). Strip escape sequences at the Rust-to-webview event boundary (bootstrap and update flows) and collapse carriage-return redraws to the last visible frame. The on-disk tee keeps the raw bytes for terminal viewing. Fixes #112675
This commit is contained in:
@@ -985,6 +985,10 @@ fn build_pin_args(script: &install_script::ResolvedScript) -> Vec<String> {
|
||||
}
|
||||
|
||||
fn emit_event(app: &AppHandle, event: BootstrapEvent) {
|
||||
// The webview shows log lines as plain text, so ANSI styling/cursor
|
||||
// bytes from install.sh must not cross the event boundary (#112675).
|
||||
// The disk tee keeps the raw bytes — only the UI payload is sanitized.
|
||||
let event = event.sanitized_for_ui();
|
||||
// Tee important state transitions to the rolling installer log so
|
||||
// bootstrap-installer.log isn't just "starting" + final summary.
|
||||
// Log lines (the noisy stuff) handle their own tracing in
|
||||
|
||||
@@ -109,4 +109,167 @@ impl BootstrapEvent {
|
||||
/// Tauri event name. Single channel for all bootstrap events; the
|
||||
/// `type` tag tells the renderer how to interpret the payload.
|
||||
pub const CHANNEL: &'static str = "bootstrap";
|
||||
|
||||
/// Returns this event with terminal escape bytes removed from `Log`
|
||||
/// lines. The webview renders log lines as plain text, so styling and
|
||||
/// cursor codes from the install script would show up as mojibake
|
||||
/// (#112675).
|
||||
pub fn sanitized_for_ui(self) -> Self {
|
||||
match self {
|
||||
Self::Log {
|
||||
stage,
|
||||
line,
|
||||
stream,
|
||||
} => Self::Log {
|
||||
stage,
|
||||
line: strip_ansi(&line),
|
||||
stream,
|
||||
},
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes ANSI escape sequences from one raw installer log line.
|
||||
///
|
||||
/// install.sh (and the git/curl/uv children it drives) emits SGR styling,
|
||||
/// cursor movement, and OSC title commands even though its stdout is a pipe,
|
||||
/// not a TTY. The UI's log pane has no terminal emulator, so those bytes must
|
||||
/// not cross the event boundary. Carriage-return in-place redraws (progress
|
||||
/// meters) collapse to the last visible frame, which is what a terminal
|
||||
/// would be left showing.
|
||||
pub(crate) fn strip_ansi(line: &str) -> String {
|
||||
const ESC: char = '\u{1b}';
|
||||
|
||||
let mut out = String::with_capacity(line.len());
|
||||
let mut chars = line.chars().peekable();
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
if c != ESC {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
|
||||
match chars.next() {
|
||||
// CSI: parameters (0x30–0x3F) and intermediates (0x20–0x2F),
|
||||
// closed by a final byte in 0x40–0x7E.
|
||||
Some('[') => {
|
||||
for b in chars.by_ref() {
|
||||
if ('\u{40}'..='\u{7e}').contains(&b) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// String sequences (OSC/DCS/PM/APC): run until BEL or the ST
|
||||
// terminator (ESC \).
|
||||
Some(']' | 'P' | 'X' | '^' | '_') => {
|
||||
for b in chars.by_ref() {
|
||||
if b == '\u{07}' {
|
||||
break;
|
||||
}
|
||||
if b == ESC {
|
||||
if chars.peek() == Some(&'\\') {
|
||||
chars.next();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Charset selection ESC ( B and friends carry one trailing byte;
|
||||
// every other two-character escape is fully consumed here.
|
||||
Some('(') | Some(')') => {
|
||||
let _ = chars.next();
|
||||
}
|
||||
Some(_) | None => {}
|
||||
}
|
||||
}
|
||||
|
||||
match out.split('\r').filter(|seg| !seg.is_empty()).next_back() {
|
||||
Some(seg) => seg.to_string(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strips_sgr_color_sequences() {
|
||||
// The colored checkmark banners from install.sh seen in #112675.
|
||||
assert_eq!(
|
||||
strip_ansi("\u{1b}[0;32m✓\u{1b}[0m Detected: macos (macos)"),
|
||||
"✓ Detected: macos (macos)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_cursor_movement_and_private_modes() {
|
||||
assert_eq!(
|
||||
strip_ansi("\u{1b}[2K\u{1b}[1GCloning repository…"),
|
||||
"Cloning repository…"
|
||||
);
|
||||
assert_eq!(strip_ansi("down\u{1b}[?25lloading"), "downloading");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_osc_title_commands_with_bel_and_st_terminators() {
|
||||
assert_eq!(
|
||||
strip_ansi("\u{1b}]0;hermes\u{07}Installing Hermes"),
|
||||
"Installing Hermes"
|
||||
);
|
||||
assert_eq!(
|
||||
strip_ansi("\u{1b}]2;hermes\u{1b}\\Installing Hermes"),
|
||||
"Installing Hermes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn carriage_return_redraws_keep_the_last_frame() {
|
||||
// curl-style progress rewrites one line in place with \r.
|
||||
assert_eq!(strip_ansi("\r 12%\r 67%\r100%"), "100%");
|
||||
assert_eq!(
|
||||
strip_ansi("Resolving dependencies…\r"),
|
||||
"Resolving dependencies…"
|
||||
);
|
||||
assert_eq!(strip_ansi("\r\r"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_plain_and_multibyte_text_verbatim() {
|
||||
assert_eq!(
|
||||
strip_ansi("Installed 12 packages in 1.2s"),
|
||||
"Installed 12 packages in 1.2s"
|
||||
);
|
||||
assert_eq!(strip_ansi("Ready — café ✓ 中文"), "Ready — café ✓ 中文");
|
||||
assert_eq!(strip_ansi(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_an_unterminated_sequence_cut_by_the_pipe() {
|
||||
assert_eq!(strip_ansi("ok\u{1b}[0;3"), "ok");
|
||||
assert_eq!(strip_ansi("ok\u{1b}"), "ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitized_for_ui_only_touches_log_lines() {
|
||||
let log = BootstrapEvent::Log {
|
||||
stage: None,
|
||||
line: "\u{1b}[1;32mdone\u{1b}[0m\r".to_string(),
|
||||
stream: LogStream::Stdout,
|
||||
};
|
||||
match log.sanitized_for_ui() {
|
||||
BootstrapEvent::Log { line, .. } => assert_eq!(line, "done"),
|
||||
other => panic!("expected Log, got {other:?}"),
|
||||
}
|
||||
|
||||
let stage = BootstrapEvent::Failed {
|
||||
stage: None,
|
||||
error: "\u{1b}[0;31mfatal\u{1b}[0m".to_string(),
|
||||
};
|
||||
match stage.sanitized_for_ui() {
|
||||
BootstrapEvent::Failed { error, .. } => assert_eq!(error, "\u{1b}[0;31mfatal\u{1b}[0m"),
|
||||
other => panic!("expected Failed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1308,6 +1308,9 @@ fn option_env_string(key: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
fn emit(app: &AppHandle, event: BootstrapEvent) {
|
||||
// Same UI boundary as bootstrap.rs's emit_event: the update flow's log
|
||||
// lines also reach the plain-text Live output pane (#112675).
|
||||
let event = event.sanitized_for_ui();
|
||||
if let Err(e) = app.emit(BootstrapEvent::CHANNEL, &event) {
|
||||
tracing::warn!(?e, "failed to emit update event");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user