feat(boot): implement basic boot menu

This commit is contained in:
2025-10-26 23:59:50 -04:00
parent 9d2e25183b
commit 4bbac3e4d5
12 changed files with 304 additions and 32 deletions

View File

@@ -35,13 +35,15 @@ set -- "${@}" -smp 2 -m 4096
if [ "${NO_GRAPHICAL_BOOT}" = "1" ]; then if [ "${NO_GRAPHICAL_BOOT}" = "1" ]; then
set -- "${@}" -nographic set -- "${@}" -nographic
else else
if [ "${QEMU_LEGACY_SERIAL}" = "1" ]; then if [ "${GRAPHICAL_ONLY}" != "1" ]; then
set -- "${@}" -serial stdio if [ "${QEMU_LEGACY_SERIAL}" = "1" ]; then
else set -- "${@}" -serial stdio
set -- "${@}" \ else
-device virtio-serial-pci,id=vs0 \ set -- "${@}" \
-chardev stdio,id=stdio0 \ -device virtio-serial-pci,id=vs0 \
-device virtconsole,chardev=stdio0,id=console0 -chardev stdio,id=stdio0 \
-device virtconsole,chardev=stdio0,id=console0
fi
fi fi
if [ "${QEMU_LEGACY_VGA}" = "1" ]; then if [ "${QEMU_LEGACY_VGA}" = "1" ]; then

View File

@@ -11,7 +11,7 @@ if [ "${TARGET_ARCH}" = "aarch64" ]; then
fi fi
if [ -z "${SPROUT_CONFIG_NAME}" ]; then if [ -z "${SPROUT_CONFIG_NAME}" ]; then
SPROUT_CONFIG_NAME="kernel" SPROUT_CONFIG_NAME="all"
fi fi
echo "[build] ${TARGET_ARCH} ${RUST_PROFILE}" echo "[build] ${TARGET_ARCH} ${RUST_PROFILE}"

View File

@@ -0,0 +1,30 @@
version = 1
[defaults]
entry = "kernel"
[extractors.boot.filesystem-device-match]
has-item = "\\EFI\\BOOT\\kernel.efi"
[actions.chainload-kernel]
chainload.path = "$boot\\EFI\\BOOT\\kernel.efi"
chainload.options = ["console=hvc0"]
chainload.linux-initrd = "$boot\\initramfs"
[entries.kernel]
title = "Boot Linux"
actions = ["chainload-kernel"]
[actions.chainload-shell]
chainload.path = "$boot\\EFI\\BOOT\\shell.efi"
[entries.shell]
title = "Boot Shell"
actions = ["chainload-shell"]
[actions.chainload-xen]
chainload.path = "$boot\\EFI\\BOOT\\xen.efi"
[entries.xen]
title = "Boot Xen"
actions = ["chainload-xen"]

View File

@@ -2,6 +2,7 @@ version = 1
[defaults] [defaults]
entry = "edera" entry = "edera"
menu-timeout = 0
[extractors.boot.filesystem-device-match] [extractors.boot.filesystem-device-match]
has-item = "\\EFI\\BOOT\\xen.efi" has-item = "\\EFI\\BOOT\\xen.efi"

View File

@@ -2,6 +2,7 @@ version = 1
[defaults] [defaults]
entry = "kernel" entry = "kernel"
menu-timeout = 0
[extractors.boot.filesystem-device-match] [extractors.boot.filesystem-device-match]
has-item = "\\EFI\\BOOT\\kernel.efi" has-item = "\\EFI\\BOOT\\kernel.efi"

View File

@@ -2,6 +2,7 @@ version = 1
[defaults] [defaults]
entry = "shell" entry = "shell"
menu-timeout = 0
[extractors.boot.filesystem-device-match] [extractors.boot.filesystem-device-match]
has-item = "\\EFI\\BOOT\\shell.efi" has-item = "\\EFI\\BOOT\\shell.efi"

View File

@@ -2,6 +2,7 @@ version = 1
[defaults] [defaults]
entry = "xen" entry = "xen"
menu-timeout = 0
[extractors.boot.filesystem-device-match] [extractors.boot.filesystem-device-match]
has-item = "\\EFI\\BOOT\\xen.efi" has-item = "\\EFI\\BOOT\\xen.efi"

View File

