xref: /freebsd/stand/lua/menu.lua (revision 732a02b4e77866604a120a275c082bb6221bd2ff)
1--
2-- SPDX-License-Identifier: BSD-2-Clause-FreeBSD
3--
4-- Copyright (c) 2015 Pedro Souza <pedrosouza@freebsd.org>
5-- Copyright (c) 2018 Kyle Evans <kevans@FreeBSD.org>
6-- All rights reserved.
7--
8-- Redistribution and use in source and binary forms, with or without
9-- modification, are permitted provided that the following conditions
10-- are met:
11-- 1. Redistributions of source code must retain the above copyright
12--    notice, this list of conditions and the following disclaimer.
13-- 2. Redistributions in binary form must reproduce the above copyright
14--    notice, this list of conditions and the following disclaimer in the
15--    documentation and/or other materials provided with the distribution.
16--
17-- THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
18-- ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
19-- IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
20-- ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
21-- FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
22-- DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
23-- OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
24-- HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
25-- LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
26-- OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
27-- SUCH DAMAGE.
28--
29-- $FreeBSD$
30--
31
32local cli = require("cli")
33local core = require("core")
34local color = require("color")
35local config = require("config")
36local screen = require("screen")
37local drawer = require("drawer")
38
39local menu = {}
40
41local drawn_menu
42local return_menu_entry = {
43	entry_type = core.MENU_RETURN,
44	name = "Back to main menu" .. color.highlight(" [Backspace]"),
45}
46
47local function OnOff(str, value)
48	if value then
49		return str .. color.escapefg(color.GREEN) .. "On" ..
50		    color.resetfg()
51	else
52		return str .. color.escapefg(color.RED) .. "off" ..
53		    color.resetfg()
54	end
55end
56
57local function bootenvSet(env)
58	loader.setenv("vfs.root.mountfrom", env)
59	loader.setenv("currdev", env .. ":")
60	config.reload()
61end
62
63-- Module exports
64menu.handlers = {
65	-- Menu handlers take the current menu and selected entry as parameters,
66	-- and should return a boolean indicating whether execution should
67	-- continue or not. The return value may be omitted if this entry should
68	-- have no bearing on whether we continue or not, indicating that we
69	-- should just continue after execution.
70	[core.MENU_ENTRY] = function(_, entry)
71		-- run function
72		entry.func()
73	end,
74	[core.MENU_CAROUSEL_ENTRY] = function(_, entry)
75		-- carousel (rotating) functionality
76		local carid = entry.carousel_id
77		local caridx = config.getCarouselIndex(carid)
78		local choices = entry.items
79		if type(choices) == "function" then
80			choices = choices()
81		end
82		if #choices > 0 then
83			caridx = (caridx % #choices) + 1
84			config.setCarouselIndex(carid, caridx)
85			entry.func(caridx, choices[caridx], choices)
86		end
87	end,
88	[core.MENU_SUBMENU] = function(_, entry)
89		menu.process(entry.submenu)
90	end,
91	[core.MENU_RETURN] = function(_, entry)
92		-- allow entry to have a function/side effect
93		if entry.func ~= nil then
94			entry.func()
95		end
96		return false
97	end,
98}
99-- loader menu tree is rooted at menu.welcome
100
101menu.boot_environments = {
102	entries = {
103		-- return to welcome menu
104		return_menu_entry,
105		{
106			entry_type = core.MENU_CAROUSEL_ENTRY,
107			carousel_id = "be_active",
108			items = core.bootenvList,
109			name = function(idx, choice, all_choices)
110				if #all_choices == 0 then
111					return "Active: "
112				end
113
114				local is_default = (idx == 1)
115				local bootenv_name = ""
116				local name_color
117				if is_default then
118					name_color = color.escapefg(color.GREEN)
119				else
120					name_color = color.escapefg(color.BLUE)
121				end
122				bootenv_name = bootenv_name .. name_color ..
123				    choice .. color.resetfg()
124				return color.highlight("A").."ctive: " ..
125				    bootenv_name .. " (" .. idx .. " of " ..
126				    #all_choices .. ")"
127			end,
128			func = function(_, choice, _)
129				bootenvSet(choice)
130			end,
131			alias = {"a", "A"},
132		},
133		{
134			entry_type = core.MENU_ENTRY,
135			name = function()
136				return color.highlight("b") .. "ootfs: " ..
137				    core.bootenvDefault()
138			end,
139			func = function()
140				-- Reset active boot environment to the default
141				config.setCarouselIndex("be_active", 1)
142				bootenvSet(core.bootenvDefault())
143			end,
144			alias = {"b", "B"},
145		},
146	},
147}
148
149menu.boot_options = {
150	entries = {
151		-- return to welcome menu
152		return_menu_entry,
153		-- load defaults
154		{
155			entry_type = core.MENU_ENTRY,
156			name = "Load System " .. color.highlight("D") ..
157			    "efaults",
158			func = core.setDefaults,
159			alias = {"d", "D"},
160		},
161		{
162			entry_type = core.MENU_SEPARATOR,
163		},
164		{
165			entry_type = core.MENU_SEPARATOR,
166			name = "Boot Options:",
167		},
168		-- acpi
169		{
170			entry_type = core.MENU_ENTRY,
171			visible = core.isSystem386,
172			name = function()
173				return OnOff(color.highlight("A") ..
174				    "CPI       :", core.acpi)
175			end,
176			func = core.setACPI,
177			alias = {"a", "A"},
178		},
179		-- safe mode
180		{
181			entry_type = core.MENU_ENTRY,
182			name = function()
183				return OnOff("Safe " .. color.highlight("M") ..
184				    "ode  :", core.sm)
185			end,
186			func = core.setSafeMode,
187			alias = {"m", "M"},
188		},
189		-- single user
190		{
191			entry_type = core.MENU_ENTRY,
192			name = function()
193				return OnOff(color.highlight("S") ..
194				    "ingle user:", core.su)
195			end,
196			func = core.setSingleUser,
197			alias = {"s", "S"},
198		},
199		-- verbose boot
200		{
201			entry_type = core.MENU_ENTRY,
202			name = function()
203				return OnOff(color.highlight("V") ..
204				    "erbose    :", core.verbose)
205			end,
206			func = core.setVerbose,
207			alias = {"v", "V"},
208		},
209	},
210}
211
212menu.welcome = {
213	entries = function()
214		local menu_entries = menu.welcome.all_entries
215		-- Swap the first two menu items on single user boot
216		if core.isSingleUserBoot() then
217			-- We'll cache the swapped menu, for performance
218			if menu.welcome.swapped_menu ~= nil then
219				return menu.welcome.swapped_menu
220			end
221			-- Shallow copy the table
222			menu_entries = core.deepCopyTable(menu_entries)
223
224			-- Swap the first two menu entries
225			menu_entries[1], menu_entries[2] =
226			    menu_entries[2], menu_entries[1]
227
228			-- Then set their names to their alternate names
229			menu_entries[1].name, menu_entries[2].name =
230			    menu_entries[1].alternate_name,
231			    menu_entries[2].alternate_name
232			menu.welcome.swapped_menu = menu_entries
233		end
234		return menu_entries
235	end,
236	all_entries = {
237		-- boot multi user
238		{
239			entry_type = core.MENU_ENTRY,
240			name = color.highlight("B") .. "oot Multi user " ..
241			    color.highlight("[Enter]"),
242			-- Not a standard menu entry function!
243			alternate_name = color.highlight("B") ..
244			    "oot Multi user",
245			func = function()
246				core.setSingleUser(false)
247				core.boot()
248			end,
249			alias = {"b", "B"},
250		},
251		-- boot single user
252		{
253			entry_type = core.MENU_ENTRY,
254			name = "Boot " .. color.highlight("S") .. "ingle user",
255			-- Not a standard menu entry function!
256			alternate_name = "Boot " .. color.highlight("S") ..
257			    "ingle user " .. color.highlight("[Enter]"),
258			func = function()
259				core.setSingleUser(true)
260				core.boot()
261			end,
262			alias = {"s", "S"},
263		},
264		-- escape to interpreter
265		{
266			entry_type = core.MENU_RETURN,
267			name = color.highlight("Esc") .. "ape to loader prompt",
268			func = function()
269				loader.setenv("autoboot_delay", "NO")
270			end,
271			alias = {core.KEYSTR_ESCAPE},
272		},
273		-- reboot
274		{
275			entry_type = core.MENU_ENTRY,
276			name = color.highlight("R") .. "eboot",
277			func = function()
278				loader.perform("reboot")
279			end,
280			alias = {"r", "R"},
281		},
282		{
283			entry_type = core.MENU_SEPARATOR,
284		},
285		{
286			entry_type = core.MENU_SEPARATOR,
287			name = "Options:",
288		},
289		-- kernel options
290		{
291			entry_type = core.MENU_CAROUSEL_ENTRY,
292			carousel_id = "kernel",
293			items = core.kernelList,
294			name = function(idx, choice, all_choices)
295				if #all_choices == 0 then
296					return "Kernel: "
297				end
298
299				local is_default = (idx == 1)
300				local kernel_name = ""
301				local name_color
302				if is_default then
303					name_color = color.escapefg(color.GREEN)
304					kernel_name = "default/"
305				else
306					name_color = color.escapefg(color.BLUE)
307				end
308				kernel_name = kernel_name .. name_color ..
309				    choice .. color.resetfg()
310				return color.highlight("K") .. "ernel: " ..
311				    kernel_name .. " (" .. idx .. " of " ..
312				    #all_choices .. ")"
313			end,
314			func = function(_, choice, _)
315				if loader.getenv("kernelname") ~= nil then
316					loader.perform("unload")
317				end
318				config.selectKernel(choice)
319			end,
320			alias = {"k", "K"},
321		},
322		-- boot options
323		{
324			entry_type = core.MENU_SUBMENU,
325			name = "Boot " .. color.highlight("O") .. "ptions",
326			submenu = menu.boot_options,
327			alias = {"o", "O"},
328		},
329		-- boot environments
330		{
331			entry_type = core.MENU_SUBMENU,
332			visible = function()
333				return core.isZFSBoot() and
334				    #core.bootenvList() > 1
335			end,
336			name = "Boot " .. color.highlight("E") .. "nvironments",
337			submenu = menu.boot_environments,
338			alias = {"e", "E"},
339		},
340		-- chainload
341		{
342			entry_type = core.MENU_ENTRY,
343			name = function()
344				return 'Chain' .. color.highlight("L") ..
345				    "oad " .. loader.getenv('chain_disk')
346			end,
347			func = function()
348				loader.perform("chain " ..
349				    loader.getenv('chain_disk'))
350			end,
351			visible = function()
352				return loader.getenv('chain_disk') ~= nil
353			end,
354			alias = {"l", "L"},
355		},
356	},
357}
358
359menu.default = menu.welcome
360-- current_alias_table will be used to keep our alias table consistent across
361-- screen redraws, instead of relying on whatever triggered the redraw to update
362-- the local alias_table in menu.process.
363menu.current_alias_table = {}
364
365function menu.draw(menudef)
366	-- Clear the screen, reset the cursor, then draw
367	screen.clear()
368	menu.current_alias_table = drawer.drawscreen(menudef)
369	drawn_menu = menudef
370	screen.defcursor()
371end
372
373-- 'keypress' allows the caller to indicate that a key has been pressed that we
374-- should process as our initial input.
375function menu.process(menudef, keypress)
376	assert(menudef ~= nil)
377
378	if drawn_menu ~= menudef then
379		menu.draw(menudef)
380	end
381
382	while true do
383		local key = keypress or io.getchar()
384		keypress = nil
385
386		-- Special key behaviors
387		if (key == core.KEY_BACKSPACE or key == core.KEY_DELETE) and
388		    menudef ~= menu.default then
389			break
390		elseif key == core.KEY_ENTER then
391			core.boot()
392			-- Should not return.  If it does, escape menu handling
393			-- and drop to loader prompt.
394			return false
395		end
396
397		key = string.char(key)
398		-- check to see if key is an alias
399		local sel_entry = nil
400		for k, v in pairs(menu.current_alias_table) do
401			if key == k then
402				sel_entry = v
403				break
404			end
405		end
406
407		-- if we have an alias do the assigned action:
408		if sel_entry ~= nil then
409			local handler = menu.handlers[sel_entry.entry_type]
410			assert(handler ~= nil)
411			-- The handler's return value indicates if we
412			-- need to exit this menu.  An omitted or true
413			-- return value means to continue.
414			if handler(menudef, sel_entry) == false then
415				return
416			end
417			-- If we got an alias key the screen is out of date...
418			-- redraw it.
419			menu.draw(menudef)
420		end
421	end
422end
423
424function menu.run()
425	local autoboot_key
426	local delay = loader.getenv("autoboot_delay")
427
428	if delay ~= nil and delay:lower() == "no" then
429		delay = nil
430	else
431		delay = tonumber(delay) or 10
432	end
433
434	if delay == -1 then
435		core.boot()
436		return
437	end
438
439	menu.draw(menu.default)
440
441	if delay ~= nil then
442		autoboot_key = menu.autoboot(delay)
443
444		-- autoboot_key should return the key pressed.  It will only
445		-- return nil if we hit the timeout and executed the timeout
446		-- command.  Bail out.
447		if autoboot_key == nil then
448			return
449		end
450	end
451
452	menu.process(menu.default, autoboot_key)
453	drawn_menu = nil
454
455	screen.defcursor()
456	print("Exiting menu!")
457end
458
459function menu.autoboot(delay)
460	local x = loader.getenv("loader_menu_timeout_x") or 4
461	local y = loader.getenv("loader_menu_timeout_y") or 23
462	local endtime = loader.time() + delay
463	local time
464	local last
465	repeat
466		time = endtime - loader.time()
467		if last == nil or last ~= time then
468			last = time
469			screen.setcursor(x, y)
470			print("Autoboot in " .. time ..
471			    " seconds, hit [Enter] to boot" ..
472			    " or any other key to stop     ")
473			screen.defcursor()
474		end
475		if io.ischar() then
476			local ch = io.getchar()
477			if ch == core.KEY_ENTER then
478				break
479			else
480				-- erase autoboot msg
481				screen.setcursor(0, y)
482				print(string.rep(" ", 80))
483				screen.defcursor()
484				return ch
485			end
486		end
487
488		loader.delay(50000)
489	until time <= 0
490
491	local cmd = loader.getenv("menu_timeout_command") or "boot"
492	cli_execute_unparsed(cmd)
493	return nil
494end
495
496-- CLI commands
497function cli.menu()
498	menu.run()
499end
500
501return menu
502