diff --git a/README.md b/README.md index 0467fe5..7589ce3 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,8 @@ $ sprout.efi --config=\path\to\config.toml $ sprout.efi --boot="Boot Xen" # Autoconfigure Sprout, without loading a configuration file. $ sprout.efi --autoconfigure +# Use the basic boot menu instead of the simple one. +$ sprout.efi --menu-style=basic ``` ### Boot Linux from ESP diff --git a/crates/boot/src/main.rs b/crates/boot/src/main.rs index 377eb0c..2d2508c 100644 --- a/crates/boot/src/main.rs +++ b/crates/boot/src/main.rs @@ -338,13 +338,21 @@ fn run() -> Result<()> { // Convert the menu timeout to a duration. let menu_timeout = Duration::from_secs(menu_timeout); + // Determine the menu style based on the options or configuration. + // We prefer the options over the configuration to allow for overriding. + let menu_style = context + .root() + .options() + .menu_style + .unwrap_or(config.options.menu_style); + // Use the forced boot entry if possible, otherwise pick the first entry using a boot menu. let entry = if !force_boot_menu && let Some(ref force_boot_entry) = force_boot_entry { BootableEntry::find(force_boot_entry, entries.iter()) .context(format!("unable to find entry: {force_boot_entry}"))? } else { // Delegate to the menu to select an entry to boot. - menu::select(&timer, menu_timeout, &entries) + menu::select(&timer, menu_timeout, menu_style, &entries) .context("unable to select entry via boot menu")? }; diff --git a/crates/boot/src/menu.rs b/crates/boot/src/menu.rs index c21b273..e6ad5a8 100644 --- a/crates/boot/src/menu.rs +++ b/crates/boot/src/menu.rs @@ -1,36 +1,40 @@ use crate::entries::BootableEntry; +use crate::menu::basic::BasicMenu; +use crate::menu::simple::SimpleMenu; use alloc::vec; use anyhow::{Context, Result}; use core::time::Duration; +use edera_sprout_config::MenuStyle; use eficore::bootloader_interface::BootloaderInterface; use eficore::platform::timer::PlatformTimer; -use log::{info, warn}; +use log::warn; use uefi::ResultExt; use uefi::boot::TimerTrigger; -use uefi::proto::console::text::{Input, Key, ScanCode}; +use uefi::proto::console::text::Input; use uefi_raw::table::boot::{EventType, Tpl}; -/// The characters that can be used to select an entry from keys. -const ENTRY_NUMBER_TABLE: &[char] = &['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; +/// basic: A menu that prints the entries and selects them by number. +pub mod basic; -/// Represents the operation that can be performed by the boot menu. -#[derive(PartialEq, Eq)] -enum MenuOperation { - /// The user selected a numbered entry. - Number(usize), - /// The user selected the escape key to exit the boot menu. - Exit, - /// The user selected the enter key to display the entries again. - Continue, - /// Timeout occurred. - Timeout, - /// No operation should be performed. - Nop, +/// simple: A full-screen menu that selects entries with the arrow keys. +pub mod simple; + +/// A boot menu that can be shown to select an entry to boot. +pub trait BootMenu { + /// Select an entry from `entries` to boot. If no entry is chosen before `timeout` passes, + /// the default entry is selected. + fn select<'a>( + &self, + timeout: Duration, + entries: &'a [BootableEntry], + ) -> Result<&'a BootableEntry>; } -/// Read a key from the input device with a duration, returning the [MenuOperation] that was -/// performed. -fn read(input: &mut Input, timeout: &Duration) -> Result { +/// Wait for the key event of `input` to be signaled, giving up once `timeout` passes. +/// Returns true if the key event was signaled, or false if the timeout passed. +/// Some firmware signals the key event without a key being available, so reading +/// the key afterward may still produce no key. +pub fn wait_for_key(input: &mut Input, timeout: Duration) -> Result { // The event to wait for a key press. let key_event = input .wait_for_key_event() @@ -45,7 +49,7 @@ fn read(input: &mut Input, timeout: &Duration) -> Result { }; // Set a timer to trigger after the specified duration. - let trigger = TimerTrigger::Relative(*timeout); + let trigger = TimerTrigger::Relative(timeout); uefi::boot::set_timer(&timer_event, trigger).context("unable to set timeout timer")?; let events = vec![timer_event, key_event]; @@ -79,115 +83,26 @@ fn read(input: &mut Input, timeout: &Duration) -> Result { // Acquire the event that triggered. let event = event_result?; - // The first event is the timer event. - // If it has triggered, the user did not select a numbered entry. - if event == 0 { - return Ok(MenuOperation::Timeout); - } - - // If we reach here, there is a key event. - // Some firmware signals the key event without a key being available, - // in which case there is nothing to do. - let Some(key) = input.read_key().context("unable to read key")? else { - return Ok(MenuOperation::Nop); - }; - - match key { - Key::Printable(c) => { - // If the key is not ascii, we can't process it. - if !c.is_ascii() { - return Ok(MenuOperation::Continue); - } - // Convert the key to a char. - let c: char = c.into(); - // Find the key pressed in the entry number table or continue. - Ok(ENTRY_NUMBER_TABLE - .iter() - .position(|&x| x == c) - .map(MenuOperation::Number) - .unwrap_or(MenuOperation::Continue)) - } - - // The escape key is used to exit the boot menu. - Key::Special(ScanCode::ESCAPE) => Ok(MenuOperation::Exit), - - // If the special key is unknown, do nothing. - Key::Special(_) => Ok(MenuOperation::Nop), - } + // The first event is the timer event, so any other event is the key event. + Ok(event != 0) } -/// Selects an entry from the list of entries using the boot menu. -fn select_with_input<'a>( - input: &mut Input, - timeout: Duration, - entries: &'a [BootableEntry], -) -> Result<&'a BootableEntry> { - loop { - // If the timeout is not zero, let's display the boot menu. - if !timeout.is_zero() { - // Until a pretty menu is available, we just print all the entries. - info!("Boot Menu:"); - for (index, entry) in entries.iter().enumerate() { - let title = entry.context().stamp(&entry.declaration().title); - info!(" [{}] {}", index, title); - } - } - - // Read from input until a valid operation is selected. - let operation = loop { - // If the timeout is zero, we can exit immediately because there is nothing to do. - if timeout.is_zero() { - break MenuOperation::Exit; - } - - info!("Select a boot entry using the number keys."); - info!("Press Escape to exit and enter to display the entries again."); - - let operation = read(input, &timeout)?; - if operation != MenuOperation::Nop { - break operation; - } - }; - - match operation { - // Entry was selected by number. If the number is invalid, we continue. - MenuOperation::Number(index) => { - let Some(entry) = entries.get(index) else { - info!("invalid entry number"); - continue; - }; - return Ok(entry); - } - - // When the user exits the boot menu or a timeout occurs, we should - // boot the default entry, if any. - MenuOperation::Exit | MenuOperation::Timeout => { - return entries - .iter() - .find(|item| item.is_default()) - .context("no default entry available"); - } - - // If the operation is to continue or nop, we can just run the loop again. - MenuOperation::Continue | MenuOperation::Nop => { - continue; - } - } - } -} - -/// Shows a boot menu to select a bootable entry to boot. -/// The actual work is done internally in [select_with_input] which is called -/// within the context of the standard input device. +/// Shows a boot menu of the specified `style` to select a bootable entry to boot. pub fn select<'live>( timer: &'live PlatformTimer, timeout: Duration, + style: MenuStyle, entries: &'live [BootableEntry], ) -> Result<&'live BootableEntry> { // Notify the bootloader interface that we are about to display the menu. BootloaderInterface::mark_menu(timer) .context("unable to mark menu display in bootloader interface")?; - // Acquire the standard input device and run the boot menu. - uefi::system::with_stdin(move |input| select_with_input(input, timeout, entries)) + // Pick the menu that implements the requested style. + let menu: &dyn BootMenu = match style { + MenuStyle::Basic => &BasicMenu, + MenuStyle::Simple => &SimpleMenu, + }; + + menu.select(timeout, entries) } diff --git a/crates/boot/src/menu/basic.rs b/crates/boot/src/menu/basic.rs new file mode 100644 index 0000000..96ef055 --- /dev/null +++ b/crates/boot/src/menu/basic.rs @@ -0,0 +1,139 @@ +use crate::entries::BootableEntry; +use crate::menu::{BootMenu, wait_for_key}; +use anyhow::{Context, Result}; +use core::time::Duration; +use log::info; +use uefi::proto::console::text::{Input, Key, ScanCode}; + +/// The characters that can be used to select an entry from keys. +const ENTRY_NUMBER_TABLE: &[char] = &['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; + +/// A boot menu that prints the entries and selects them by number. +pub struct BasicMenu; + +/// Represents the operation that can be performed by the boot menu. +#[derive(PartialEq, Eq)] +enum MenuOperation { + /// The user selected a numbered entry. + Number(usize), + /// The user selected the escape key to exit the boot menu. + Exit, + /// The user selected the enter key to display the entries again. + Continue, + /// Timeout occurred. + Timeout, + /// No operation should be performed. + Nop, +} + +/// Read a key from the input device with a duration, returning the [MenuOperation] that was +/// performed. +fn read(input: &mut Input, timeout: &Duration) -> Result { + // If the timer triggered, the user did not select a numbered entry. + if !wait_for_key(input, *timeout)? { + return Ok(MenuOperation::Timeout); + } + + // If we reach here, there is a key event. + // Some firmware signals the key event without a key being available, + // in which case there is nothing to do. + let Some(key) = input.read_key().context("unable to read key")? else { + return Ok(MenuOperation::Nop); + }; + + match key { + Key::Printable(c) => { + // If the key is not ascii, we can't process it. + if !c.is_ascii() { + return Ok(MenuOperation::Continue); + } + // Convert the key to a char. + let c: char = c.into(); + // Find the key pressed in the entry number table or continue. + Ok(ENTRY_NUMBER_TABLE + .iter() + .position(|&x| x == c) + .map(MenuOperation::Number) + .unwrap_or(MenuOperation::Continue)) + } + + // The escape key is used to exit the boot menu. + Key::Special(ScanCode::ESCAPE) => Ok(MenuOperation::Exit), + + // If the special key is unknown, do nothing. + Key::Special(_) => Ok(MenuOperation::Nop), + } +} + +/// Selects an entry from the list of entries using the boot menu. +fn select_with_input<'a>( + input: &mut Input, + timeout: Duration, + entries: &'a [BootableEntry], +) -> Result<&'a BootableEntry> { + loop { + // If the timeout is not zero, let's display the boot menu. + if !timeout.is_zero() { + // Print all the entries with the number used to select them. + info!("Boot Menu:"); + for (index, entry) in entries.iter().enumerate() { + let title = entry.context().stamp(&entry.declaration().title); + info!(" [{}] {}", index, title); + } + } + + // Read from input until a valid operation is selected. + let operation = loop { + // If the timeout is zero, we can exit immediately because there is nothing to do. + if timeout.is_zero() { + break MenuOperation::Exit; + } + + info!("Select a boot entry using the number keys."); + info!("Press Escape to exit and enter to display the entries again."); + + let operation = read(input, &timeout)?; + if operation != MenuOperation::Nop { + break operation; + } + }; + + match operation { + // Entry was selected by number. If the number is invalid, we continue. + MenuOperation::Number(index) => { + let Some(entry) = entries.get(index) else { + info!("invalid entry number"); + continue; + }; + return Ok(entry); + } + + // When the user exits the boot menu or a timeout occurs, we should + // boot the default entry, if any. + MenuOperation::Exit | MenuOperation::Timeout => { + return entries + .iter() + .find(|item| item.is_default()) + .context("no default entry available"); + } + + // If the operation is to continue or nop, we can just run the loop again. + MenuOperation::Continue | MenuOperation::Nop => { + continue; + } + } + } +} + +impl BootMenu for BasicMenu { + /// Selects an entry using the basic menu. + /// The actual work is done internally in [select_with_input] which is called + /// within the context of the standard input device. + fn select<'a>( + &self, + timeout: Duration, + entries: &'a [BootableEntry], + ) -> Result<&'a BootableEntry> { + uefi::system::with_stdin(move |input| select_with_input(input, timeout, entries)) + } +} diff --git a/crates/boot/src/menu/simple.rs b/crates/boot/src/menu/simple.rs new file mode 100644 index 0000000..6a97f1b --- /dev/null +++ b/crates/boot/src/menu/simple.rs @@ -0,0 +1,285 @@ +use crate::entries::BootableEntry; +use crate::menu::{BootMenu, wait_for_key}; +use alloc::format; +use alloc::string::String; +use anyhow::{Context, Result, anyhow}; +use core::fmt::Write; +use core::iter::repeat; +use core::time::Duration; +use uefi::proto::console::text::{Color, Input, Key, Output, ScanCode}; + +/// The number of columns to assume if the console does not report its mode. +const FALLBACK_COLUMNS: usize = 80; + +/// The number of rows to assume if the console does not report its mode. +const FALLBACK_ROWS: usize = 25; + +/// How often the countdown is updated. +const TICK: Duration = Duration::from_secs(1); + +/// The number of spaces on either side of an entry title. +const ENTRY_PADDING: usize = 2; + +/// The hint shown once the countdown has been stopped. +const HINT: &str = "Use the arrow keys to select an entry and press enter to boot."; + +/// A full-screen boot menu that selects entries with the arrow keys, similar to systemd-boot. +pub struct SimpleMenu; + +/// The position and size of the menu on the console. +struct Layout { + /// The number of rows on the console. + rows: usize, + /// The number of columns that can be drawn to. + columns: usize, + /// The column that entries start at. + x: usize, + /// The row that entries start at. + y: usize, + /// The width of each entry, including padding. + width: usize, + /// The number of entries that fit on the console. + visible: usize, +} + +impl Layout { + /// Calculate the layout of `entries` on the console `output`. + fn new(output: &Output, entries: &[BootableEntry]) -> Self { + // Some consoles do not report a mode, so we assume a standard size for them. + let (columns, rows) = output + .current_mode() + .ok() + .flatten() + .map(|mode| (mode.columns(), mode.rows())) + .unwrap_or((FALLBACK_COLUMNS, FALLBACK_ROWS)); + + // Leave the last column empty, as writing to it makes some consoles scroll. + let columns = columns.saturating_sub(1); + + // All entries are the width of the longest title so the highlight lines up. + let longest = entries + .iter() + .map(|entry| entry.title().chars().count()) + .max() + .unwrap_or(0); + let width = (longest + ENTRY_PADDING * 2).min(columns); + + // Keep the last two rows for the status line and a blank row above it. + let space = rows.saturating_sub(2).max(1); + let visible = entries.len().min(space); + + Self { + rows, + columns, + x: (columns - width) / 2, + y: (space - visible) / 2, + width, + visible, + } + } +} + +/// Draw `text` at `column` and `row`, padded with spaces or cut to fit `width`. +fn draw( + output: &mut Output, + column: usize, + row: usize, + width: usize, + text: &str, + colors: (Color, Color), +) -> Result<()> { + // Replace anything that isn't printable ASCII, as not all consoles can draw it. + let line: String = text + .chars() + .map(|c| { + if c == ' ' || c.is_ascii_graphic() { + c + } else { + '?' + } + }) + .chain(repeat(' ')) + .take(width) + .collect(); + + output + .set_color(colors.0, colors.1) + .context("unable to set console color")?; + output + .set_cursor_position(column, row) + .context("unable to set console cursor position")?; + output + .write_str(&line) + .map_err(|_| anyhow!("unable to write to console"))?; + Ok(()) +} + +/// Draw the visible `entries` starting at `offset` with `selected` highlighted, +/// and the `status` line at the bottom of the console. +fn render( + output: &mut Output, + layout: &Layout, + entries: &[BootableEntry], + selected: usize, + offset: usize, + status: &str, +) -> Result<()> { + for (row, (index, entry)) in entries + .iter() + .enumerate() + .skip(offset) + .take(layout.visible) + .enumerate() + { + // The selected entry is drawn in inverted colors. + let colors = if index == selected { + (Color::Black, Color::LightGray) + } else { + (Color::LightGray, Color::Black) + }; + let text = format!("{:padding$}{}", "", entry.title(), padding = ENTRY_PADDING); + draw( + output, + layout.x, + layout.y + row, + layout.width, + &text, + colors, + )?; + } + + // Center the status line across the console. + let padding = layout.columns.saturating_sub(status.len()) / 2; + let text = format!("{:padding$}{}", "", status); + draw( + output, + 0, + layout.rows.saturating_sub(1), + layout.columns, + &text, + (Color::LightGray, Color::Black), + ) +} + +/// Run the menu until an entry is chosen, returning the index of that entry. +/// The `selected` entry is booted if no key is pressed before `timeout` passes. +fn run( + input: &mut Input, + output: &mut Output, + layout: &Layout, + timeout: Duration, + entries: &[BootableEntry], + mut selected: usize, +) -> Result { + let last = entries.len() - 1; + // The first visible entry, which changes when scrolling through a long list. + let mut offset = 0; + // The time left before booting, or None once a key stops the countdown. + let mut remaining = Some(timeout); + + loop { + // Scroll so that the selected entry is visible. + if selected < offset { + offset = selected; + } else if selected >= offset + layout.visible { + offset = selected + 1 - layout.visible; + } + + let status = match remaining { + Some(remaining) => format!("Boot in {} s.", remaining.as_secs()), + None => String::from(HINT), + }; + render(output, layout, entries, selected, offset, &status)?; + + // Wait for a key, waking up every tick to update the countdown. + let tick = remaining.map_or(TICK, |remaining| remaining.min(TICK)); + if !wait_for_key(input, tick)? { + // Without a countdown there's nothing to do until a key is pressed. + let Some(left) = remaining else { + continue; + }; + + let left = left.saturating_sub(tick); + if left.is_zero() { + return Ok(selected); + } + remaining = Some(left); + continue; + } + + // Some firmware signals the key event without a key being available, + // in which case there is nothing to do. + let Some(key) = input.read_key().context("unable to read key")? else { + continue; + }; + + // Any key stops the countdown, which leaves escape with nothing else to do. + remaining = None; + + match key { + Key::Special(ScanCode::UP) => selected = selected.saturating_sub(1), + Key::Special(ScanCode::DOWN) => selected = (selected + 1).min(last), + Key::Special(ScanCode::HOME) => selected = 0, + Key::Special(ScanCode::END) => selected = last, + Key::Special(ScanCode::PAGE_UP) => selected = selected.saturating_sub(layout.visible), + Key::Special(ScanCode::PAGE_DOWN) => selected = (selected + layout.visible).min(last), + + // Serial consoles may send a line feed instead of a carriage return. + Key::Printable(c) if matches!(char::from(c), '\r' | '\n') => return Ok(selected), + + _ => {} + } + } +} + +/// Selects an entry from `entries` using the console `input` and `output`. +fn select_with_console<'a>( + input: &mut Input, + output: &mut Output, + timeout: Duration, + entries: &'a [BootableEntry], +) -> Result<&'a BootableEntry> { + let default = entries + .iter() + .position(|entry| entry.is_default()) + .context("no default entry available")?; + + // If the timeout is zero, boot the default entry without showing the menu. + if timeout.is_zero() { + return Ok(&entries[default]); + } + + let layout = Layout::new(output, entries); + let cursor_visible = output.cursor_visible(); + + output + .set_color(Color::LightGray, Color::Black) + .context("unable to set console color")?; + output.clear().context("unable to clear console")?; + // Not all consoles can hide the cursor, so this is allowed to fail. + let _ = output.enable_cursor(false); + + let result = run(input, output, &layout, timeout, entries, default); + + // Put the console back so that anything printed after the menu is readable. + let _ = output.set_color(Color::LightGray, Color::Black); + let _ = output.clear(); + let _ = output.enable_cursor(cursor_visible); + + Ok(&entries[result?]) +} + +impl BootMenu for SimpleMenu { + /// Selects an entry using the simple menu. + /// The actual work is done internally in [select_with_console] which is called + /// within the context of the standard input and output devices. + fn select<'a>( + &self, + timeout: Duration, + entries: &'a [BootableEntry], + ) -> Result<&'a BootableEntry> { + uefi::system::with_stdin(|input| { + uefi::system::with_stdout(|output| select_with_console(input, output, timeout, entries)) + }) + } +} diff --git a/crates/boot/src/options.rs b/crates/boot/src/options.rs index 0d38547..c6b60d8 100644 --- a/crates/boot/src/options.rs +++ b/crates/boot/src/options.rs @@ -1,9 +1,11 @@ use alloc::string::{String, ToString}; use anyhow::{Context, Result, anyhow}; use core::ptr::null_mut; +use edera_sprout_config::MenuStyle; use jaarg::{ ErrorUsageWriter, ErrorUsageWriterContext, HelpWriter, HelpWriterContext, Opt, Opts, - ParseControl, ParseResult, StandardErrorUsageWriter, StandardFullHelpWriter, + ParseControl, ParseError, ParseErrorKind, ParseResult, StandardErrorUsageWriter, + StandardFullHelpWriter, }; use log::{error, info}; use uefi_raw::Status; @@ -24,6 +26,8 @@ pub struct SproutOptions { pub force_menu: bool, /// The timeout for the boot menu in seconds. pub menu_timeout: Option, + /// The style of boot menu to display. + pub menu_style: Option, /// Retains the boot console before boot. pub retain_boot_console: bool, } @@ -37,6 +41,7 @@ impl Default for SproutOptions { boot: None, force_menu: false, menu_timeout: None, + menu_style: None, retain_boot_console: false, } } @@ -54,6 +59,7 @@ impl SproutOptions { Boot, ForceMenu, MenuTimeout, + MenuStyle, RetainBootConsole, } @@ -69,6 +75,8 @@ impl SproutOptions { Opt::flag(ArgID::ForceMenu, &["--force-menu"]).help_text("Force showing the boot menu"), Opt::value(ArgID::MenuTimeout, &["--menu-timeout"], "TIMEOUT") .help_text("Boot menu timeout, in seconds"), + Opt::value(ArgID::MenuStyle, &["--menu-style"], "STYLE") + .help_text("Boot menu style, basic or simple"), Opt::flag(ArgID::RetainBootConsole, &["--retain-boot-console"]) .help_text("Retain boot console before boot"), ]); @@ -105,6 +113,23 @@ impl SproutOptions { // The timeout for the boot menu in seconds. result.menu_timeout = Some(value.parse::()?); } + ArgID::MenuStyle => { + // The style of boot menu to display. + result.menu_style = Some(match value { + "basic" => MenuStyle::Basic, + "simple" => MenuStyle::Simple, + // jaarg has no error kind for an unknown choice, but this one is + // printed as an invalid argument. The parser fills in the option + // and value of the error. + _ => { + return Err(ParseError::ArgumentError( + "", + "", + ParseErrorKind::InvalidInteger, + )); + } + }); + } ArgID::RetainBootConsole => { // Retain the boot console before booting. result.retain_boot_console = true; diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 6b2ff5e..0da352b 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -84,11 +84,26 @@ pub struct OptionsConfiguration { /// The timeout of the boot menu. #[serde(rename = "menu-timeout", default = "default_menu_timeout")] pub menu_timeout: u64, + /// The style of boot menu to display. + #[serde(rename = "menu-style", default)] + pub menu_style: MenuStyle, /// Enables autoconfiguration of Sprout based on the environment. #[serde(default)] pub autoconfigure: bool, } +/// The style of boot menu to display. +#[derive(Serialize, Deserialize, Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum MenuStyle { + /// A basic menu that prints the entries and selects them by number. + #[serde(rename = "basic")] + Basic, + /// A simple full-screen menu that selects entries with the arrow keys. + #[default] + #[serde(rename = "simple")] + Simple, +} + /// Get the latest version of the Sprout configuration format. pub fn latest_version() -> u32 { LATEST_VERSION