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