@@ -14,6 +14,9 @@ pub mod loader;
/// This must be incremented when the configuration breaks compatibility. /// This must be incremented when the configuration breaks compatibility.
pub const LATEST_VERSION: u32 = 1; pub const LATEST_VERSION: u32 = 1;
/// The default timeout for the boot menu in seconds.
pub const DEFAULT_MENU_TIMEOUT_SECONDS: u64 = 10;
/// The Sprout configuration format. /// The Sprout configuration format.
#[derive(Serialize, Deserialize, Default, Clone)] #[derive(Serialize, Deserialize, Default, Clone)]
pub struct RootConfiguration { pub struct RootConfiguration {
@@ -68,8 +71,15 @@ pub struct DefaultsConfiguration {
/// The entry to boot without showing the boot menu. /// The entry to boot without showing the boot menu.
/// If not specified, a boot menu is shown. /// If not specified, a boot menu is shown.
pub entry: Option<String>, pub entry: Option<String>,
/// The timeout of the boot menu.
#[serde(rename = "menu-timeout", default = "default_menu_timeout")]
pub menu_timeout: u64,
} }
fn latest_version() -> u32 { fn latest_version() -> u32 {
LATEST_VERSION LATEST_VERSION
} }
fn default_menu_timeout() -> u64 {
DEFAULT_MENU_TIMEOUT_SECONDS
}

View File

@@ -27,6 +27,7 @@ pub struct BootableEntry {
title: String, title: String,
context: Rc<SproutContext>, context: Rc<SproutContext>,
declaration: EntryDeclaration, declaration: EntryDeclaration,
default: bool,
} }
impl BootableEntry { impl BootableEntry {
@@ -42,6 +43,7 @@ impl BootableEntry {
title, title,
context, context,
declaration, declaration,
default: false,
} }
} }
@@ -65,6 +67,11 @@ impl BootableEntry {
&self.declaration &self.declaration
} }
/// Fetch whether the entry is the default entry.
pub fn is_default(&self) -> bool {
self.default
}
/// Swap out the context of the entry. /// Swap out the context of the entry.
pub fn swap_context(&mut self, context: Rc<SproutContext>) { pub fn swap_context(&mut self, context: Rc<SproutContext>) {
self.context = context; self.context = context;
@@ -75,8 +82,30 @@ impl BootableEntry {
self.title = self.context.stamp(&self.title); self.title = self.context.stamp(&self.title);
} }
/// Mark this entry as the default entry.
pub fn mark_default(&mut self) {
self.default = true;
}
/// Prepend the name of the entry with `prefix`. /// Prepend the name of the entry with `prefix`.
pub fn prepend_name_prefix(&mut self, prefix: &str) { pub fn prepend_name_prefix(&mut self, prefix: &str) {
self.name.insert_str(0, prefix); self.name.insert_str(0, prefix);
} }
/// Determine if this entry matches `needle` by comparing to the name or title of the entry.
pub fn is_match(&self, needle: &str) -> bool {
self.name == needle || self.title == needle
}
/// Find an entry by `needle` inside the entry iterator `haystack`.
/// This will search for an entry by name, title, or index.
pub fn find<'a>(
needle: &str,
haystack: impl Iterator<Item = &'a BootableEntry>,
) -> Option<&'a BootableEntry> {
haystack
.enumerate()
.find(|(index, entry)| entry.is_match(needle) || index.to_string() == needle)
.map(|(_index, entry)| entry)
}
} }

View File

@@ -10,6 +10,7 @@ use anyhow::{Context, Result};
use log::info; use log::info;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::ops::Deref; use std::ops::Deref;
use std::time::Duration;
use uefi::proto::device_path::LoadedImageDevicePath; use uefi::proto::device_path::LoadedImageDevicePath;
use uefi::proto::device_path::text::{AllowShortcuts, DisplayOnly}; use uefi::proto::device_path::text::{AllowShortcuts, DisplayOnly};
@@ -34,6 +35,9 @@ pub mod extractors;
/// generators: Runtime code that can generate entries with specific values. /// generators: Runtime code that can generate entries with specific values.
pub mod generators; pub mod generators;
/// menu: Display a boot menu to select an entry to boot.
pub mod menu;
/// phases: Hooks into specific parts of the boot process. /// phases: Hooks into specific parts of the boot process.
pub mod phases; pub mod phases;
@@ -151,41 +155,47 @@ fn main() -> Result<()> {
entry.swap_context(context); entry.swap_context(context);
// Restamp the title with any values. // Restamp the title with any values.
entry.restamp_title(); entry.restamp_title();
// Mark this entry as the default entry if it is declared as such.
if let Some(ref default_entry) = config.defaults.entry {
// If the entry matches the default entry, mark it as the default entry.
if entry.is_match(default_entry) {
entry.mark_default();
}
}
} }
// TODO(azenla): Implement boot menu here. // If no entries were the default, pick the first entry as the default entry.
// For now, we just print all of the entries. if entries.iter().all(|entry| !entry.is_default())
info!("entries:"); && let Some(entry) = entries.first_mut()
for (index, entry) in entries.iter().enumerate() { {
let title = entry.context().stamp(&entry.declaration().title); entry.mark_default();
info!(" entry {} [{}]: {}", index, entry.name(), title);
} }
// Execute the late phase. // Execute the late phase.
phase(context.clone(), &config.phases.late).context("unable to execute late phase")?; phase(context.clone(), &config.phases.late).context("unable to execute late phase")?;
// If --boot is specified, or defaults.entry is specified, use that to find the entry to boot. // If --boot is specified, boot that entry immediately.
let boot = context let force_boot_entry = context.root().options().boot.as_ref();
// If --force-menu is specified, show the boot menu regardless of the value of --boot.
let force_boot_menu = context.root().options().force_menu;
// Determine the menu timeout in seconds based on the options or configuration.
// We prefer the options over the configuration to allow for overriding.
let menu_timeout = context
.root() .root()
.options() .options()
.boot .menu_timeout
.as_ref() .unwrap_or(config.defaults.menu_timeout);
.or(config.defaults.entry.as_ref()); let menu_timeout = Duration::from_secs(menu_timeout);
// Use the boot option if possible, otherwise pick the first entry. // Use the forced boot entry if possible, otherwise pick the first entry using a boot menu.
let entry = if let Some(ref boot) = boot { let entry = if !force_boot_menu && let Some(ref force_boot_entry) = force_boot_entry {
entries BootableEntry::find(force_boot_entry, entries.iter())
.iter() .context(format!("unable to find entry: {force_boot_entry}"))?
.enumerate()
.find(|(index, entry)| {
entry.name() == boot.as_str()
|| entry.title() == boot.as_str()
|| index.to_string() == boot.as_str()
})
.context(format!("unable to find entry: {boot}"))?
.1 // select the bootable entry.
} else { } else {
entries.first().context("no entries found")? // Delegate to the menu to select an entry to boot.
menu::select(menu_timeout, &entries).context("unable to select entry via boot menu")?
}; };
// Execute all the actions for the selected entry. // Execute all the actions for the selected entry.

