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