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: stable/12/stand/lua/menu.lua 370544 2021-09-12 05:44:02Z kevans $
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			name = function()
136				return color.highlight("b") .. "ootfs: " ..
137				    core.bootenvDefault()
138			end,
139			func = function()
140				-- Reset active boot environment to the default
141				config.setCarouselIndex("be_active", 1)
142				bootenvSet(core.bootenvDefault())
143			end,
144			alias = {"b", "B"},
145		},
146	},
147}
148
149menu.boot_options = {
150	entries = {
151		-- return to welcome menu
152		return_menu_entry,
153		-- load defaults
154		{
155			entry_type = core.MENU_ENTRY,
156			name = "Load System " .. color.highlight("D") ..
157			    "efaults",
158			func = core.setDefaults,
159			alias = {"d", "D"},
160		},
161		{
162			entry_type = core.MENU_SEPARATOR,
163		},
164		{
165			entry_type = core.MENU_SEPARATOR,
166			name = "Boot Options:",
167		},
168		-- acpi
169		{
170			entry_type = core.MENU_ENTRY,
171			visible = core.isSystem386,
172			name = function()
173				return OnOff(color.highlight("A") ..
174				    "CPI       :", core.acpi)
175			end,
176			func = core.setACPI,
177			alias = {"a", "A"},
178		},
179		-- safe mode
180		{
181			entry_type = core.MENU_ENTRY,
182			name = function()
183				return OnOff("Safe " .. color.highlight("M") ..
184				    "ode  :", core.sm)
185			end,
186			func = core.setSafeMode,
187			alias = {"m", "M"},
188		},
189		-- single user
190		{
191			entry_type = core.MENU_ENTRY,
192			name = function()
193				return OnOff(color.highlight("S") ..
194				    "ingle user:", core.su)
195			end,
196			func = core.setSingleUser,
197			alias = {"s", "S"},
198		},
199		-- verbose boot
200		{
201			entry_type = core.MENU_ENTRY,
202			name = function()
203				return OnOff(color.highlight("V") ..
204				    "erbose    :", core.verbose)
205			end,
206			func = core.setVerbose,
207			alias = {"v", "V"},
208		},
209	},
210}
211
212menu.welcome = {
213	entries = function()
214		local menu_entries = menu.welcome.all_entries
215		local multi_user = menu_entries.multi_user
216		local single_user = menu_entries.single_user
217		local boot_entry_1, boot_entry_2
218		if core.isSingleUserBoot() then
219			-- Swap the first two menu items on single user boot.
220			-- We'll cache the alternate entries for performance.
221			local alts = menu_entries.alts
222			if alts == nil then
223				single_user = core.deepCopyTable(single_user)
224				multi_user = core.deepCopyTable(multi_user)
225				single_user.name = single_user.alternate_name
226				multi_user.name = multi_user.alternate_name
227				menu_entries.alts = {
228					single_user = single_user,
229					multi_user = multi_user,
230				}
231			else
232				single_user = alts.single_user
233				multi_user = alts.multi_user
234			end
235			boot_entry_1, boot_entry_2 = single_user, multi_user
236		else
237			boot_entry_1, boot_entry_2 = multi_user, single_user
238		end
239		return {
240			boot_entry_1,
241			boot_entry_2,
242			menu_entries.prompt,
243			menu_entries.reboot,
244			menu_entries.console,
245			{
246				entry_type = core.MENU_SEPARATOR,
247			},
248			{
249				entry_type = core.MENU_SEPARATOR,
250				name = "Options:",
251			},
252			menu_entries.kernel_options,
253			menu_entries.boot_options,
254			menu_entries.boot_envs,
255			menu_entries.chainload,
256		}
257	end,
258	all_entries = {
259		multi_user = {
260			entry_type = core.MENU_ENTRY,
261			name = color.highlight("B") .. "oot Multi user " ..
262			    color.highlight("[Enter]"),
263			-- Not a standard menu entry function!
264			alternate_name = color.highlight("B") ..
265			    "oot Multi user",
266			func = function()
267				core.setSingleUser(false)
268				core.boot()
269			end,
270			alias = {"b", "B"},
271		},
272		single_user = {
273			entry_type = core.MENU_ENTRY,
274			name = "Boot " .. color.highlight("S") .. "ingle user",
275			-- Not a standard menu entry function!
276			alternate_name = "Boot " .. color.highlight("S") ..
277			    "ingle user " .. color.highlight("[Enter]"),
278			func = function()
279				core.setSingleUser(true)
280				core.boot()
281			end,
282			alias = {"s", "S"},
283		},
284		console = {
285			entry_type = core.MENU_ENTRY,
286			name = function()
287				return color.highlight("C") .. "ons: " .. core.getConsoleName()
288			end,
289			func = function()
290				core.nextConsoleChoice()
291			end,
292			alias = {"c", "C"},
293		},
294		prompt = {
295			entry_type = core.MENU_RETURN,
296			name = color.highlight("Esc") .. "ape to loader prompt",
297			func = function()
298				loader.setenv("autoboot_delay", "NO")
299			end,
300			alias = {core.KEYSTR_ESCAPE},
301		},
302		reboot = {
303			entry_type = core.MENU_ENTRY,
304			name = color.highlight("R") .. "eboot",
305			func = function()
306				loader.perform("reboot")
307			end,
308			alias = {"r", "R"},
309		},
310		kernel_options = {
311			entry_type = core.MENU_CAROUSEL_ENTRY,
312			carousel_id = "kernel",
313			items = core.kernelList,
314			name = function(idx, choice, all_choices)
315				if #all_choices == 0 then
316					return "Kernel: "
317				end
318
319				local is_default = (idx == 1)
320				local kernel_name = ""
321				local name_color
322				if is_default then
323					name_color = color.escapefg(color.GREEN)
324					kernel_name = "default/"
325				else
326					name_color = color.escapefg(color.BLUE)
327				end
328				kernel_name = kernel_name .. name_color ..
329				    choice .. color.resetfg()
330				return color.highlight("K") .. "ernel: " ..
331				    kernel_name .. " (" .. idx .. " of " ..
332				    #all_choices .. ")"
333			end,
334			func = function(_, choice, _)
335				if loader.getenv("kernelname") ~= nil then
336					loader.perform("unload")
337				end
338				config.selectKernel(choice)
339			end,
340			alias = {"k", "K"},
341		},
342		boot_options = {
343			entry_type = core.MENU_SUBMENU,
344			name = "Boot " .. color.highlight("O") .. "ptions",
345			submenu = menu.boot_options,
346			alias = {"o", "O"},
347		},
348		boot_envs = {
349			entry_type = core.MENU_SUBMENU,
350			visible = function()
351				return core.isZFSBoot() and
352				    #core.bootenvList() > 1
353			end,
354			name = "Boot " .. color.highlight("E") .. "nvironments",
355			submenu = menu.boot_environments,
356			alias = {"e", "E"},
357		},
358		chainload = {
359			entry_type = core.MENU_ENTRY,
360			name = function()
361				return 'Chain' .. color.highlight("L") ..
362				    "oad " .. loader.getenv('chain_disk')
363			end,
364			func = function()
365				loader.perform("chain " ..
366				    loader.getenv('chain_disk'))
367			end,
368			visible = function()
369				return loader.getenv('chain_disk') ~= nil
370			end,
371			alias = {"l", "L"},
372		},
373	},
374}
375
376menu.default = menu.welcome
377-- current_alias_table will be used to keep our alias table consistent across
378-- screen redraws, instead of relying on whatever triggered the redraw to update
379-- the local alias_table in menu.process.
380menu.current_alias_table = {}
381
382function menu.draw(menudef)
383	-- Clear the screen, reset the cursor, then draw
384	screen.clear()
385	menu.current_alias_table = drawer.drawscreen(menudef)
386	drawn_menu = menudef
387	screen.defcursor()
388end
389
390-- 'keypress' allows the caller to indicate that a key has been pressed that we
391-- should process as our initial input.
392function menu.process(menudef, keypress)
393	assert(menudef ~= nil)
394
395	if drawn_menu ~= menudef then
396		menu.draw(menudef)
397	end
398
399	while true do
400		local key = keypress or io.getchar()
401		keypress = nil
402
403		-- Special key behaviors
404		if (key == core.KEY_BACKSPACE or key == core.KEY_DELETE) and
405		    menudef ~= menu.default then
406			break
407		elseif key == core.KEY_ENTER then
408			core.boot()
409			-- Should not return.  If it does, escape menu handling
410			-- and drop to loader prompt.
411			return false
412		end
413
414		key = string.char(key)
415		-- check to see if key is an alias
416		local sel_entry = nil
417		for k, v in pairs(menu.current_alias_table) do
418			if key == k then
419				sel_entry = v
420				break
421			end
422		end
423
424		-- if we have an alias do the assigned action:
425		if sel_entry ~= nil then
426			local handler = menu.handlers[sel_entry.entry_type]
427			assert(handler ~= nil)
428			-- The handler's return value indicates if we
429			-- need to exit this menu.  An omitted or true
430			-- return value means to continue.
431			if handler(menudef, sel_entry) == false then
432				return
433			end
434			-- If we got an alias key the screen is out of date...
435			-- redraw it.
436			menu.draw(menudef)
437		end
438	end
439end
440
441function menu.run()
442	local autoboot_key
443	local delay = loader.getenv("autoboot_delay")
444
445	if delay ~= nil and delay:lower() == "no" then
446		delay = nil
447	else
448		delay = tonumber(delay) or 10
449	end
450
451	if delay == -1 then
452		core.boot()
453		return
454	end
455
456	menu.draw(menu.default)
457
458	if delay ~= nil then
459		autoboot_key = menu.autoboot(delay)
460
461		-- autoboot_key should return the key pressed.  It will only
462		-- return nil if we hit the timeout and executed the timeout
463		-- command.  Bail out.
464		if autoboot_key == nil then
465			return
466		end
467	end
468
469	menu.process(menu.default, autoboot_key)
470	drawn_menu = nil
471
472	screen.defcursor()
473	print("Exiting menu!")
474end
475
476function menu.autoboot(delay)
477	local x = loader.getenv("loader_menu_timeout_x") or 4
478	local y = loader.getenv("loader_menu_timeout_y") or 23
479	local endtime = loader.time() + delay
480	local time
481	local last
482	repeat
483		time = endtime - loader.time()
484		if last == nil or last ~= time then
485			last = time
486			screen.setcursor(x, y)
487			print("Autoboot in " .. time ..
488			    " seconds. [Space] to pause")
489			screen.defcursor()
490		end
491		if io.ischar() then
492			local ch = io.getchar()
493			if ch == core.KEY_ENTER then
494				break
495			else
496				-- erase autoboot msg
497				screen.setcursor(0, y)
498				print(string.rep(" ", 80))
499				screen.defcursor()
500				return ch
501			end
502		end
503
504		loader.delay(50000)
505	until time <= 0
506
507	local cmd = loader.getenv("menu_timeout_command") or "boot"
508	cli_execute_unparsed(cmd)
509	return nil
510end
511
512-- CLI commands
513function cli.menu()
514	menu.run()
515end
516
517return menu
518