xref: /freebsd/stand/lua/menu.lua (revision 3fb656f8ef21332d96de8097521aaa51ddeb649d)
1--
2-- SPDX-License-Identifier: BSD-2-Clause
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
30local cli = require("cli")
31local core = require("core")
32local color = require("color")
33local config = require("config")
34local screen = require("screen")
35local drawer = require("drawer")
36
37local menu = {}
38
39local drawn_menu
40local return_menu_entry = {
41	entry_type = core.MENU_RETURN,
42	name = "Back to main menu" .. color.highlight(" [Backspace]"),
43}
44
45local function OnOff(str, value)
46	if value then
47		return str .. color.escapefg(color.GREEN) .. "On" ..
48		    color.resetfg()
49	else
50		return str .. color.escapefg(color.RED) .. "off" ..
51		    color.resetfg()
52	end
53end
54
55local function bootenvSet(env)
56	loader.setenv("vfs.root.mountfrom", env)
57	loader.setenv("currdev", env .. ":")
58	config.reload()
59	if loader.getenv("kernelname") ~= nil then
60		loader.perform("unload")
61	end
62end
63
64local function multiUserPrompt()
65	return loader.getenv("loader_menu_multi_user_prompt") or "Multi user"
66end
67
68-- Module exports
69menu.handlers = {
70	-- Menu handlers take the current menu and selected entry as parameters,
71	-- and should return a boolean indicating whether execution should
72	-- continue or not. The return value may be omitted if this entry should
73	-- have no bearing on whether we continue or not, indicating that we
74	-- should just continue after execution.
75	[core.MENU_ENTRY] = function(_, entry)
76		-- run function
77		entry.func()
78	end,
79	[core.MENU_CAROUSEL_ENTRY] = function(_, entry)
80		-- carousel (rotating) functionality
81		local carid = entry.carousel_id
82		local caridx = config.getCarouselIndex(carid)
83		local choices = entry.items
84		if type(choices) == "function" then
85			choices = choices()
86		end
87		if #choices > 0 then
88			caridx = (caridx % #choices) + 1
89			config.setCarouselIndex(carid, caridx)
90			entry.func(caridx, choices[caridx], choices)
91		end
92	end,
93	[core.MENU_SUBMENU] = function(_, entry)
94		menu.process(entry.submenu)
95	end,
96	[core.MENU_RETURN] = function(_, entry)
97		-- allow entry to have a function/side effect
98		if entry.func ~= nil then
99			entry.func()
100		end
101		return false
102	end,
103}
104-- loader menu tree is rooted at menu.welcome
105
106menu.boot_environments = {
107	entries = {
108		-- return to welcome menu
109		return_menu_entry,
110		{
111			entry_type = core.MENU_CAROUSEL_ENTRY,
112			carousel_id = "be_active",
113			items = core.bootenvList,
114			name = function(idx, choice, all_choices)
115				if #all_choices == 0 then
116					return "Active: "
117				end
118
119				local is_default = (idx == 1)
120				local bootenv_name = ""
121				local name_color
122				if is_default then
123					name_color = color.escapefg(color.GREEN)
124				else
125					name_color = color.escapefg(color.BLUE)
126				end
127				bootenv_name = bootenv_name .. name_color ..
128				    choice .. color.resetfg()
129				return color.highlight("A").."ctive: " ..
130				    bootenv_name .. " (" .. idx .. " of " ..
131				    #all_choices .. ")"
132			end,
133			func = function(_, choice, _)
134				bootenvSet(choice)
135			end,
136			alias = {"a", "A"},
137		},
138		{
139			entry_type = core.MENU_ENTRY,
140			visible = function()
141				return core.isRewinded() == false
142			end,
143			name = function()
144				return color.highlight("b") .. "ootfs: " ..
145				    core.bootenvDefault()
146			end,
147			func = function()
148				-- Reset active boot environment to the default
149				config.setCarouselIndex("be_active", 1)
150				bootenvSet(core.bootenvDefault())
151			end,
152			alias = {"b", "B"},
153		},
154	},
155}
156
157menu.boot_options = {
158	entries = {
159		-- return to welcome menu
160		return_menu_entry,
161		-- load defaults
162		{
163			entry_type = core.MENU_ENTRY,
164			name = "Load System " .. color.highlight("D") ..
165			    "efaults",
166			func = core.setDefaults,
167			alias = {"d", "D"},
168		},
169		{
170			entry_type = core.MENU_SEPARATOR,
171		},
172		{
173			entry_type = core.MENU_SEPARATOR,
174			name = "Boot Options:",
175		},
176		-- acpi
177		{
178			entry_type = core.MENU_ENTRY,
179			visible = core.hasACPI,
180			name = function()
181				return OnOff(color.highlight("A") ..
182				    "CPI       :", core.acpi)
183			end,
184			func = core.setACPI,
185			alias = {"a", "A"},
186		},
187		-- safe mode
188		{
189			entry_type = core.MENU_ENTRY,
190			name = function()
191				return OnOff("Safe " .. color.highlight("M") ..
192				    "ode  :", core.sm)
193			end,
194			func = core.setSafeMode,
195			alias = {"m", "M"},
196		},
197		-- single user
198		{
199			entry_type = core.MENU_ENTRY,
200			name = function()
201				return OnOff(color.highlight("S") ..
202				    "ingle user:", core.su)
203			end,
204			func = core.setSingleUser,
205			alias = {"s", "S"},
206		},
207		-- verbose boot
208		{
209			entry_type = core.MENU_ENTRY,
210			name = function()
211				return OnOff(color.highlight("V") ..
212				    "erbose    :", core.verbose)
213			end,
214			func = core.setVerbose,
215			alias = {"v", "V"},
216		},
217	},
218}
219
220menu.welcome = {
221	entries = function()
222		local menu_entries = menu.welcome.all_entries
223		local multi_user = menu_entries.multi_user
224		local single_user = menu_entries.single_user
225		local boot_entry_1, boot_entry_2
226		if core.isSingleUserBoot() then
227			-- Swap the first two menu items on single user boot.
228			-- We'll cache the alternate entries for performance.
229			local alts = menu_entries.alts
230			if alts == nil then
231				single_user = core.deepCopyTable(single_user)
232				multi_user = core.deepCopyTable(multi_user)
233				single_user.name = single_user.alternate_name
234				multi_user.name = multi_user.alternate_name
235				menu_entries.alts = {
236					single_user = single_user,
237					multi_user = multi_user,
238				}
239			else
240				single_user = alts.single_user
241				multi_user = alts.multi_user
242			end
243			boot_entry_1, boot_entry_2 = single_user, multi_user
244		else
245			boot_entry_1, boot_entry_2 = multi_user, single_user
246		end
247		return {
248			boot_entry_1,
249			boot_entry_2,
250			menu_entries.prompt,
251			menu_entries.reboot,
252			menu_entries.console,
253			{
254				entry_type = core.MENU_SEPARATOR,
255			},
256			{
257				entry_type = core.MENU_SEPARATOR,
258				name = "Options:",
259			},
260			menu_entries.kernel_options,
261			menu_entries.boot_options,
262			menu_entries.zpool_checkpoints,
263			menu_entries.boot_envs,
264			menu_entries.chainload,
265			menu_entries.vendor,
266			{
267				entry_type = core.MENU_SEPARATOR,
268			},
269			menu_entries.loader_needs_upgrade,
270		}
271	end,
272	all_entries = {
273		multi_user = {
274			entry_type = core.MENU_ENTRY,
275			name = function()
276				return color.highlight("B") .. "oot " ..
277				    multiUserPrompt() .. " " ..
278				    color.highlight("[Enter]")
279			end,
280			-- Not a standard menu entry function!
281			alternate_name = function()
282				return color.highlight("B") .. "oot " ..
283				    multiUserPrompt()
284			end,
285			func = function()
286				core.setSingleUser(false)
287				core.boot()
288			end,
289			alias = {"b", "B"},
290		},
291		single_user = {
292			entry_type = core.MENU_ENTRY,
293			name = "Boot " .. color.highlight("S") .. "ingle user",
294			-- Not a standard menu entry function!
295			alternate_name = "Boot " .. color.highlight("S") ..
296			    "ingle user " .. color.highlight("[Enter]"),
297			func = function()
298				core.setSingleUser(true)
299				core.boot()
300			end,
301			alias = {"s", "S"},
302		},
303		console = {
304			entry_type = core.MENU_ENTRY,
305			name = function()
306				return color.highlight("C") .. "ons: " .. core.getConsoleName()
307			end,
308			func = function()
309				core.nextConsoleChoice()
310			end,
311			alias = {"c", "C"},
312		},
313		prompt = {
314			entry_type = core.MENU_RETURN,
315			name = color.highlight("Esc") .. "ape to loader prompt",
316			func = function()
317				loader.setenv("autoboot_delay", "NO")
318			end,
319			alias = {core.KEYSTR_ESCAPE},
320		},
321		reboot = {
322			entry_type = core.MENU_ENTRY,
323			name = color.highlight("R") .. "eboot",
324			func = function()
325				loader.perform("reboot")
326			end,
327			alias = {"r", "R"},
328		},
329		kernel_options = {
330			entry_type = core.MENU_CAROUSEL_ENTRY,
331			carousel_id = "kernel",
332			items = core.kernelList,
333			name = function(idx, choice, all_choices)
334				if #all_choices == 0 then
335					return "Kernel: "
336				end
337
338				local is_default = (idx == 1)
339				local kernel_name = ""
340				local name_color
341				if is_default then
342					name_color = color.escapefg(color.GREEN)
343					kernel_name = "default/"
344				else
345					name_color = color.escapefg(color.BLUE)
346				end
347				kernel_name = kernel_name .. name_color ..
348				    choice .. color.resetfg()
349				return color.highlight("K") .. "ernel: " ..
350				    kernel_name .. " (" .. idx .. " of " ..
351				    #all_choices .. ")"
352			end,
353			func = function(_, choice, _)
354				if loader.getenv("kernelname") ~= nil then
355					loader.perform("unload")
356				end
357				config.selectKernel(choice)
358			end,
359			alias = {"k", "K"},
360		},
361		boot_options = {
362			entry_type = core.MENU_SUBMENU,
363			name = "Boot " .. color.highlight("O") .. "ptions",
364			submenu = menu.boot_options,
365			alias = {"o", "O"},
366		},
367		zpool_checkpoints = {
368			entry_type = core.MENU_ENTRY,
369			name = function()
370				local rewind = "No"
371				if core.isRewinded() then
372					rewind = "Yes"
373				end
374				return "Rewind ZFS " .. color.highlight("C") ..
375					"heckpoint: " .. rewind
376			end,
377			func = function()
378				core.changeRewindCheckpoint()
379				if core.isRewinded() then
380					bootenvSet(
381					    core.bootenvDefaultRewinded())
382				else
383					bootenvSet(core.bootenvDefault())
384				end
385				config.setCarouselIndex("be_active", 1)
386			end,
387			visible = function()
388				return core.isZFSBoot() and
389				    core.isCheckpointed()
390			end,
391			alias = {"c", "C"},
392		},
393		boot_envs = {
394			entry_type = core.MENU_SUBMENU,
395			visible = function()
396				return core.isZFSBoot() and
397				    #core.bootenvList() > 1
398			end,
399			name = "Boot " .. color.highlight("E") .. "nvironments",
400			submenu = menu.boot_environments,
401			alias = {"e", "E"},
402		},
403		chainload = {
404			entry_type = core.MENU_ENTRY,
405			name = function()
406				return 'Chain' .. color.highlight("L") ..
407				    "oad " .. loader.getenv('chain_disk')
408			end,
409			func = function()
410				loader.perform("chain " ..
411				    loader.getenv('chain_disk'))
412			end,
413			visible = function()
414				return loader.getenv('chain_disk') ~= nil
415			end,
416			alias = {"l", "L"},
417		},
418		loader_needs_upgrade = {
419			entry_type = core.MENU_SEPARATOR,
420			name = function()
421				return color.highlight("Loader needs to be updated")
422			end,
423			visible = function()
424				return core.loaderTooOld()
425			end
426		},
427		vendor = {
428			entry_type = core.MENU_ENTRY,
429			visible = function()
430				return false
431			end
432		},
433	},
434}
435
436menu.default = menu.welcome
437-- current_alias_table will be used to keep our alias table consistent across
438-- screen redraws, instead of relying on whatever triggered the redraw to update
439-- the local alias_table in menu.process.
440menu.current_alias_table = {}
441
442function menu.draw(menudef)
443	-- Clear the screen, reset the cursor, then draw
444	screen.clear()
445	menu.current_alias_table = drawer.drawscreen(menudef)
446	drawn_menu = menudef
447	screen.defcursor()
448end
449
450-- 'keypress' allows the caller to indicate that a key has been pressed that we
451-- should process as our initial input.
452function menu.process(menudef, keypress)
453	assert(menudef ~= nil)
454
455	if drawn_menu ~= menudef then
456		menu.draw(menudef)
457	end
458
459	while true do
460		local key = keypress or io.getchar()
461		keypress = nil
462
463		-- Special key behaviors
464		if (key == core.KEY_BACKSPACE or key == core.KEY_DELETE) and
465		    menudef ~= menu.default then
466			break
467		elseif key == core.KEY_ENTER then
468			core.boot()
469			-- Should not return.  If it does, escape menu handling
470			-- and drop to loader prompt.
471			return false
472		end
473
474		key = string.char(key)
475		-- check to see if key is an alias
476		local sel_entry = nil
477		for k, v in pairs(menu.current_alias_table) do
478			if key == k then
479				sel_entry = v
480				break
481			end
482		end
483
484		-- if we have an alias do the assigned action:
485		if sel_entry ~= nil then
486			local handler = menu.handlers[sel_entry.entry_type]
487			assert(handler ~= nil)
488			-- The handler's return value indicates if we
489			-- need to exit this menu.  An omitted or true
490			-- return value means to continue.
491			if handler(menudef, sel_entry) == false then
492				return
493			end
494			-- If we got an alias key the screen is out of date...
495			-- redraw it.
496			menu.draw(menudef)
497		end
498	end
499end
500
501function menu.run()
502	local autoboot_key
503	local delay = loader.getenv("autoboot_delay")
504
505	if delay ~= nil and delay:lower() == "no" then
506		delay = nil
507	else
508		delay = tonumber(delay) or 10
509	end
510
511	if delay == -1 then
512		core.boot()
513		return
514	end
515
516	menu.draw(menu.default)
517
518	if delay ~= nil then
519		autoboot_key = menu.autoboot(delay)
520
521		-- autoboot_key should return the key pressed.  It will only
522		-- return nil if we hit the timeout and executed the timeout
523		-- command.  Bail out.
524		if autoboot_key == nil then
525			return
526		end
527	end
528
529	menu.process(menu.default, autoboot_key)
530	drawn_menu = nil
531
532	screen.defcursor()
533	-- We explicitly want the newline print adds
534	print("Exiting menu!")
535end
536
537function menu.autoboot(delay)
538	local x = loader.getenv("loader_menu_timeout_x") or 4
539	local y = loader.getenv("loader_menu_timeout_y") or 24
540	local endtime = loader.time() + delay
541	local time
542	local last
543	repeat
544		time = endtime - loader.time()
545		if last == nil or last ~= time then
546			last = time
547			screen.setcursor(x, y)
548			printc("Autoboot in " .. time ..
549			    " seconds. [Space] to pause ")
550			screen.defcursor()
551		end
552		if io.ischar() then
553			local ch = io.getchar()
554			if ch == core.KEY_ENTER then
555				break
556			else
557				-- Erase autoboot msg.  While real VT100s
558				-- wouldn't scroll when receiving a char with
559				-- the cursor at (24, 79), bad emulators do.
560				-- Avoid the issue by stopping at 79.
561				screen.setcursor(0, y)
562				printc(string.rep(" ", 79))
563				screen.defcursor()
564				return ch
565			end
566		end
567
568		loader.delay(50000)
569	until time <= 0
570
571	local cmd = loader.getenv("menu_timeout_command") or "boot"
572	cli_execute_unparsed(cmd)
573	return nil
574end
575
576-- CLI commands
577function cli.menu()
578	menu.run()
579end
580
581return menu
582