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