deb: change ui/ux of pkh deb
CI / build (push) Successful in 2m54s
CI / test (push) Skipped
CI / snap (push) Failing after 12s

This commit is contained in:
2026-08-22 22:26:20 +02:00
parent e2d201d815
commit e5adf600c3
16 changed files with 1899 additions and 199 deletions
+39 -5
View File
@@ -1,4 +1,5 @@
use crate::context::{self, Context, ContextConfig};
use crate::ui::deb::{DebUi, Phase};
use directories::ProjectDirs;
use std::error::Error;
use std::fs;
@@ -28,6 +29,7 @@ impl EphemeralContextGuard {
series: &str,
arch: Option<&str>,
base_ctx: Arc<Context>,
ui: Option<Arc<DebUi>>,
) -> Result<Self, Box<dyn Error>> {
let current_context_name = context::manager().current_name();
@@ -43,7 +45,8 @@ impl EphemeralContextGuard {
);
// Download and extract the chroot tarball
Self::download_and_extract_chroot(series, arch, &chroot_path, base_ctx.clone()).await?;
Self::download_and_extract_chroot(series, arch, &chroot_path, base_ctx.clone(), &ui)
.await?;
// Switch to an ephemeral context to build the package in the chroot
context::manager().set_current_ephemeral(Context::new(ContextConfig::Unshare {
@@ -64,6 +67,7 @@ impl EphemeralContextGuard {
arch: Option<&str>,
chroot_path: &PathBuf,
ctx: Arc<context::Context>,
ui: &Option<Arc<DebUi>>,
) -> Result<(), Box<dyn Error>> {
// Clone ctx for use in create_device_nodes after download_chroot_tarball consumes it
let ctx_for_devices = ctx.clone();
@@ -119,7 +123,10 @@ impl EphemeralContextGuard {
series,
arch
);
Self::download_chroot_tarball(series, arch, &tarball_path, ctx).await?;
if let Some(u) = ui {
u.phase(Phase::PreparingChroot);
}
Self::download_chroot_tarball(series, arch, &tarball_path, ctx, ui).await?;
} else {
log::debug!(
"Using cached chroot tarball for {} (arch: {:?})",
@@ -130,10 +137,16 @@ impl EphemeralContextGuard {
// Extract tarball to chroot directory
log::debug!("Extracting chroot tarball to {}...", chroot_path.display());
Self::extract_tarball(&tarball_path, chroot_path)?;
if let Some(u) = ui {
u.phase(Phase::ExtractingChroot);
}
Self::extract_tarball(&tarball_path, chroot_path, ui.as_deref())?;
// Create device nodes in the chroot
log::debug!("Creating device nodes in chroot...");
if let Some(u) = ui {
u.phase(Phase::FinalizingChroot);
}
Self::create_device_nodes(chroot_path, ctx_for_devices.clone())?;
// Bind mount /proc from host into chroot (before entering unshare namespace)
@@ -149,6 +162,7 @@ impl EphemeralContextGuard {
arch: Option<&str>,
tarball_path: &Path,
ctx: Arc<context::Context>,
ui: &Option<Arc<DebUi>>,
) -> Result<(), Box<dyn Error>> {
// Create a lock file to make sure that noone tries to use the file while it's not fully downloaded
let lockfile_path = tarball_path.with_extension("lock");
@@ -182,6 +196,10 @@ impl EphemeralContextGuard {
cmd.arg(series)
.arg(tarball_path.to_string_lossy().to_string());
if let Some(u) = ui {
cmd.capture(u.sink());
}
let status = cmd.status()?;
if !status.success() {
@@ -216,6 +234,7 @@ impl EphemeralContextGuard {
fn extract_tarball(
tarball_path: &PathBuf,
chroot_path: &PathBuf,
ui: Option<&DebUi>,
) -> Result<(), Box<dyn Error>> {
// Create the chroot directory
fs::create_dir_all(chroot_path)?;
@@ -225,8 +244,23 @@ impl EphemeralContextGuard {
let xz_decoder = XzDecoder::new(tarball_file);
let mut archive = Archive::new(xz_decoder);
// Extract all files to the chroot directory
archive.unpack(chroot_path)?;
// Extract entries one by one so progress can be reported (a full
// second decompression pass just to count entries upfront would be
// too expensive for multi-hundred-MB chroot tarballs)
let mut count = 0usize;
for entry in archive.entries()? {
let mut entry = entry?;
entry.unpack_in(chroot_path)?;
count += 1;
if count.is_multiple_of(100)
&& let Some(u) = ui
{
u.progress_message(&format!("Extracting chroot… ({count} files)"));
}
}
if let Some(u) = ui {
u.progress_message(&format!("Extracting chroot… ({count} files)"));
}
Ok(())
}
+117 -55
View File
@@ -1,7 +1,9 @@
/// Local binary package building
/// Directly calling 'debian/rules' in current context
use crate::context::Context;
use crate::context::{Context, ContextCommand, LineSink};
use crate::deb::find_dsc_file;
use crate::ui::deb::{DebUi, Phase};
use crate::ui::logfmt::QuiltClassifier;
use log::warn;
use std::collections::HashMap;
use std::error::Error;
@@ -11,6 +13,17 @@ use std::sync::Arc;
use crate::apt;
use crate::deb::cross;
/// Attach the capture sink to a command when the live UI is active
fn cap<'a>(
cmd: &'a mut ContextCommand<'a>,
sink: &Option<Arc<dyn LineSink>>,
) -> &'a mut ContextCommand<'a> {
if let Some(s) = sink {
cmd.capture(s.clone());
}
cmd
}
#[allow(clippy::too_many_arguments)]
pub async fn build(
package: &str,
@@ -23,7 +36,10 @@ pub async fn build(
ppa: Option<&[&str]>,
inject_packages: Option<&[&str]>,
ctx: Arc<Context>,
ui: Option<Arc<DebUi>>,
) -> Result<(), Box<dyn Error>> {
let sink: Option<Arc<dyn LineSink>> = ui.as_ref().map(|u| u.sink());
// Environment
let mut env = HashMap::<String, String>::new();
env.insert("LANG".to_string(), "C".to_string());
@@ -155,19 +171,22 @@ pub async fn build(
// Update package lists
log::debug!("Updating package lists for local build...");
let status = ctx
.command("apt-get")
.envs(env.clone())
.arg("update")
.status()
.map_err(|e| {
format!(
"Failed to run 'apt-get update' inside the build context: {}. \
if let Some(u) = &ui {
u.phase(Phase::UpdatingPackageLists);
}
let status = cap(
ctx.command("apt-get").envs(env.clone()).arg("update"),
&sink,
)
.status()
.map_err(|e| {
format!(
"Failed to run 'apt-get update' inside the build context: {}. \
If this is a local build, make sure apt-get is available and \
try executing with sudo.",
e
)
})?;
e
)
})?;
if !status.success() {
return Err("apt-get update failed inside the build context. \
If this is a local build, try executing with sudo, \
@@ -193,7 +212,10 @@ pub async fn build(
cmd.arg(format!("libc6:{arch}"));
cmd.arg(format!("libc6-dev:{arch}"));
}
let status = cmd.status()?;
if let Some(u) = &ui {
u.phase(Phase::InstallingEssentials);
}
let status = cap(&mut cmd, &sink).status()?;
if !status.success() {
return Err("Could not install essential packages for the build".into());
}
@@ -206,15 +228,18 @@ pub async fn build(
.ok_or("Invalid package directory path")?;
// Apply quilt patches if the package provides a patch series
apply_quilt_patches(package_dir_str, &env, ctx.clone())?;
apply_quilt_patches(package_dir_str, &env, ctx.clone(), &ui, &sink)?;
// Install injected packages if specified
if let Some(packages) = inject_packages {
install_injected_packages(packages, &env, ctx.clone())?;
install_injected_packages(packages, &env, ctx.clone(), &ui, &sink)?;
}
// Install arch-specific build dependencies
log::debug!("Installing arch-specific build dependencies...");
if let Some(u) = &ui {
u.phase(Phase::InstallingBuildDeps);
}
let mut cmd = ctx.command("apt-get");
cmd.current_dir(package_dir_str)
.envs(env.clone())
@@ -224,51 +249,69 @@ pub async fn build(
cmd.arg(format!("--host-architecture={arch}"));
}
cmd.arg("--arch-only");
let status = cmd.arg("./").status()?;
let status = cap(&mut cmd, &sink).arg("./").status()?;
// If build-dep fails, we try to explain the failure using dose-debcheck
if !status.success() {
if let Some(u) = &ui {
u.suspend();
}
dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone())?;
return Err("Could not install build-dependencies for the build".into());
}
// Install arch-independant build dependencies
log::debug!("Installing arch-independant build dependencies...");
let status = ctx
.command("apt-get")
.current_dir(package_dir_str)
.envs(env.clone())
.arg("-y")
.arg("build-dep")
.arg("./")
.status()?;
let status = cap(
ctx.command("apt-get")
.current_dir(package_dir_str)
.envs(env.clone())
.arg("-y")
.arg("build-dep")
.arg("./"),
&sink,
)
.status()?;
// If build-dep fails, we try to explain the failure using dose-debcheck
if !status.success() {
if let Some(u) = &ui {
u.suspend();
}
dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone())?;
return Err("Could not install build-dependencies for the build".into());
}
// Run the build step
log::debug!("Building (debian/rules build) package...");
let status = ctx
.command("debian/rules")
.current_dir(package_dir_str)
.envs(env.clone())
.arg("build")
.status()?;
if let Some(u) = &ui {
u.phase(Phase::Building);
}
let status = cap(
ctx.command("debian/rules")
.current_dir(package_dir_str)
.envs(env.clone())
.arg("build"),
&sink,
)
.status()?;
if !status.success() {
return Err("Error while building the package".into());
}
// Run the 'binary' step to produce deb
let status = ctx
.command("fakeroot")
.current_dir(package_dir_str)
.envs(env.clone())
.arg("debian/rules")
.arg("binary")
.status()?;
if let Some(u) = &ui {
u.phase(Phase::ProducingBinaries);
}
let status = cap(
ctx.command("fakeroot")
.current_dir(package_dir_str)
.envs(env.clone())
.arg("debian/rules")
.arg("binary"),
&sink,
)
.status()?;
if !status.success() {
return Err(
"Error while building the binary artifacts (.deb) from the built package".into(),
@@ -284,6 +327,8 @@ fn apply_quilt_patches(
package_dir: &str,
env: &HashMap<String, String>,
ctx: Arc<Context>,
ui: &Option<Arc<DebUi>>,
sink: &Option<Arc<dyn LineSink>>,
) -> Result<(), Box<dyn Error>> {
let series_path = Path::new(package_dir).join("debian/patches/series");
if !ctx.exists(&series_path)? {
@@ -296,9 +341,11 @@ fn apply_quilt_patches(
// Skip patch application if the series file contains no patches
let series_content = ctx.read_file(&series_path)?;
let has_patches = series_content
let total_patches = series_content
.lines()
.any(|line| !line.trim().is_empty() && !line.trim().starts_with('#'));
.filter(|line| !line.trim().is_empty() && !line.trim().starts_with('#'))
.count();
let has_patches = total_patches > 0;
if !has_patches {
log::debug!(
"'{}' contains no patches, skipping quilt patch application",
@@ -309,28 +356,37 @@ fn apply_quilt_patches(
// Make sure quilt is available in the build context
log::debug!("Installing quilt for patch application...");
let status = ctx
.command("apt-get")
.envs(env.clone())
.arg("-y")
.arg("install")
.arg("quilt")
.status()?;
let status = cap(
ctx.command("apt-get")
.envs(env.clone())
.arg("-y")
.arg("install")
.arg("quilt"),
sink,
)
.status()?;
if !status.success() {
return Err("Could not install 'quilt', required to apply patches".into());
}
// Apply all patches listed in the series
log::info!("Applying quilt patches from debian/patches/series...");
if let Some(u) = ui {
u.phase_with(
Phase::ApplyingPatches,
Box::new(QuiltClassifier::new(total_patches)),
);
}
let mut patch_env = env.clone();
patch_env.insert("QUILT_PATCHES".to_string(), "debian/patches".to_string());
let status = ctx
.command("quilt")
.current_dir(package_dir)
.envs(patch_env)
.arg("push")
.arg("-a")
.status()?;
let status = cap(
ctx.command("quilt")
.current_dir(package_dir)
.envs(patch_env)
.arg("push")
.arg("-a"),
sink,
)
.status()?;
if !status.success() {
return Err("Failed to apply quilt patches ('quilt push -a')".into());
}
@@ -360,9 +416,15 @@ fn install_injected_packages(
packages: &[&str],
env: &HashMap<String, String>,
ctx: Arc<Context>,
ui: &Option<Arc<DebUi>>,
sink: &Option<Arc<dyn LineSink>>,
) -> Result<(), Box<dyn Error>> {
log::info!("Installing injected packages: {:?}", packages);
if let Some(u) = ui {
u.phase(Phase::InjectingPackages);
}
// Separate .deb files from package names
let mut deb_files: Vec<String> = Vec::new();
let mut package_names: Vec<&str> = Vec::new();
@@ -400,7 +462,7 @@ fn install_injected_packages(
if !package_names.is_empty() {
cmd.args(&package_names);
}
let status = cmd.status()?;
let status = cap(&mut cmd, sink).status()?;
if !status.success() {
return Err(format!("Could not install injected packages: {:?}", deb_files).into());
}
+125 -40
View File
@@ -3,6 +3,7 @@ mod ephemeral;
mod local;
use crate::context::{self, Context};
use crate::ui::deb::{DebUi, Phase};
use std::error::Error;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -15,6 +16,11 @@ pub enum BuildMode {
}
/// Build package in 'cwd' to a .deb
///
/// Returns the list of produced .deb files retrieved locally. When `ui` is
/// set, a live view (status bar + rolling log pane) is displayed and all
/// subprocess output is captured through it; on failure the widget is cleared
/// and a summary of captured errors is printed.
#[allow(clippy::too_many_arguments)]
pub async fn build_binary_package(
arch: Option<&str>,
@@ -26,7 +32,43 @@ pub async fn build_binary_package(
ppa: Option<&[&str]>,
inject_packages: Option<&[&str]>,
ctx: Option<Arc<Context>>,
) -> Result<(), Box<dyn Error>> {
ui: Option<Arc<DebUi>>,
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
let result = build_binary_package_impl(
arch,
series,
pocket,
cwd,
cross,
mode,
ppa,
inject_packages,
ctx,
&ui,
)
.await;
if let (Some(u), Err(_)) = (&ui, &result) {
u.finish_failure();
}
result
}
/// Implementation of [`build_binary_package`], without failure handling
#[allow(clippy::too_many_arguments)]
async fn build_binary_package_impl(
arch: Option<&str>,
series: Option<&str>,
pocket: Option<&str>,
cwd: Option<&Path>,
cross: bool,
mode: Option<BuildMode>,
ppa: Option<&[&str]>,
inject_packages: Option<&[&str]>,
ctx: Option<Arc<Context>>,
ui: &Option<Arc<DebUi>>,
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
let cwd = cwd.unwrap_or_else(|| Path::new("."));
// Parse changelog to get package name, version and series
@@ -61,12 +103,22 @@ pub async fn build_binary_package(
// Use provided context or get current
let base_ctx = ctx.unwrap_or_else(context::current);
// Identify the target in the live UI once the changelog is parsed, so
// even the chroot download output is attributed and tee'd
if let Some(u) = ui {
u.set_target(&package, &version, series, arch);
}
// Create an ephemeral unshare context for all Local builds. It is kept in
// this scope so it outlives the guarded section below and is only dropped
// once the live view has been cleared.
let mut guard = if mode == BuildMode::Local {
Some(
ephemeral::EphemeralContextGuard::new_with_context(
series,
chroot_arch,
base_ctx.clone(),
ui.clone(),
)
.await?,
)
@@ -74,59 +126,91 @@ pub async fn build_binary_package(
None
};
// Get the build context - either the ephemeral context or the base context
let build_ctx = if mode == BuildMode::Local {
context::current()
} else {
base_ctx.clone()
};
let result = async {
// Get the build context - either the ephemeral context or the base context
let build_ctx = if mode == BuildMode::Local {
context::current()
} else {
base_ctx.clone()
};
// Prepare build directory
let build_root = build_ctx.create_temp_dir()?;
// Prepare build directory
let build_root = build_ctx.create_temp_dir()?;
// Ensure availability of all needed files for the build
let parent_dir = cwd.parent().ok_or("Cannot find parent directory")?;
build_ctx.ensure_available(parent_dir, &build_root)?;
let parent_dir_name = parent_dir
.file_name()
.ok_or("Cannot find parent directory name")?;
let build_root = format!("{}/{}", build_root, parent_dir_name.to_str().unwrap());
// Ensure availability of all needed files for the build
let parent_dir = cwd.parent().ok_or("Cannot find parent directory")?;
build_ctx.ensure_available(parent_dir, &build_root)?;
let parent_dir_name = parent_dir
.file_name()
.ok_or("Cannot find parent directory name")?;
let build_root = format!("{}/{}", build_root, parent_dir_name.to_str().unwrap());
// Run the build using target build mode
match mode {
BuildMode::Local => {
local::build(
&package,
&version,
arch,
series,
pocket,
&build_root,
cross,
ppa,
inject_packages,
build_ctx.clone(),
)
.await?
// Run the build using target build mode
match mode {
BuildMode::Local => {
local::build(
&package,
&version,
arch,
series,
pocket,
&build_root,
cross,
ppa,
inject_packages,
build_ctx.clone(),
ui.clone(),
)
.await?
}
}
}
// Retrieve produced .deb files
let remote_files = build_ctx.list_files(Path::new(&build_root))?;
for remote_file in remote_files {
if remote_file.extension().is_some_and(|ext| ext == "deb") {
// Retrieve produced .deb files
if let Some(u) = ui {
u.phase(Phase::RetrievingArtifacts);
}
let remote_files = build_ctx.list_files(Path::new(&build_root))?;
let deb_files: Vec<PathBuf> = remote_files
.into_iter()
.filter(|f| f.extension().is_some_and(|ext| ext == "deb"))
.collect();
let total_debs = deb_files.len();
let mut artifacts = Vec::with_capacity(total_debs);
for (idx, remote_file) in deb_files.iter().enumerate() {
let file_name = remote_file.file_name().ok_or("Invalid remote filename")?;
let local_dest = parent_dir.join(file_name);
build_ctx.retrieve_path(&remote_file, &local_dest)?;
build_ctx.retrieve_path(remote_file, &local_dest)?;
artifacts.push(local_dest);
if let Some(u) = ui {
u.count_progress("Retrieving artifacts", idx + 1, total_debs);
}
}
if let Some(u) = ui {
u.finish_success(&artifacts, u.elapsed());
}
Ok(artifacts)
}
.await;
// Clear the live view before returning: the ephemeral guard is dropped at
// the end of this function and its cleanup commands (umount, rm -rf of
// the chroot) inherit the terminal, so they must not fight the widget.
if let Some(u) = ui {
u.suspend();
}
// Mark build as successful to trigger chroot cleanup
if let Some(ref mut g) = guard {
if result.is_ok()
&& let Some(ref mut g) = guard
{
g.mark_build_successful();
}
Ok(())
result
}
/// Find the current package directory by trying both patterns:
@@ -321,6 +405,7 @@ mod tests {
None,
None,
Some(ctx),
None,
)
.await
.expect("Cannot build binary package (deb)");