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