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