xref: /freebsd/stand/lua/menu.lua (revision dd21556857e8d40f66bf5ad54754d9d52669ebf7)
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.CYAN)
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 = "Kernel:",
259			},
260			menu_entries.kernel_options,
261			{
262				entry_type = core.MENU_SEPARATOR,
263			},
264			{
265				entry_type = core.MENU_SEPARATOR,
266				name = "Options:",
267			},
268			menu_entries.boot_options,
269			menu_entries.zpool_checkpoints,
270			menu_entries.boot_envs,
271			menu_entries.chainload,
272			menu_entries.vendor,
273			{
274				entry_type = core.MENU_SEPARATOR,
275			},
276			menu_entries.loader_needs_upgrade,
277		}
278	end,
279	all_entries = {
280		multi_user = {
281			entry_type = core.MENU_ENTRY,
282			name = function()
283				return color.highlight("B") .. "oot " ..
284				    multiUserPrompt() .. " " ..
285				    color.highlight("[Enter]")
286			end,
287			-- Not a standard menu entry function!
288			alternate_name = function()
289				return color.highlight("B") .. "oot " ..
290				    multiUserPrompt()
291			end,
292			func = function()
293				core.setSingleUser(false)
294				core.boot()
295			end,
296			alias = {"b", "B"},
297		},
298		single_user = {
299			entry_type = core.MENU_ENTRY,
300			name = "Boot " .. color.highlight("S") .. "ingle user",
301			-- Not a standard menu entry function!
302			alternate_name = "Boot " .. color.highlight("S") ..
303			    "ingle user " .. color.highlight("[Enter]"),
304			func = function()
305				core.setSingleUser(true)
306				core.boot()
307			end,
308			alias = {"s", "S"},
309		},
310		console = {
311			entry_type = core.MENU_ENTRY,
312			name = function()
313				return color.highlight("C") .. "ons: " .. core.getConsoleName()
314			end,
315			func = function()
316				core.nextConsoleChoice()
317			end,
318			alias = {"c", "C"},
319		},
320		prompt = {
321			entry_type = core.MENU_RETURN,
322			name = color.highlight("Esc") .. "ape to loader prompt",
323			func = function()
324				loader.setenv("autoboot_delay", "NO")
325			end,
326			alias = {core.KEYSTR_ESCAPE},
327		},
328		reboot = {
329			entry_type = core.MENU_ENTRY,
330			name = color.highlight("R") .. "eboot",
331			func = function()
332				loader.perform("reboot")
333			end,
334			alias = {"r", "R"},
335		},
336		kernel_options = {
337			entry_type = core.MENU_CAROUSEL_ENTRY,
338			carousel_id = "kernel",
339			items = core.kernelList,
340			name = function(idx, choice, all_choices)
341				if #all_choices == 0 then
342					return ""
343				end
344
345				local kernel_name
346				local name_color
347				if idx == 1 then
348					name_color = color.escapefg(color.GREEN)
349				else
350					name_color = color.escapefg(color.CYAN)
351				end
352				kernel_name = name_color .. choice ..
353				    color.resetfg()
354				return kernel_name .. " (" .. idx .. " of " ..
355				    #all_choices .. ")"
356			end,
357			func = function(_, choice, _)
358				if loader.getenv("kernelname") ~= nil then
359					loader.perform("unload")
360				end
361				config.selectKernel(choice)
362			end,
363			alias = {"k", "K"},
364		},
365		boot_options = {
366			entry_type = core.MENU_SUBMENU,
367			name = "Boot " .. color.highlight("O") .. "ptions",
368			submenu = menu.boot_options,
369			alias = {"o", "O"},
370		},
371		zpool_checkpoints = {
372			entry_type = core.MENU_ENTRY,
373			name = function()
374				local rewind = "No"
375				if core.isRewinded() then
376					rewind = "Yes"
377				end
378				return "Rewind ZFS " .. color.highlight("C") ..
379					"heckpoint: " .. rewind
380			end,
381			func = function()
382				core.changeRewindCheckpoint()
383				if core.isRewinded() then
384					bootenvSet(
385					    core.bootenvDefaultRewinded())
386				else
387					bootenvSet(core.bootenvDefault())
388				end
389				config.setCarouselIndex("be_active", 1)
390			end,
391			visible = function()
392				return core.isZFSBoot() and
393				    core.isCheckpointed()
394			end,
395			alias = {"c", "C"},
396		},
397		boot_envs = {
398			entry_type = core.MENU_SUBMENU,
399			visible = function()
400				return core.isZFSBoot() and
401				    #core.bootenvList() > 1
402			end,
403			name = "Boot " .. color.highlight("E") .. "nvironments",
404			submenu = menu.boot_environments,
405			alias = {"e", "E"},
406		},
407		chainload = {
408			entry_type = core.MENU_ENTRY,
409			name = function()
410				return 'Chain' .. color.highlight("L") ..
411				    "oad " .. loader.getenv('chain_disk')
412			end,
413			func = function()
414				loader.perform("chain " ..
415				    loader.getenv('chain_disk'))
416			end,
417			visible = function()
418				return loader.getenv('chain_disk') ~= nil
419			end,
420			alias = {"l", "L"},
421		},
422		loader_needs_upgrade = {
423			entry_type = core.MENU_SEPARATOR,
424			name = function()
425				return color.highlight("Loader needs to be updated")
426			end,
427			visible = function()
428				return core.loaderTooOld()
429			end
430		},
431		vendor = {
432			entry_type = core.MENU_ENTRY,
433			visible = function()
434				return false
435			end
436		},
437	},
438}
439
440menu.default = menu.welcome
441-- current_alias_table will be used to keep our alias table consistent across
442-- screen redraws, instead of relying on whatever triggered the redraw to update
443-- the local alias_table in menu.process.
444menu.current_alias_table = {}
445
446function menu.draw(menudef)
447	-- Clear the screen, reset the cursor, then draw
448	screen.clear()
449	menu.current_alias_table = drawer.drawscreen(menudef)
450	drawn_menu = menudef
451	screen.defcursor()
452end
453
454-- 'keypress' allows the caller to indicate that a key has been pressed that we
455-- should process as our initial input.
456function menu.process(menudef, keypress)
457	assert(menudef ~= nil)
458
459	if drawn_menu ~= menudef then
460		menu.draw(menudef)
461	end
462
463	while true do
464		local key = keypress or io.getchar()
465		keypress = nil
466
467		-- Special key behaviors
468		if (key == core.KEY_BACKSPACE or key == core.KEY_DELETE) and
469		    menudef ~= menu.default then
470			break
471		elseif key == core.KEY_ENTER then
472			core.boot()
473			-- Should not return.  If it does, escape menu handling
474			-- and drop to loader prompt.
475			return false
476		end
477
478		key = string.char(key)
479		-- check to see if key is an alias
480		local sel_entry = nil
481		for k, v in pairs(menu.current_alias_table) do
482			if key == k then
483				sel_entry = v
484				break
485			end
486		end
487
488		-- if we have an alias do the assigned action:
489		if sel_entry ~= nil then
490			local handler = menu.handlers[sel_entry.entry_type]
491			assert(handler ~= nil)
492			-- The handler's return value indicates if we
493			-- need to exit this menu.  An omitted or true
494			-- return value means to continue.
495			if handler(menudef, sel_entry) == false then
496				return
497			end
498			-- If we got an alias key the screen is out of date...
499			-- redraw it.
500			menu.draw(menudef)
501		end
502	end
503end
504
505function menu.run()
506	local autoboot_key
507	local delay = loader.getenv("autoboot_delay")
508
509	if delay ~= nil and delay:lower() == "no" then
510		delay = nil
511	else
512		delay = tonumber(delay) or 10
513	end
514
515	if delay == -1 then
516		core.boot()
517		return
518	end
519
520	menu.draw(menu.default)
521
522	if delay ~= nil then
523		autoboot_key = menu.autoboot(delay)
524
525		-- autoboot_key should return the key pressed.  It will only
526		-- return nil if we hit the timeout and executed the timeout
527		-- command.  Bail out.
528		if autoboot_key == nil then
529			return
530		end
531	end
532
533	menu.process(menu.default, autoboot_key)
534	drawn_menu = nil
535
536	screen.defcursor()
537	-- We explicitly want the newline print adds
538	print("Exiting menu!")
539end
540
541function menu.autoboot(delay)
542	local x = loader.getenv("loader_menu_timeout_x") or 4
543	local y = loader.getenv("loader_menu_timeout_y") or 24
544	local endtime = loader.time() + delay
545	local time
546	local last
547	repeat
548		time = endtime - loader.time()
549		if last == nil or last ~= time then
550			last = time
551			screen.setcursor(x, y)
552			printc("Autoboot in " .. time ..
553			    " seconds. [Space] to pause ")
554			screen.defcursor()
555		end
556		if io.ischar() then
557			local ch = io.getchar()
558			if ch == core.KEY_ENTER then
559				break
560			else
561				-- Erase autoboot msg.  While real VT100s
562				-- wouldn't scroll when receiving a char with
563				-- the cursor at (79, 24), bad emulators do.
564				-- Avoid the issue by stopping at 79.
565				screen.setcursor(1, y)
566				printc(string.rep(" ", 79))
567				screen.defcursor()
568				return ch
569			end
570		end
571
572		loader.delay(50000)
573	until time <= 0
574
575	local cmd = loader.getenv("menu_timeout_command") or "boot"
576	cli_execute_unparsed(cmd)
577	return nil
578end
579
580-- CLI commands
581function cli.menu()
582	menu.run()
583end
584
585return menu
586