153
src/menu.rs Normal file
View File

@@ -0,0 +1,153 @@
use crate::entries::BootableEntry;
use anyhow::{Context, Result, bail};
use std::time::Duration;
use uefi::ResultExt;
use uefi::boot::TimerTrigger;
use uefi::proto::console::text::{Input, Key, ScanCode};
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'];
/// 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<MenuOperation> {
// The event to wait for a key press.
let key_event = input
.wait_for_key_event()
.context("unable to acquire key event")?;
// Timer event for timeout.
// SAFETY: The timer event creation allocated a timer pointer on the UEFI heap.
// This is validated safe as long as we are in boot services.
let timer_event = unsafe {
uefi::boot::create_event_ex(EventType::TIMER, Tpl::CALLBACK, None, None, None)
.context("unable to create timer event")?
};
// The timeout is in increments of 100 nanoseconds.
let trigger = TimerTrigger::Relative(timeout.as_nanos() as u64 / 100);
uefi::boot::set_timer(&timer_event, trigger).context("unable to set timeout timer")?;
let mut events = [timer_event, key_event];
let event = uefi::boot::wait_for_event(&mut events)
.discard_errdata()
.context("unable to wait for event")?;
// 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.
let Some(key) = input.read_key().context("unable to read key")? else {
bail!("no key was pressed");
};
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() {
// Until a pretty menu is available, we just print all the entries.
println!("Boot Menu:");
for (index, entry) in entries.iter().enumerate() {
let title = entry.context().stamp(&entry.declaration().title);
println!(" [{}] {} ({})", index, title, entry.name());
}
}
// 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;
}
println!();
println!("Select a boot entry using the number keys.");
println!("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 {
println!("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.
pub fn select(timeout: Duration, entries: &[BootableEntry]) -> Result<&BootableEntry> {
// Acquire the standard input device and run the boot menu.
uefi::system::with_stdin(move |input| select_with_input(input, timeout, entries))
}

View File

@@ -15,6 +15,10 @@ pub struct SproutOptions {
pub config: String, pub config: String,
/// Entry to boot without showing the boot menu. /// Entry to boot without showing the boot menu.
pub boot: Option<String>, pub boot: Option<String>,
/// Force display of the boot menu.
pub force_menu: bool,
/// The timeout for the boot menu in seconds.
pub menu_timeout: Option<u64>,
} }
/// The default Sprout options. /// The default Sprout options.
@@ -23,6 +27,8 @@ impl Default for SproutOptions {
Self { Self {
config: DEFAULT_CONFIG_PATH.to_string(), config: DEFAULT_CONFIG_PATH.to_string(),
boot: None, boot: None,
force_menu: false,
menu_timeout: None,
} }
} }
} }
@@ -49,6 +55,20 @@ impl OptionsRepresentable for SproutOptions {
form: OptionForm::Value, form: OptionForm::Value,
}, },
), ),
(
"force-menu",
OptionDescription {
description: "Force showing of the boot menu",
form: OptionForm::Flag,
},
),
(
"menu-timeout",
OptionDescription {
description: "Boot menu timeout, in seconds",
form: OptionForm::Value,
},
),
( (
"help", "help",
OptionDescription { OptionDescription {
@@ -76,6 +96,20 @@ impl OptionsRepresentable for SproutOptions {
result.boot = Some(value.context("--boot option requires a value")?); result.boot = Some(value.context("--boot option requires a value")?);
} }
"force-menu" => {
// Force showing of the boot menu.
result.force_menu = true;
}
"menu-timeout" => {
// The timeout for the boot menu in seconds.
let value = value.context("--menu-timeout option requires a value")?;
let value = value
.parse::<u64>()
.context("menu-timeout must be a number")?;
result.menu_timeout = Some(value);
}
_ => bail!("unknown option: --{key}"), _ => bail!("unknown option: --{key}"),
} }
} }