//! Live UI for `pkh deb`: a status bar with the current build phase on top //! and a rolling pane of rewritten log lines below ("a terminal in the //! terminal"). //! //! Subprocess output is captured through a [`LineSink`] implementation, //! rewritten by classifiers ([`crate::ui::logfmt`]) and rendered in place //! with indicatif, so pkh's own log lines keep printing above the widget via //! `indicatif-log-bridge`. Every raw captured line is also tee'd to a log //! file under the pkh cache directory. use std::collections::VecDeque; use std::fs::{self, File}; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use crossterm::{cursor, execute, style::Stylize, terminal::Clear, terminal::ClearType}; use directories::ProjectDirs; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use crate::context::{LineSink, Stream}; use crate::ui::logfmt::{ Action, AptInstallClassifier, AptUpdateClassifier, Classifier, GenericClassifier, MakeClassifier, MmdebstrapClassifier, QuiltClassifier, }; /// Number of lines displayed in the rolling pane const PANE_LINES: usize = 4; /// Minimum interval between pane redraws const REDRAW_INTERVAL: Duration = Duration::from_millis(50); /// Build phases of `pkh deb`, shown in the status bar #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Phase { /// Downloading the chroot tarball (mmdebstrap) PreparingChroot, /// Extracting the chroot tarball ExtractingChroot, /// Device nodes, /proc bind mount, etc. FinalizingChroot, /// apt-get update UpdatingPackageLists, /// Installing build-essential & co InstallingEssentials, /// quilt push -a ApplyingPatches, /// --inject packages InjectingPackages, /// apt-get build-dep InstallingBuildDeps, /// debian/rules build Building, /// fakeroot debian/rules binary ProducingBinaries, /// Retrieving produced .deb files RetrievingArtifacts, } impl Phase { /// Human-readable label displayed in the status bar pub fn label(&self) -> &'static str { match self { Phase::PreparingChroot => "Preparing chroot", Phase::ExtractingChroot => "Extracting chroot", Phase::FinalizingChroot => "Finalizing chroot", Phase::UpdatingPackageLists => "Updating package lists", Phase::InstallingEssentials => "Installing essential packages", Phase::ApplyingPatches => "Applying patches", Phase::InjectingPackages => "Injecting packages", Phase::InstallingBuildDeps => "Installing build dependencies", Phase::Building => "Building package", Phase::ProducingBinaries => "Producing binary packages", Phase::RetrievingArtifacts => "Retrieving artifacts", } } } /// Default classifier used for a given phase fn default_classifier(phase: Phase) -> Box { match phase { Phase::PreparingChroot => Box::new(MmdebstrapClassifier::new()), Phase::ExtractingChroot | Phase::FinalizingChroot => Box::new(GenericClassifier::new()), Phase::UpdatingPackageLists => Box::new(AptUpdateClassifier::new()), Phase::InstallingEssentials => Box::new(AptInstallClassifier::new("Installing essentials")), Phase::ApplyingPatches => Box::new(QuiltClassifier::new(0)), Phase::InjectingPackages => Box::new(AptInstallClassifier::new("Injecting packages")), Phase::InstallingBuildDeps => { Box::new(AptInstallClassifier::new("Installing build dependencies")) } Phase::Building | Phase::ProducingBinaries => Box::new(MakeClassifier::new()), Phase::RetrievingArtifacts => Box::new(GenericClassifier::new()), } } /// Visual kind of a pane line, driving its color #[derive(Debug, Clone, Copy, PartialEq)] enum Kind { Normal, Warning, Error, } /// Mutable state shared between the sink and the widget struct Pipeline { classifier: Box, lines: VecDeque<(Kind, String)>, errors: Vec, last_draw: Instant, bar_total: u64, } /// State shared between [`DebUi`] and its sinks struct Shared { top: ProgressBar, pane: ProgressBar, state: Mutex, tee: Mutex>, log_path: Mutex, timestamp: String, enabled: bool, /// Set once the widget has been removed from the terminal; afterwards all /// rendering is skipped so late events cannot redraw stale frames. suspended: AtomicBool, started: Instant, } /// Live build view for `pkh deb` /// /// Create one per build (disabled automatically when stdout is not a TTY or /// when the user requests verbose output), pass it down as /// `Option>`, and feed subprocess output through [`DebUi::sink`]. pub struct DebUi { shared: Arc, } impl DebUi { /// Create a live view attached to `multi` /// /// When stdout is not a TTY, the widget is disabled: no bars are drawn, /// but captured output is still tee'd to the log file. pub fn new(multi: &MultiProgress) -> Self { let enabled = is_stdout_tty(); let top = if enabled { let pb = multi.add(ProgressBar::new(0)); pb.enable_steady_tick(Duration::from_millis(80)); pb.set_style(spinner_style()); pb.set_prefix("Building package"); pb.set_message("(starting…)"); pb } else { ProgressBar::hidden() }; let pane = if enabled { let pb = multi.add(ProgressBar::new(0)); pb.enable_steady_tick(Duration::from_millis(150)); // No template margin: multi-line messages are only prefixed by // the template on their first line, which would misalign the // pane; each rendered line carries its own indent instead. pb.set_style( ProgressStyle::default_bar() .template("{msg}") .expect("valid template"), ); pb.set_message(" │ (starting…)"); pb } else { ProgressBar::hidden() }; let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%S").to_string(); let log_path = default_log_path(×tamp); let ui = Self { shared: Arc::new(Shared { top, pane, state: Mutex::new(Pipeline { classifier: Box::new(GenericClassifier::new()), lines: VecDeque::new(), errors: Vec::new(), last_draw: Instant::now(), bar_total: 0, }), tee: Mutex::new(None), log_path: Mutex::new(log_path.clone()), timestamp, enabled, suspended: AtomicBool::new(false), started: Instant::now(), }), }; if ui.shared.enabled { install_sigint_hook(&log_path); } ui } /// Identify the package being built; names the log file and the status bar pub fn set_target(&self, package: &str, version: &str, series: &str, arch: &str) { if self.shared.enabled { self.shared.top.set_prefix(format!( "Building {package} ({version}) for {series}/{arch}" )); } // Rename the log file to include the package identity (best-effort), // then open it so subsequent captured lines are tee'd. let old_path = self.shared.log_path.lock().unwrap().clone(); let log_path = match old_path.parent() { Some(dir) => dir.join(format!( "deb-{package}-{version}-{}.log", self.shared.timestamp )), None => old_path.clone(), }; let _ = fs::rename(&old_path, &log_path); *self.shared.log_path.lock().unwrap() = log_path.clone(); update_sigint_log_path(&log_path); if let Some(dir) = log_path.parent() { let _ = fs::create_dir_all(dir); } match File::create(&log_path) { Ok(mut file) => { let _ = writeln!( file, "# pkh deb {} ({}) for {}/{} started {}", package, version, series, arch, chrono::Utc::now().to_rfc3339() ); *self.shared.tee.lock().unwrap() = Some(file); } Err(e) => { log::warn!( "Could not create build log file {}: {}", log_path.display(), e ); } } } /// Switch to a phase, installing its default classifier pub fn phase(&self, phase: Phase) { self.phase_with(phase, default_classifier(phase)); } /// Switch to a phase with a custom classifier (e.g. quilt with a known /// patch count) pub fn phase_with(&self, phase: Phase, classifier: Box) { { let mut st = self.shared.state.lock().unwrap(); st.classifier = classifier; st.lines.clear(); st.bar_total = 0; st.last_draw = Instant::now(); } if self.shared.enabled { self.shared.top.set_style(spinner_style()); self.shared.top.set_message(phase.label()); self.shared.pane.set_message(""); } } /// Update the status bar message directly (for in-process work such as /// tarball extraction that has no subprocess output) pub fn progress_message(&self, msg: &str) { if self.active() { self.shared.top.set_message(msg.to_string()); } } /// Drive the determinate progress bar directly (e.g. artifact retrieval) pub fn count_progress(&self, label: &str, pos: usize, total: usize) { if !self.active() || total == 0 { return; } apply_progress( &self.shared.top, &mut self.shared.state.lock().unwrap(), pos as u64, total as u64, ); self.shared.top.set_message(label.to_string()); } /// Whether the widget is enabled and still drawn fn active(&self) -> bool { self.shared.enabled && !self.shared.suspended.load(Ordering::SeqCst) } /// Obtain a sink feeding this view; pass it to `ContextCommand::capture` pub fn sink(self: &Arc) -> Arc { Arc::new(Sink { shared: self.shared.clone(), }) } /// Remove the widget from the terminal (e.g. before printing passthrough /// diagnostics or letting child cleanup commands write to the terminal); /// idempotent /// /// Steady ticks are disabled first: otherwise a tick can redraw a frame /// right after the clear, leaving stale copies of the widget on screen. pub fn suspend(&self) { if !self.shared.enabled { return; } if self.shared.suspended.swap(true, Ordering::SeqCst) { return; } self.shared.top.disable_steady_tick(); self.shared.pane.disable_steady_tick(); self.shared.top.finish_and_clear(); self.shared.pane.finish_and_clear(); } /// Clear the widget and print a success summary with the artifacts pub fn finish_success(&self, artifacts: &[PathBuf], elapsed: Duration) { self.suspend(); if self.shared.enabled && !artifacts.is_empty() { for artifact in artifacts { println!(" → {}", artifact.display()); } println!(" ✔ Built in {}s", elapsed.as_secs()); } } /// Clear the widget and print a failure summary (recent captured errors /// and the path to the full log) pub fn finish_failure(&self) { self.suspend(); let st = self.shared.state.lock().unwrap(); if self.shared.enabled && !st.errors.is_empty() { eprintln!("Last captured errors:"); for err in st .errors .iter() .rev() .take(5) .collect::>() .iter() .rev() { eprintln!(" {}", err); } } let log_path = self.shared.log_path.lock().unwrap().clone(); if log_path.exists() { eprintln!("Full log: {}", log_path.display()); } } /// Time elapsed since the view was created pub fn elapsed(&self) -> Duration { self.shared.started.elapsed() } /// Path of the full build log file pub fn log_path(&self) -> PathBuf { self.shared.log_path.lock().unwrap().clone() } } impl Drop for DebUi { fn drop(&mut self) { // Safety net: clear the widget on early returns/unwinds self.suspend(); } } /// Bridge forwarding captured subprocess lines into the pipeline struct Sink { shared: Arc, } impl LineSink for Sink { fn line(&self, stream: Stream, line: &str) { // Tee the raw line first: nothing should be lost, even when the // widget is disabled. { let mut tee = self.shared.tee.lock().unwrap(); if let Some(file) = tee.as_mut() { let _ = writeln!(file, "{line}"); } } if !self.shared.enabled || self.shared.suspended.load(Ordering::SeqCst) { return; } let mut st = self.shared.state.lock().unwrap(); for action in st.classifier.feed(stream, line) { match action { Action::Hidden => {} Action::Progress { pos, total } => { apply_progress(&self.shared.top, &mut st, pos, total) } Action::Shown(text) => push_line(&self.shared, &mut st, Kind::Normal, text), Action::Warning(text) => push_line(&self.shared, &mut st, Kind::Warning, text), Action::Error(text) => { if st.errors.len() < 100 { st.errors.push(text.clone()); } push_line(&self.shared, &mut st, Kind::Error, text); } } } } } /// Update the determinate progress bar, restyling it when needed fn apply_progress(top: &ProgressBar, st: &mut Pipeline, pos: u64, total: u64) { if total == 0 { return; } if st.bar_total != total { st.bar_total = total; top.set_style(determinate_style()); top.set_length(total); } top.set_position(pos.min(total)); } /// Push a line into the rolling pane and redraw (throttled) /// /// When the pane is full, the oldest non-error line is dropped first so /// errors stay visible longer. fn push_line(shared: &Shared, st: &mut Pipeline, kind: Kind, text: String) { st.lines.push_back((kind, text)); while st.lines.len() > PANE_LINES { let drop_idx = st .lines .iter() .position(|(k, _)| *k != Kind::Error) .unwrap_or(0); st.lines.remove(drop_idx); } let now = Instant::now(); if now.duration_since(st.last_draw) >= REDRAW_INTERVAL { st.last_draw = now; shared.pane.set_message(render_pane(&st.lines)); } } /// Render the pane content with per-kind colors fn render_pane(lines: &VecDeque<(Kind, String)>) -> String { lines .iter() .map(|(kind, text)| match kind { Kind::Normal => format!(" │ {text}"), Kind::Warning => format!(" │ {}", text.as_str().yellow()), Kind::Error => format!(" │ {}", text.as_str().red()), }) .collect::>() .join("\n") } /// Status bar style while no determinate progress is known /// /// The target lives on the first line and the current phase/message on its own /// line below, so narrow terminals are not overflowed. fn spinner_style() -> ProgressStyle { ProgressStyle::default_bar() .template("> {spinner:.blue} {prefix}\n {msg}") .expect("valid template") } /// Status bar style with a determinate progress bar /// /// Same two-line layout as [`spinner_style`], plus the bar on a third line. fn determinate_style() -> ProgressStyle { ProgressStyle::default_bar() .template("> {spinner:.blue} {prefix}\n {msg} [{bar:24.cyan/blue}] {pos}/{len}") .expect("valid template") .progress_chars("=> ") } /// Whether stdout is a terminal fn is_stdout_tty() -> bool { // SAFETY: isatty only inspects the file descriptor unsafe { libc::isatty(libc::STDOUT_FILENO) == 1 } } /// Default log file path for a given timestamp fn default_log_path(timestamp: &str) -> PathBuf { let dir = ProjectDirs::from("com", "pkh", "pkh") .map(|dirs| dirs.cache_dir().join("logs")) .unwrap_or_else(std::env::temp_dir); dir.join(format!("deb-{timestamp}.log")) } static SIGINT_LOG_PATH: Mutex> = Mutex::new(None); static SIGINT_INSTALLED: AtomicBool = AtomicBool::new(false); /// Install a best-effort Ctrl+C handler clearing the widget and pointing at /// the log file before exiting fn install_sigint_hook(log_path: &Path) { update_sigint_log_path(log_path); if SIGINT_INSTALLED.swap(true, Ordering::SeqCst) { return; } // SAFETY: installing a signal handler; the handler itself is best-effort // (it performs non async-signal-safe operations, acceptable here because // it immediately exits afterwards). unsafe { libc::signal(libc::SIGINT, on_sigint as *const () as usize); } } /// Point the sigint handler at the current log file location fn update_sigint_log_path(log_path: &Path) { *SIGINT_LOG_PATH.lock().unwrap() = Some(log_path.to_path_buf()); } extern "C" fn on_sigint(_sig: libc::c_int) { // Best-effort cleanup: clear leftover widget lines and show the cursor let _ = execute!( std::io::stdout(), Clear(ClearType::FromCursorDown), cursor::Show ); if let Ok(guard) = SIGINT_LOG_PATH.try_lock() && let Some(path) = guard.as_ref() { eprintln!("\nInterrupted — full log: {}", path.display()); } // SAFETY: raw exit bypassing destructors, intended in a signal handler unsafe { libc::_exit(130); } }