v0.4.61: asset cache fix, PNG-enabled iPXE, composed PXE logo
Two real issues v0.4.6 left on the table: Asset caching: - index.html now interpolates the running OpenPXE version into every asset URL as `?v=<version>` (app.css, app.js, logo.svg). Combined with `Cache-Control: no-cache, must-revalidate` on the asset handlers, browsers and intermediary proxies are forced to fetch fresh on every upgrade. Without this, last release's bundled JS kept serving the old UI even after the operator pulled the new image — invisible to anyone who only checks the version chip in the footer (which is dynamic). - The Cache-Control header is also applied to logo.svg and loader.svg so a logo upload reflects immediately rather than after a hard refresh. Real-image PXE menu logo (matches iVentoy now): - New Dockerfile stage `ipxe-build` clones the iPXE source and compiles all four binaries (undionly.kpxe, snponly.efi for x86_64/i386, snponly.efi for arm64 via gcc-aarch64-linux-gnu) with IMAGE_PNG + CONSOLE_FRAMEBUFFER + CONSOLE_VESAFB enabled. Replaces the boot.ipxe.org fetch — those binaries are built without PNG support, which is why v0.4.6's `console --picture` line silently no-op'd. - `iso-store::pxe_logo::compose_pxe_logo` decodes any operator upload (PNG / JPEG / WebP / GIF), downscales-to-fit if larger than 600×200, and pastes it onto a transparent 1024×768 canvas centered horizontally with a 64-pixel top margin. iPXE paints the result at 1:1 on the typical VESA framebuffer, giving the iVentoy-style centered-logo look regardless of the operator's source dimensions. - GET /branding/pxe-logo now returns the composed PNG. wimboot still fetches from ipxe/wimboot's GitHub release (separately signed). - Dropped the ASCII OpenPXE wordmark from render_menu — once the real image paints, the banner would duplicate it visually. iPXE builds without PNG (none of ours after this release, but a third- party undionly might) simply show the menu without a logo, which is the right graceful-degradation outcome. Quality: - 142 tests passing (was 138 in v0.4.6): +4 pxe_logo unit tests covering canvas dimensions, centered-top placement, oversize downscale, and unsupported-bytes error handling; existing integration tests updated to verify the 1024×768 IHDR header from the composed PNG instead of round-tripping the raw upload. - cargo clippy --workspace --all-targets clean. - Image dependency: `image = "0.25"` with only `png/jpeg/webp/gif` features enabled. No new transitive C deps. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
55f4765a20
commit
1419309a2d
@@ -27,6 +27,13 @@ parking_lot.workspace = true
|
||||
bytes.workspace = true
|
||||
tempfile = "3.12"
|
||||
libc = "0.2"
|
||||
# v0.4.61: server-side compose of the operator's uploaded raster into a
|
||||
# fixed 1024x768 canvas so the PXE menu always gets a consistently-sized
|
||||
# PNG regardless of what the operator uploaded. We use the bare-bones
|
||||
# `image` crate (no default features) and explicitly enable only the
|
||||
# decoders we accept on upload (PNG/JPEG/WebP/GIF) plus the PNG
|
||||
# encoder. Keeps the build slim — no JPEG2000, TIFF, BMP, etc.
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.12"
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
pub mod entry;
|
||||
pub mod introspect;
|
||||
pub mod nfs;
|
||||
pub mod pxe_logo;
|
||||
pub mod smb;
|
||||
pub mod store;
|
||||
pub mod windows;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Operator-logo compositor for the iPXE menu.
|
||||
//!
|
||||
//! The brief: match iVentoy's polished centered-logo PXE chrome with
|
||||
//! whatever raster the operator drops onto Settings → Branding. A wide
|
||||
//! wordmark, a portrait stack, a square monogram — all three should
|
||||
//! land in roughly the same place on the boot screen.
|
||||
//!
|
||||
//! Approach: decode the operator's upload, fit it into a fixed
|
||||
//! 1024×768 canvas with the logo horizontally centered and pinned a
|
||||
//! short margin from the top, re-encode as PNG, return the bytes. iPXE
|
||||
//! built with `IMAGE_PNG` paints the result via `console --picture`.
|
||||
//!
|
||||
//! The 1024×768 size matches the default VESA framebuffer iPXE picks
|
||||
//! on most BIOS/UEFI consoles. Operators uploading 4K logos get
|
||||
//! correctly downscaled; tiny icons get drawn at their native size,
|
||||
//! centered, with transparent margins.
|
||||
//!
|
||||
//! We deliberately don't ship `resvg` for SVG support — keeping the
|
||||
//! dependency surface narrow matters more than supporting SVG-only
|
||||
//! brand assets. The WebUI's logo stays SVG-native (the browser
|
||||
//! rasterizes it); the PXE menu wants a raster regardless.
|
||||
|
||||
use image::imageops::FilterType;
|
||||
use image::{DynamicImage, ImageError, ImageFormat, Rgba, RgbaImage};
|
||||
use std::io::Cursor;
|
||||
|
||||
/// Canvas dimensions used for the composed PXE logo. Picked to match
|
||||
/// the framebuffer dimensions iPXE picks on most BIOS/UEFI consoles —
|
||||
/// gives a 1:1 paint with no scaling at the firmware layer.
|
||||
pub const CANVAS_W: u32 = 1024;
|
||||
pub const CANVAS_H: u32 = 768;
|
||||
|
||||
/// Maximum dimensions for the operator's logo inside the canvas. Any
|
||||
/// upload larger than this in either axis is downscaled (preserving
|
||||
/// aspect ratio) to fit. Smaller uploads paint at native size.
|
||||
const LOGO_MAX_W: u32 = 600;
|
||||
const LOGO_MAX_H: u32 = 200;
|
||||
|
||||
/// Top margin in pixels from the canvas's top edge to the logo's top
|
||||
/// edge. Matches the visual rhythm of iVentoy's screen (logo at top,
|
||||
/// menu below).
|
||||
const LOGO_TOP_MARGIN: u32 = 64;
|
||||
|
||||
/// Compose `src_bytes` (any PNG/JPEG/WebP/GIF) into a centered-top
|
||||
/// 1024×768 PNG and return the encoded bytes.
|
||||
///
|
||||
/// Errors when the source can't be decoded or the encoded buffer can't
|
||||
/// be written (only really fires on out-of-memory; the encoder itself
|
||||
/// is infallible for well-formed inputs).
|
||||
pub fn compose_pxe_logo(src_bytes: &[u8]) -> Result<Vec<u8>, ImageError> {
|
||||
let logo = image::load_from_memory(src_bytes)?;
|
||||
// Resize-fit if the upload exceeds our bounding box. `Lanczos3`
|
||||
// keeps the antialiasing crisp on the framebuffer console; it's a
|
||||
// touch slower than `Triangle` but the operator hits this endpoint
|
||||
// once per boot at most.
|
||||
let logo = downscale_to_fit(logo, LOGO_MAX_W, LOGO_MAX_H);
|
||||
let logo_rgba = logo.to_rgba8();
|
||||
|
||||
// Transparent canvas. iPXE 1.21+ honours alpha-channel transparency
|
||||
// on framebuffer consoles; older builds simply draw the alpha as
|
||||
// black, which still gives a sensible look.
|
||||
let mut canvas: RgbaImage = RgbaImage::from_pixel(CANVAS_W, CANVAS_H, Rgba([0, 0, 0, 0]));
|
||||
let logo_w = logo_rgba.width();
|
||||
let logo_h = logo_rgba.height();
|
||||
// Horizontal center, top-margin from the top. Saturating math
|
||||
// means a logo wider than CANVAS_W (shouldn't happen after the
|
||||
// downscale above, but defensive) just sits flush-left.
|
||||
let off_x = CANVAS_W.saturating_sub(logo_w) / 2;
|
||||
let off_y = LOGO_TOP_MARGIN.min(CANVAS_H.saturating_sub(logo_h));
|
||||
image::imageops::overlay(&mut canvas, &logo_rgba, off_x.into(), off_y.into());
|
||||
|
||||
let mut out = Vec::with_capacity(64 * 1024);
|
||||
DynamicImage::ImageRgba8(canvas).write_to(&mut Cursor::new(&mut out), ImageFormat::Png)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn downscale_to_fit(img: DynamicImage, max_w: u32, max_h: u32) -> DynamicImage {
|
||||
let (w, h) = (img.width(), img.height());
|
||||
if w <= max_w && h <= max_h {
|
||||
return img;
|
||||
}
|
||||
// Preserve aspect ratio. `resize` clamps to the smaller of the
|
||||
// two scale factors so we never overshoot the bounding box.
|
||||
img.resize(max_w, max_h, FilterType::Lanczos3)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use image::{ImageBuffer, Rgb};
|
||||
|
||||
fn solid_png(w: u32, h: u32, rgb: [u8; 3]) -> Vec<u8> {
|
||||
let img: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_pixel(w, h, Rgb(rgb));
|
||||
let mut out = Vec::with_capacity(4096);
|
||||
DynamicImage::ImageRgb8(img)
|
||||
.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
|
||||
.unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_emits_canvas_sized_png() {
|
||||
let src = solid_png(120, 60, [200, 50, 50]);
|
||||
let out = compose_pxe_logo(&src).unwrap();
|
||||
// Round-trip the output and confirm dimensions.
|
||||
let img = image::load_from_memory(&out).unwrap();
|
||||
assert_eq!(img.width(), CANVAS_W);
|
||||
assert_eq!(img.height(), CANVAS_H);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_logo_centered_at_top_margin() {
|
||||
let src = solid_png(100, 40, [10, 200, 10]);
|
||||
let out = compose_pxe_logo(&src).unwrap();
|
||||
let canvas = image::load_from_memory(&out).unwrap().to_rgba8();
|
||||
// Pixel just inside the logo box should match the source color
|
||||
// (alpha=255). Pixel near a far corner of the canvas should be
|
||||
// the transparent background.
|
||||
let cx = (CANVAS_W - 100) / 2;
|
||||
let cy = LOGO_TOP_MARGIN;
|
||||
let inside = canvas.get_pixel(cx + 10, cy + 10);
|
||||
assert_eq!(inside.0[3], 255, "logo pixel should be opaque");
|
||||
assert!(inside.0[0] < 100 && inside.0[1] > 100 && inside.0[2] < 100, "color mismatch: {inside:?}");
|
||||
let corner = canvas.get_pixel(CANVAS_W - 1, CANVAS_H - 1);
|
||||
assert_eq!(corner.0[3], 0, "canvas corner should be transparent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversize_logo_is_downscaled_to_bounding_box() {
|
||||
// 4000×800 image — bigger than LOGO_MAX_W and LOGO_MAX_H in
|
||||
// both axes. After downscale the output must fit; we re-decode
|
||||
// the canvas, count non-transparent pixels, and confirm none
|
||||
// sit outside the expected band.
|
||||
let src = solid_png(4000, 800, [50, 50, 200]);
|
||||
let out = compose_pxe_logo(&src).unwrap();
|
||||
let canvas = image::load_from_memory(&out).unwrap().to_rgba8();
|
||||
// Span row at the top margin should have non-transparent
|
||||
// pixels somewhere; rows past the LOGO_TOP_MARGIN + LOGO_MAX_H
|
||||
// should be entirely transparent.
|
||||
let bottom_band_y = LOGO_TOP_MARGIN + LOGO_MAX_H + 10;
|
||||
for x in 0..CANVAS_W {
|
||||
let p = canvas.get_pixel(x, bottom_band_y);
|
||||
assert_eq!(p.0[3], 0, "row {bottom_band_y} should be transparent at x={x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_bytes_returns_error_not_panic() {
|
||||
let r = compose_pxe_logo(b"\xde\xad\xbe\xef not an image");
|
||||
assert!(r.is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user