From 4bbac3e4d5fc74ab62f2d3d9635d8e50bc084610 Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Sun, 26 Oct 2025 23:59:50 -0400 Subject: [PATCH] feat(boot): implement basic boot menu --- hack/dev/boot.sh | 16 +-- hack/dev/build.sh | 2 +- hack/dev/configs/all.sprout.toml | 30 ++++++ hack/dev/configs/edera.sprout.toml | 1 + hack/dev/configs/kernel.sprout.toml | 1 + hack/dev/configs/shell.sprout.toml | 1 + hack/dev/configs/xen.sprout.toml | 1 + src/config.rs | 10 ++ src/entries.rs | 29 ++++++ src/main.rs | 58 ++++++----- src/menu.rs | 153 ++++++++++++++++++++++++++++ src/options.rs | 34 +++++++ 12 files changed, 304 insertions(+), 32 deletions(-) create mode 100644 hack/dev/configs/all.sprout.toml create mode 100644 src/menu.rs diff --git a/hack/dev/boot.sh b/hack/dev/boot.sh index 24488ad..e52c513 100755 --- a/hack/dev/boot.sh +++ b/hack/dev/boot.sh @@ -35,13 +35,15 @@ set -- "${@}" -smp 2 -m 4096 if [ "${NO_GRAPHICAL_BOOT}" = "1" ]; then set -- "${@}" -nographic else - if [ "${QEMU_LEGACY_SERIAL}" = "1" ]; then - set -- "${@}" -serial stdio - else - set -- "${@}" \ - -device virtio-serial-pci,id=vs0 \ - -chardev stdio,id=stdio0 \ - -device virtconsole,chardev=stdio0,id=console0 + if [ "${GRAPHICAL_ONLY}" != "1" ]; then + if [ "${QEMU_LEGACY_SERIAL}" = "1" ]; then + set -- "${@}" -serial stdio + else + set -- "${@}" \ + -device virtio-serial-pci,id=vs0 \ + -chardev stdio,id=stdio0 \ + -device virtconsole,chardev=stdio0,id=console0 + fi fi if [ "${QEMU_LEGACY_VGA}" = "1" ]; then diff --git a/hack/dev/build.sh b/hack/dev/build.sh index 61984b9..d4acd4e 100755 --- a/hack/dev/build.sh +++ b/hack/dev/build.sh @@ -11,7 +11,7 @@ if [ "${TARGET_ARCH}" = "aarch64" ]; then fi if [ -z "${SPROUT_CONFIG_NAME}" ]; then - SPROUT_CONFIG_NAME="kernel" + SPROUT_CONFIG_NAME="all" fi echo "[build] ${TARGET_ARCH} ${RUST_PROFILE}" diff --git a/hack/dev/configs/all.sprout.toml b/hack/dev/configs/all.sprout.toml new file mode 100644 index 0000000..c42ba93 --- /dev/null +++ b/hack/dev/configs/all.sprout.toml @@ -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"] diff --git a/hack/dev/configs/edera.sprout.toml b/hack/dev/configs/edera.sprout.toml index 77acdb2..613d32d 100644 --- a/hack/dev/configs/edera.sprout.toml +++ b/hack/dev/configs/edera.sprout.toml @@ -2,6 +2,7 @@ version = 1 [defaults] entry = "edera" +menu-timeout = 0 [extractors.boot.filesystem-device-match] has-item = "\\EFI\\BOOT\\xen.efi" diff --git a/hack/dev/configs/kernel.sprout.toml b/hack/dev/configs/kernel.sprout.toml index 8ac3fce..dd9064e 100644 --- a/hack/dev/configs/kernel.sprout.toml +++ b/hack/dev/configs/kernel.sprout.toml @@ -2,6 +2,7 @@ version = 1 [defaults] entry = "kernel" +menu-timeout = 0 [extractors.boot.filesystem-device-match] has-item = "\\EFI\\BOOT\\kernel.efi" diff --git a/hack/dev/configs/shell.sprout.toml b/hack/dev/configs/shell.sprout.toml index f36b850..79fafa3 100644 --- a/hack/dev/configs/shell.sprout.toml +++ b/hack/dev/configs/shell.sprout.toml @@ -2,6 +2,7 @@ version = 1 [defaults] entry = "shell" +menu-timeout = 0 [extractors.boot.filesystem-device-match] has-item = "\\EFI\\BOOT\\shell.efi" diff --git a/hack/dev/configs/xen.sprout.toml b/hack/dev/configs/xen.sprout.toml index 6a54986..e7e913d 100644 --- a/hack/dev/configs/xen.sprout.toml +++ b/hack/dev/configs/xen.sprout.toml @@ -2,6 +2,7 @@ version = 1 [defaults] entry = "xen" +menu-timeout = 0 [extractors.boot.filesystem-device-match] has-item = "\\EFI\\BOOT\\xen.efi" diff --git a/src/config.rs b/src/config.rs index ba6aaa7..4ad4225 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,6 +14,9 @@ pub mod loader; /// This must be incremented when the configuration breaks compatibility. 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. #[derive(Serialize, Deserialize, Default, Clone)] pub struct RootConfiguration { @@ -68,8 +71,15 @@ pub struct DefaultsConfiguration { /// The entry to boot without showing the boot menu. /// If not specified, a boot menu is shown. pub entry: Option, + /// The timeout of the boot menu. + #[serde(rename = "menu-timeout", default = "default_menu_timeout")] + pub menu_timeout: u64, } fn latest_version() -> u32 { LATEST_VERSION } + +fn default_menu_timeout() -> u64 { + DEFAULT_MENU_TIMEOUT_SECONDS +} diff --git a/src/entries.rs b/src/entries.rs index 68a1165..f32f781 100644 --- a/src/entries.rs +++ b/src/entries.rs @@ -27,6 +27,7 @@ pub struct BootableEntry { title: String, context: Rc, declaration: EntryDeclaration, + default: bool, } impl BootableEntry { @@ -42,6 +43,7 @@ impl BootableEntry { title, context, declaration, + default: false, } } @@ -65,6 +67,11 @@ impl BootableEntry { &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. pub fn swap_context(&mut self, context: Rc) { self.context = context; @@ -75,8 +82,30 @@ impl BootableEntry { 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`. pub fn prepend_name_prefix(&mut self, prefix: &str) { 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, + ) -> Option<&'a BootableEntry> { + haystack + .enumerate() + .find(|(index, entry)| entry.is_match(needle) || index.to_string() == needle) + .map(|(_index, entry)| entry) + } } diff --git a/src/main.rs b/src/main.rs index be369e1..af59c42 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ use anyhow::{Context, Result}; use log::info; use std::collections::BTreeMap; use std::ops::Deref; +use std::time::Duration; use uefi::proto::device_path::LoadedImageDevicePath; 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. 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. pub mod phases; @@ -151,41 +155,47 @@ fn main() -> Result<()> { entry.swap_context(context); // Restamp the title with any values. 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. - // For now, we just print all of the entries. - info!("entries:"); - for (index, entry) in entries.iter().enumerate() { - let title = entry.context().stamp(&entry.declaration().title); - info!(" entry {} [{}]: {}", index, entry.name(), title); + // If no entries were the default, pick the first entry as the default entry. + if entries.iter().all(|entry| !entry.is_default()) + && let Some(entry) = entries.first_mut() + { + entry.mark_default(); } // Execute the 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. - let boot = context + // If --boot is specified, boot that entry immediately. + 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() .options() - .boot - .as_ref() - .or(config.defaults.entry.as_ref()); + .menu_timeout + .unwrap_or(config.defaults.menu_timeout); + let menu_timeout = Duration::from_secs(menu_timeout); - // Use the boot option if possible, otherwise pick the first entry. - let entry = if let Some(ref boot) = boot { - entries - .iter() - .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. + // 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 { - 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. diff --git a/src/menu.rs b/src/menu.rs new file mode 100644 index 0000000..e3f9bd3 --- /dev/null +++ b/src/menu.rs @@ -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 { + // 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)) +} diff --git a/src/options.rs b/src/options.rs index db209e5..3f73eb2 100644 --- a/src/options.rs +++ b/src/options.rs @@ -15,6 +15,10 @@ pub struct SproutOptions { pub config: String, /// Entry to boot without showing the boot menu. pub boot: Option, + /// Force display of the boot menu. + pub force_menu: bool, + /// The timeout for the boot menu in seconds. + pub menu_timeout: Option, } /// The default Sprout options. @@ -23,6 +27,8 @@ impl Default for SproutOptions { Self { config: DEFAULT_CONFIG_PATH.to_string(), boot: None, + force_menu: false, + menu_timeout: None, } } } @@ -49,6 +55,20 @@ impl OptionsRepresentable for SproutOptions { 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", OptionDescription { @@ -76,6 +96,20 @@ impl OptionsRepresentable for SproutOptions { 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::() + .context("menu-timeout must be a number")?; + result.menu_timeout = Some(value); + } + _ => bail!("unknown option: --{key}"), } }