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