xref: /freebsd/stand/lua/menu.lua (revision 716fd348e01c5f2ba125f878a634a753436c2994)
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			visible = function()
136				return core.isRewinded() == false
137			end,
138			name = function()
139				return color.highlight("b") .. "ootfs: " ..
140				    core.bootenvDefault()
141			end,
142			func = function()
143				-- Reset active boot environment to the default
144				config.setCarouselIndex("be_active", 1)
145				bootenvSet(core.bootenvDefault())
146			end,
147			alias = {"b", "B"},
148		},
149	},
150}
151
152menu.boot_options = {
153	entries = {
154		-- return to welcome menu
155		return_menu_entry,
156		-- load defaults
157		{
158			entry_type = core.MENU_ENTRY,
159			name = "Load System " .. color.highlight("D") ..
160			    "efaults",
161			func = core.setDefaults,
162			alias = {"d", "D"},
163		},
164		{
165			entry_type = core.MENU_SEPARATOR,
166		},
167		{
168			entry_type = core.MENU_SEPARATOR,
169			name = "Boot Options:",
170		},
171		-- acpi
172		{
173			entry_type = core.MENU_ENTRY,
174			visible = core.isSystem386,
175			name = function()
176				return OnOff(color.highlight("A") ..
177				    "CPI       :", core.acpi)
178			end,
179			func = core.setACPI,
180			alias = {"a", "A"},
181		},
182		-- safe mode
183		{
184			entry_type = core.MENU_ENTRY,
185			name = function()
186				return OnOff("Safe " .. color.highlight("M") ..
187				    "ode  :", core.sm)
188			end,
189			func = core.setSafeMode,
190			alias = {"m", "M"},
191		},
192		-- single user
193		{
194			entry_type = core.MENU_ENTRY,
195			name = function()
196				return OnOff(color.highlight("S") ..
197				    "ingle user:", core.su)
198			end,
199			func = core.setSingleUser,
200			alias = {"s", "S"},
201		},
202		-- verbose boot
203		{
204			entry_type = core.MENU_ENTRY,
205			name = function()
206				return OnOff(color.highlight("V") ..
207				    "erbose    :", core.verbose)
208			end,
209			func = core.setVerbose,
210			alias = {"v", "V"},
211		},
212	},
213}
214
215menu.welcome = {
216	entries = function()
217		local menu_entries = menu.welcome.all_entries
218		local multi_user = menu_entries.multi_user
219		local single_user = menu_entries.single_user
220		local boot_entry_1, boot_entry_2
221		if core.isSingleUserBoot() then
222			-- Swap the first two menu items on single user boot.
223			-- We'll cache the alternate entries for performance.
224			local alts = menu_entries.alts
225			if alts == nil then
226				single_user = core.deepCopyTable(single_user)
227				multi_user = core.deepCopyTable(multi_user)
228				single_user.name = single_user.alternate_name
229				multi_user.name = multi_user.alternate_name
230				menu_entries.alts = {
231					single_user = single_user,
232					multi_user = multi_user,
233				}
234			else
235				single_user = alts.single_user
236				multi_user = alts.multi_user
237			end
238			boot_entry_1, boot_entry_2 = single_user, multi_user
239		else
240			boot_entry_1, boot_entry_2 = multi_user, single_user
241		end
242		return {
243			boot_entry_1,
244			boot_entry_2,
245			menu_entries.prompt,
246			menu_entries.reboot,
247			menu_entries.console,
248			{
249				entry_type = core.MENU_SEPARATOR,
250			},
251			{
252				entry_type = core.MENU_SEPARATOR,
253				name = "Options:",
254			},
255			menu_entries.kernel_options,
256			menu_entries.boot_options,
257			menu_entries.zpool_checkpoints,
258			menu_entries.boot_envs,
259			menu_entries.chainload,
260			menu_entries.vendor,
261		}
262	end,
263	all_entries = {
264		multi_user = {
265			entry_type = core.MENU_ENTRY,
266			name = color.highlight("B") .. "oot Multi user " ..
267			    color.highlight("[Enter]"),
268			-- Not a standard menu entry function!
269			alternate_name = color.highlight("B") ..
270			    "oot Multi user",
271			func = function()
272				core.setSingleUser(false)
273				core.boot()
274			end,
275			alias = {"b", "B"},
276		},
277		single_user = {
278			entry_type = core.MENU_ENTRY,
279			name = "Boot " .. color.highlight("S") .. "ingle user",
280			-- Not a standard menu entry function!
281			alternate_name = "Boot " .. color.highlight("S") ..
282			    "ingle user " .. color.highlight("[Enter]"),
283			func = function()
284				core.setSingleUser(true)
285				core.boot()
286			end,
287			alias = {"s", "S"},
288		},
289		console = {
290			entry_type = core.MENU_ENTRY,
291			name = function()
292				return color.highlight("C") .. "ons: " .. core.getConsoleName()
293			end,
294			func = function()
295				core.nextConsoleChoice()
296			end,
297			alias = {"c", "C"},
298		},
299		prompt = {
300			entry_type = core.MENU_RETURN,
301			name = color.highlight("Esc") .. "ape to loader prompt",
302			func = function()
303				loader.setenv("autoboot_delay", "NO")
304			end,
305			alias = {core.KEYSTR_ESCAPE},
306		},
307		reboot = {
308			entry_type = core.MENU_ENTRY,
309			name = color.highlight("R") .. "eboot",
310			func = function()
311				loader.perform("reboot")
312			end,
313			alias = {"r", "R"},
314		},
315		kernel_options = {
316			entry_type = core.MENU_CAROUSEL_ENTRY,
317			carousel_id = "kernel",
318			items = core.kernelList,
319			name = function(idx, choice, all_choices)
320				if #all_choices == 0 then
321					return "Kernel: "
322				end
323
324				local is_default = (idx == 1)
325				local kernel_name = ""
326				local name_color
327				if is_default then
328					name_color = color.escapefg(color.GREEN)
329					kernel_name = "default/"
330				else
331					name_color = color.escapefg(color.BLUE)
332				end
333				kernel_name = kernel_name .. name_color ..
334				    choice .. color.resetfg()
335				return color.highlight("K") .. "ernel: " ..
336				    kernel_name .. " (" .. idx .. " of " ..
337				    #all_choices .. ")"
338			end,
339			func = function(_, choice, _)
340				if loader.getenv("kernelname") ~= nil then
341					loader.perform("unload")
342				end
343				config.selectKernel(choice)
344			end,
345			alias = {"k", "K"},
346		},
347		boot_options = {
348			entry_type = core.MENU_SUBMENU,
349			name = "Boot " .. color.highlight("O") .. "ptions",
350			submenu = menu.boot_options,
351			alias = {"o", "O"},
352		},
353		zpool_checkpoints = {
354			entry_type = core.MENU_ENTRY,
355			name = function()
356				local rewind = "No"
357				if core.isRewinded() then
358					rewind = "Yes"
359				end
360				return "Rewind ZFS " .. color.highlight("C") ..
361					"heckpoint: " .. rewind
362			end,
363			func = function()
364				core.changeRewindCheckpoint()
365				if core.isRewinded() then
366					bootenvSet(
367					    core.bootenvDefaultRewinded())
368				else
369					bootenvSet(core.bootenvDefault())
370				end
371				config.setCarouselIndex("be_active", 1)
372			end,
373			visible = function()
374				return core.isZFSBoot() and
375				    core.isCheckpointed()
376			end,
377			alias = {"c", "C"},
378		},
379		boot_envs = {
380			entry_type = core.MENU_SUBMENU,
381			visible = function()
382				return core.isZFSBoot() and
383				    #core.bootenvList() > 1
384			end,
385			name = "Boot " .. color.highlight("E") .. "nvironments",
386			submenu = menu.boot_environments,
387			alias = {"e", "E"},
388		},
389		chainload = {
390			entry_type = core.MENU_ENTRY,
391			name = function()
392				return 'Chain' .. color.highlight("L") ..
393				    "oad " .. loader.getenv('chain_disk')
394			end,
395			func = function()
396				loader.perform("chain " ..
397				    loader.getenv('chain_disk'))
398			end,
399			visible = function()
400				return loader.getenv('chain_disk') ~= nil
401			end,
402			alias = {"l", "L"},
403		},
404		vendor = {
405			entry_type = core.MENU_ENTRY,
406			visible = function()
407				return false
408			end
409		},
410	},
411}
412
413menu.default = menu.welcome
414-- current_alias_table will be used to keep our alias table consistent across
415-- screen redraws, instead of relying on whatever triggered the redraw to update
416-- the local alias_table in menu.process.
417menu.current_alias_table = {}
418
419function menu.draw(menudef)
420	-- Clear the screen, reset the cursor, then draw
421	screen.clear()
422	menu.current_alias_table = drawer.drawscreen(menudef)
423	drawn_menu = menudef
424	screen.defcursor()
425end
426
427-- 'keypress' allows the caller to indicate that a key has been pressed that we
428-- should process as our initial input.
429function menu.process(menudef, keypress)
430	assert(menudef ~= nil)
431
432	if drawn_menu ~= menudef then
433		menu.draw(menudef)
434	end
435
436	while true do
437		local key = keypress or io.getchar()
438		keypress = nil
439
440		-- Special key behaviors
441		if (key == core.KEY_BACKSPACE or key == core.KEY_DELETE) and
442		    menudef ~= menu.default then
443			break
444		elseif key == core.KEY_ENTER then
445			core.boot()
446			-- Should not return.  If it does, escape menu handling
447			-- and drop to loader prompt.
448			return false
449		end
450
451		key = string.char(key)
452		-- check to see if key is an alias
453		local sel_entry = nil
454		for k, v in pairs(menu.current_alias_table) do
455			if key == k then
456				sel_entry = v
457				break
458			end
459		end
460
461		-- if we have an alias do the assigned action:
462		if sel_entry ~= nil then
463			local handler = menu.handlers[sel_entry.entry_type]
464			assert(handler ~= nil)
465			-- The handler's return value indicates if we
466			-- need to exit this menu.  An omitted or true
467			-- return value means to continue.
468			if handler(menudef, sel_entry) == false then
469				return
470			end
471			-- If we got an alias key the screen is out of date...
472			-- redraw it.
473			menu.draw(menudef)
474		end
475	end
476end
477
478function menu.run()
479	local autoboot_key
480	local delay = loader.getenv("autoboot_delay")
481
482	if delay ~= nil and delay:lower() == "no" then
483		delay = nil
484	else
485		delay = tonumber(delay) or 10
486	end
487
488	if delay == -1 then
489		core.boot()
490		return
491	end
492
493	menu.draw(menu.default)
494
495	if delay ~= nil then
496		autoboot_key = menu.autoboot(delay)
497
498		-- autoboot_key should return the key pressed.  It will only
499		-- return nil if we hit the timeout and executed the timeout
500		-- command.  Bail out.
501		if autoboot_key == nil then
502			return
503		end
504	end
505
506	menu.process(menu.default, autoboot_key)
507	drawn_menu = nil
508
509	screen.defcursor()
510	print("Exiting menu!")
511end
512
513function menu.autoboot(delay)
514	local x = loader.getenv("loader_menu_timeout_x") or 4
515	local y = loader.getenv("loader_menu_timeout_y") or 23
516	local endtime = loader.time() + delay
517	local time
518	local last
519	repeat
520		time = endtime - loader.time()
521		if last == nil or last ~= time then
522			last = time
523			screen.setcursor(x, y)
524			print("Autoboot in " .. time ..
525			    " seconds. [Space] to pause ")
526			screen.defcursor()
527		end
528		if io.ischar() then
529			local ch = io.getchar()
530			if ch == core.KEY_ENTER then
531				break
532			else
533				-- erase autoboot msg
534				screen.setcursor(0, y)
535				print(string.rep(" ", 80))
536				screen.defcursor()
537				return ch
538			end
539		end
540
541		loader.delay(50000)
542	until time <= 0
543
544	local cmd = loader.getenv("menu_timeout_command") or "boot"
545	cli_execute_unparsed(cmd)
546	return nil
547end
548
549-- CLI commands
550function cli.menu()
551	menu.run()
552end
553
554return menu
555