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