546 lines
18 KiB
Rust
546 lines
18 KiB
Rust
//! 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<dyn Classifier> {
|
|
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<dyn Classifier>,
|
|
lines: VecDeque<(Kind, String)>,
|
|
errors: Vec<String>,
|
|
last_draw: Instant,
|
|
bar_total: u64,
|
|
}
|
|
|
|
/// State shared between [`DebUi`] and its sinks
|
|
struct Shared {
|
|
top: ProgressBar,
|
|
pane: ProgressBar,
|
|
state: Mutex<Pipeline>,
|
|
tee: Mutex<Option<File>>,
|
|
log_path: Mutex<PathBuf>,
|
|
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<Arc<DebUi>>`, and feed subprocess output through [`DebUi::sink`].
|
|
pub struct DebUi {
|
|
shared: Arc<Shared>,
|
|
}
|
|
|
|
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<dyn Classifier>) {
|
|
{
|
|
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<Self>) -> Arc<dyn LineSink> {
|
|
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::<Vec<_>>()
|
|
.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<Shared>,
|
|
}
|
|
|
|
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::<Vec<_>>()
|
|
.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<Option<PathBuf>> = 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);
|
|
}
|
|
}
|