github.com/mvdan/u-root-coreutils@v0.0.0-20230122170626-c2eef2898555/pkg/boot/menu/menu.go (about)

     1  // Copyright 2020-2021 the u-root Authors. All rights reserved
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  // Package menu displays a Terminal UI based text menu to choose boot options
     6  // from.
     7  package menu
     8  
     9  import (
    10  	"fmt"
    11  	"io"
    12  	"log"
    13  	"os"
    14  	"os/signal"
    15  	"strconv"
    16  	"strings"
    17  	"syscall"
    18  	"time"
    19  
    20  	"github.com/mvdan/u-root-coreutils/pkg/boot"
    21  	"github.com/mvdan/u-root-coreutils/pkg/sh"
    22  	"golang.org/x/sys/unix"
    23  )
    24  
    25  var (
    26  	initialTimeout    = 10 * time.Second
    27  	subsequentTimeout = 60 * time.Second
    28  )
    29  
    30  // Entry is a menu entry.
    31  type Entry interface {
    32  	// Label is the string displayed to the user in the menu. It must be a
    33  	// single line to fit in the menu.
    34  	Label() string
    35  
    36  	// Edit the kernel command line if possible. Must be called prior to
    37  	// Load.
    38  	Edit(func(cmdline string) string)
    39  
    40  	// Load is called when the entry is chosen, but does not transfer
    41  	// execution to another process or kernel.
    42  	Load() error
    43  
    44  	// Exec transfers execution to another process or kernel.
    45  	//
    46  	// Exec either returns an error or does not return at all.
    47  	Exec() error
    48  
    49  	// IsDefault indicates that this action should be run by default if the
    50  	// user didn't make an entry choice.
    51  	IsDefault() bool
    52  }
    53  
    54  // ExtendedLabel calls Entry.String(), but falls back to Entry.Label(). Shortly
    55  // before kexec, "Attempting to boot %s" is printed. This allows for multiple
    56  // lines of information which would not otherwise fit in the menu.
    57  func ExtendedLabel(e Entry) string {
    58  	if s, ok := e.(fmt.Stringer); ok {
    59  		return s.String()
    60  	}
    61  	return e.Label()
    62  }
    63  
    64  func parseBootNum(choice string, entries []Entry) (int, error) {
    65  	num, err := strconv.Atoi(strings.TrimSpace(choice))
    66  	if err != nil || num < 1 || num > len(entries) {
    67  		return -1, fmt.Errorf("%q is not a valid entry number", choice)
    68  	}
    69  	return num, nil
    70  }
    71  
    72  // SetInitialTimeout sets the initial timeout of the menu to the provided duration
    73  func SetInitialTimeout(timeout time.Duration) {
    74  	initialTimeout = timeout
    75  }
    76  
    77  // Choose presents the user a menu on input to choose an entry from and returns that entry.
    78  // Note: This call can block if MenuTerminal or the underlying os.File does
    79  //
    80  //	not support SetTimeout/SetDeadline.
    81  func Choose(term MenuTerminal, allowEdit bool, entries ...Entry) Entry {
    82  	fmt.Println("")
    83  	for i, e := range entries {
    84  		fmt.Printf("%02d. %s\r\n\r\n", i+1, e.Label())
    85  	}
    86  	fmt.Println("\r")
    87  
    88  	err := term.SetTimeout(initialTimeout)
    89  	if err != nil {
    90  		fmt.Printf("BUG: terminal does not support timeouts: %v\n", err)
    91  	}
    92  
    93  	// Reset the countdown timer when you press a key.
    94  	term.SetEntryCallback(func() {
    95  		_ = term.SetTimeout(subsequentTimeout)
    96  	})
    97  
    98  	for {
    99  		if allowEdit {
   100  			term.SetPrompt("Enter an option ('01' is the default, 'e' to edit kernel cmdline):\r\n > ")
   101  		} else {
   102  			term.SetPrompt("Enter an option ('01' is the default):\r\n > ")
   103  		}
   104  
   105  		choice, err := term.ReadLine()
   106  		if err != nil {
   107  			if text := err.Error(); !strings.Contains(text, os.ErrDeadlineExceeded.Error()) && err != io.EOF {
   108  				fmt.Printf("BUG: Please report: Terminal read error: %v.\n", err)
   109  			}
   110  			return nil
   111  		}
   112  
   113  		if allowEdit && choice == "e" {
   114  			// Edit command line.
   115  			term.SetPrompt("Select a boot option to edit:\r\n > ")
   116  			choice, err := term.ReadLine()
   117  			if err != nil {
   118  				fmt.Fprintln(term, err)
   119  				fmt.Fprintln(term, "Returning to main menu...")
   120  				continue
   121  			}
   122  			num, err := parseBootNum(choice, entries)
   123  			if err != nil {
   124  				fmt.Fprintln(term, err)
   125  				fmt.Fprintln(term, "Returning to main menu...")
   126  				continue
   127  			}
   128  			entries[num-1].Edit(func(cmdline string) string {
   129  				fmt.Fprintf(term, "The current quoted cmdline for option %d is:\r\n > %q\r\n", num, cmdline)
   130  				fmt.Fprintln(term, ` * Note the cmdline is c-style quoted. Ex: \n => newline, \\ => \`)
   131  				term.SetPrompt("Enter an option:\r\n * (a)ppend, (o)verwrite, (r)eturn to main menu\r\n > ")
   132  				choice, err := term.ReadLine()
   133  				if err != nil {
   134  					fmt.Fprintln(term, err)
   135  					return cmdline
   136  				}
   137  				switch choice {
   138  				case "a":
   139  					term.SetPrompt("Enter unquoted cmdline to append:\r\n > ")
   140  					appendCmdline, err := term.ReadLine()
   141  					if err != nil {
   142  						fmt.Fprintln(term, err)
   143  						return cmdline
   144  					}
   145  					if appendCmdline != "" {
   146  						cmdline += " " + appendCmdline
   147  					}
   148  				case "o":
   149  					term.SetPrompt("Enter new unquoted cmdline:\r\n > ")
   150  					newCmdline, err := term.ReadLine()
   151  					if err != nil {
   152  						fmt.Fprintln(term, err)
   153  						return cmdline
   154  					}
   155  					cmdline = newCmdline
   156  				case "r":
   157  				default:
   158  					fmt.Fprintf(term, "Unrecognized choice %q", choice)
   159  				}
   160  				fmt.Fprintf(term, "The new quoted cmdline for option %d is:\r\n > %q\r\n", num, cmdline)
   161  				return cmdline
   162  			})
   163  			fmt.Fprintln(term, "Returning to main menu...")
   164  			continue
   165  		}
   166  		if choice == "" {
   167  			// nil will result in the default order.
   168  			return nil
   169  		}
   170  		num, err := parseBootNum(choice, entries)
   171  		if err != nil {
   172  			fmt.Fprintln(term, err)
   173  			continue
   174  		}
   175  		return entries[num-1]
   176  	}
   177  }
   178  
   179  // ShowMenuAndLoad calls showMenuAndLoadFromFile using the default tty.
   180  // Use TTY because os.stdin does not support deadlines well.
   181  func ShowMenuAndLoad(allowEdit bool, entries ...Entry) Entry {
   182  	f, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
   183  	if err != nil {
   184  		log.Printf("Failed to open /dev/tty: %s\n", err)
   185  		return nil
   186  	}
   187  	defer f.Close()
   188  
   189  	return showMenuAndLoadFromFile(f, allowEdit, entries...)
   190  }
   191  
   192  // showMenuAndLoadFromFile lets the user choose one of entries and loads it.
   193  // If no entry is chosen by the user, an entry whose IsDefault() is true will be
   194  // returned.
   195  //
   196  // The user is left to call Entry.Exec when this function returns.
   197  func showMenuAndLoadFromFile(file *os.File, allowEdit bool, entries ...Entry) Entry {
   198  	// Clear the screen (ANSI terminal escape code for screen clear).
   199  	fmt.Printf("\033[1;1H\033[2J\n\n")
   200  	fmt.Printf("Welcome to LinuxBoot's Menu\n\n")
   201  	fmt.Printf("Enter a number to boot a kernel:\n")
   202  
   203  	for {
   204  		t := NewTerminal(file)
   205  		// Allow the user to choose.
   206  		entry := Choose(t, allowEdit, entries...)
   207  		if err := t.Close(); err != nil {
   208  			log.Printf("Failed to close terminal made from file %s "+
   209  				"(desc %d): %v", file.Name(), file.Fd(), err)
   210  		}
   211  
   212  		if entry == nil {
   213  			// This only returns something if the user explicitly
   214  			// entered something.
   215  			//
   216  			// If nothing was entered, fall back to default.
   217  			break
   218  		}
   219  		if err := entry.Load(); err != nil {
   220  			log.Printf("Failed to load %s: %v", entry.Label(), err)
   221  			continue
   222  		}
   223  
   224  		// Entry was successfully loaded. Leave it to the caller to
   225  		// exec, so the caller can clean up the OS before rebooting or
   226  		// kexecing (e.g. unmount file systems).
   227  		return entry
   228  	}
   229  
   230  	fmt.Println("")
   231  
   232  	// We only get one shot at actually booting, so boot the first kernel
   233  	// that can be loaded correctly.
   234  	for _, e := range entries {
   235  		// Only perform actions that are default actions. I.e. don't
   236  		// drop to shell.
   237  		if e.IsDefault() {
   238  			fmt.Printf("Attempting to boot %s.\n\n", ExtendedLabel(e))
   239  
   240  			if err := e.Load(); err != nil {
   241  				log.Printf("Failed to load %s: %v", e.Label(), err)
   242  				continue
   243  			}
   244  
   245  			// Entry was successfully loaded. Leave it to the
   246  			// caller to exec, so the caller can clean up the OS
   247  			// before rebooting or kexecing (e.g. unmount file
   248  			// systems).
   249  			return e
   250  		}
   251  	}
   252  	return nil
   253  }
   254  
   255  // OSImages returns menu entries for the given OSImages.
   256  func OSImages(verbose bool, imgs ...boot.OSImage) []Entry {
   257  	var menu []Entry
   258  	for _, img := range imgs {
   259  		menu = append(menu, &OSImageAction{
   260  			OSImage: img,
   261  			Verbose: verbose,
   262  		})
   263  	}
   264  	return menu
   265  }
   266  
   267  // OSImageAction is a menu.Entry that boots an OSImage.
   268  type OSImageAction struct {
   269  	boot.OSImage
   270  	Verbose bool
   271  }
   272  
   273  // Load implements Entry.Load by loading the OS image into memory.
   274  func (oia OSImageAction) Load() error {
   275  	if err := oia.OSImage.Load(oia.Verbose); err != nil {
   276  		return fmt.Errorf("could not load image %s: %v", oia.OSImage, err)
   277  	}
   278  	return nil
   279  }
   280  
   281  // Exec executes the loaded image.
   282  func (oia OSImageAction) Exec() error {
   283  	return boot.Execute()
   284  }
   285  
   286  // IsDefault returns true -- this action should be performed in order by
   287  // default if the user did not choose a boot entry.
   288  func (OSImageAction) IsDefault() bool { return true }
   289  
   290  // StartShell is a menu.Entry that starts a LinuxBoot shell.
   291  type StartShell struct{}
   292  
   293  // Label is the label to show to the user.
   294  func (StartShell) Label() string {
   295  	return "Enter a LinuxBoot shell"
   296  }
   297  
   298  // Edit does nothing.
   299  func (StartShell) Edit(func(cmdline string) string) {
   300  }
   301  
   302  // Load does nothing.
   303  func (StartShell) Load() error {
   304  	return nil
   305  }
   306  
   307  // Exec implements Entry.Exec by running /bin/defaultsh.
   308  func (StartShell) Exec() error {
   309  	// Reset signal handler for SIGINT to enable user interrupts again
   310  	signal.Reset(syscall.SIGINT)
   311  	return sh.RunWithLogs("/bin/defaultsh")
   312  }
   313  
   314  // IsDefault indicates that this should not be run as a default action.
   315  func (StartShell) IsDefault() bool { return false }
   316  
   317  // Reboot is a menu.Entry that reboots the machine.
   318  type Reboot struct{}
   319  
   320  // Label is the label to show to the user.
   321  func (Reboot) Label() string {
   322  	return "Reboot"
   323  }
   324  
   325  // Edit does nothing.
   326  func (Reboot) Edit(func(cmdline string) string) {
   327  }
   328  
   329  // Load does nothing.
   330  func (Reboot) Load() error {
   331  	return nil
   332  }
   333  
   334  // Exec reboots the machine using sys_reboot.
   335  func (Reboot) Exec() error {
   336  	unix.Sync()
   337  	return unix.Reboot(unix.LINUX_REBOOT_CMD_RESTART)
   338  }
   339  
   340  // IsDefault indicates that this should not be run as a default action.
   341  func (Reboot) IsDefault() bool { return false }