]> git.proxmox.com Git - pve-installer.git/blob - proxmox-tui-installer/src/main.rs
tui: install progress: use ok/cancel as button text for installer prompt
[pve-installer.git] / proxmox-tui-installer / src / main.rs
1 #![forbid(unsafe_code)]
2
3 use std::{collections::HashMap, env, net::IpAddr};
4
5 use cursive::{
6 event::Event,
7 theme::{ColorStyle, Effect, PaletteColor, Style},
8 view::{Nameable, Offset, Resizable, ViewWrapper},
9 views::{
10 Button, Checkbox, Dialog, DummyView, EditView, Layer, LinearLayout, PaddedView, Panel,
11 ResizedView, ScrollView, SelectView, StackView, TextView, ViewRef,
12 },
13 Cursive, CursiveRunnable, ScreenId, View, XY,
14 };
15
16 use regex::Regex;
17
18 mod options;
19 use options::InstallerOptions;
20
21 use proxmox_installer_common::{
22 options::{BootdiskOptions, NetworkOptions, PasswordOptions, TimezoneOptions},
23 setup::{installer_setup, LocaleInfo, ProxmoxProduct, RuntimeInfo, SetupInfo},
24 utils::Fqdn,
25 };
26
27 mod setup;
28
29 mod system;
30
31 mod views;
32 use views::{
33 BootdiskOptionsView, CidrAddressEditView, FormView, InstallProgressView, TableView,
34 TableViewItem, TimezoneOptionsView,
35 };
36
37 // TextView::center() seems to garble the first two lines, so fix it manually here.
38 const PROXMOX_LOGO: &str = r#"
39 ____
40 | _ \ _ __ _____ ___ __ ___ _____ __
41 | |_) | '__/ _ \ \/ / '_ ` _ \ / _ \ \/ /
42 | __/| | | (_) > <| | | | | | (_) > <
43 |_| |_| \___/_/\_\_| |_| |_|\___/_/\_\ "#;
44
45 struct InstallerView {
46 view: ResizedView<Dialog>,
47 }
48
49 impl InstallerView {
50 pub fn new<T: View>(
51 state: &InstallerState,
52 view: T,
53 next_cb: Box<dyn Fn(&mut Cursive)>,
54 focus_next: bool,
55 ) -> Self {
56 let mut bbar = LinearLayout::horizontal()
57 .child(abort_install_button())
58 .child(DummyView.full_width())
59 .child(Button::new("Previous", switch_to_prev_screen))
60 .child(DummyView)
61 .child(Button::new("Next", next_cb));
62 let _ = bbar.set_focus_index(4); // ignore errors
63 let mut inner = LinearLayout::vertical()
64 .child(PaddedView::lrtb(0, 0, 1, 1, view))
65 .child(PaddedView::lrtb(1, 1, 0, 0, bbar));
66 if focus_next {
67 let _ = inner.set_focus_index(1); // ignore errors
68 }
69
70 Self::with_raw(state, inner)
71 }
72
73 pub fn with_raw(state: &InstallerState, view: impl View) -> Self {
74 let setup = &state.setup_info;
75
76 let title = format!(
77 "{} ({}-{}) Installer",
78 setup.config.fullname, setup.iso_info.release, setup.iso_info.isorelease
79 );
80
81 let inner = Dialog::around(view).title(title);
82
83 Self {
84 // Limit the maximum to something reasonable, such that it won't get spread out much
85 // depending on the screen.
86 view: ResizedView::with_max_size((120, 40), inner),
87 }
88 }
89 }
90
91 impl ViewWrapper for InstallerView {
92 cursive::wrap_impl!(self.view: ResizedView<Dialog>);
93 }
94
95 struct InstallerBackgroundView {
96 view: StackView,
97 }
98
99 impl InstallerBackgroundView {
100 pub fn new() -> Self {
101 let style = Style {
102 effects: Effect::Bold.into(),
103 color: ColorStyle::back(PaletteColor::View),
104 };
105
106 let mut view = StackView::new();
107 view.add_fullscreen_layer(Layer::with_color(
108 DummyView
109 .full_width()
110 .fixed_height(PROXMOX_LOGO.lines().count() + 1),
111 ColorStyle::back(PaletteColor::View),
112 ));
113 view.add_transparent_layer_at(
114 XY {
115 x: Offset::Center,
116 y: Offset::Absolute(0),
117 },
118 TextView::new(PROXMOX_LOGO).style(style),
119 );
120
121 Self { view }
122 }
123 }
124
125 impl ViewWrapper for InstallerBackgroundView {
126 cursive::wrap_impl!(self.view: StackView);
127 }
128
129 #[derive(Clone, Eq, Hash, PartialEq)]
130 enum InstallerStep {
131 Licence,
132 Bootdisk,
133 Timezone,
134 Password,
135 Network,
136 Summary,
137 Install,
138 }
139
140 #[derive(Clone)]
141 struct InstallerState {
142 options: InstallerOptions,
143 setup_info: SetupInfo,
144 runtime_info: RuntimeInfo,
145 locales: LocaleInfo,
146 steps: HashMap<InstallerStep, ScreenId>,
147 in_test_mode: bool,
148 }
149
150 fn main() {
151 let mut siv = cursive::termion();
152
153 let in_test_mode = match env::args().nth(1).as_deref() {
154 Some("-t") => true,
155
156 // Always force the test directory in debug builds
157 _ => cfg!(debug_assertions),
158 };
159
160 let (setup_info, locales, runtime_info) = match installer_setup(in_test_mode) {
161 Ok(result) => result,
162 Err(err) => initial_setup_error(&mut siv, &err),
163 };
164
165 siv.clear_global_callbacks(Event::CtrlChar('c'));
166 siv.set_on_pre_event(Event::CtrlChar('c'), trigger_abort_install_dialog);
167
168 siv.set_user_data(InstallerState {
169 options: InstallerOptions {
170 bootdisk: BootdiskOptions::defaults_from(&runtime_info.disks[0]),
171 timezone: TimezoneOptions::defaults_from(&runtime_info, &locales),
172 password: Default::default(),
173 network: NetworkOptions::defaults_from(&setup_info, &runtime_info.network),
174 autoreboot: false,
175 },
176 setup_info,
177 runtime_info,
178 locales,
179 steps: HashMap::new(),
180 in_test_mode,
181 });
182
183 switch_to_next_screen(&mut siv, InstallerStep::Licence, &license_dialog);
184 siv.run();
185 }
186
187 /// Anything that can be done late in the setup and will not result in fatal errors.
188 fn installer_setup_late(siv: &mut Cursive) {
189 let state = siv.user_data::<InstallerState>().cloned().unwrap();
190
191 if !state.in_test_mode {
192 let kmap_id = &state.options.timezone.kb_layout;
193 if let Some(kmap) = state.locales.kmap.get(kmap_id) {
194 if let Err(err) = system::set_keyboard_layout(kmap) {
195 display_setup_warning(siv, &format!("Failed to apply keyboard layout: {err}"));
196 }
197 }
198 }
199
200 if state.runtime_info.total_memory < 1024 {
201 display_setup_warning(
202 siv,
203 concat!(
204 "Less than 1 GiB of usable memory detected, installation will probably fail.\n\n",
205 "See 'System Requirements' in the documentation."
206 ),
207 );
208 }
209
210 if state.setup_info.config.product == ProxmoxProduct::PVE && !state.runtime_info.hvm_supported {
211 display_setup_warning(
212 siv,
213 concat!(
214 "No support for hardware-accelerated KVM virtualization detected.\n\n",
215 "Check BIOS settings for Intel VT / AMD-V / SVM."
216 ),
217 );
218 }
219 }
220
221 fn initial_setup_error(siv: &mut CursiveRunnable, message: &str) -> ! {
222 siv.add_layer(
223 Dialog::around(TextView::new(message))
224 .title("Installer setup error")
225 .button("Ok", Cursive::quit),
226 );
227 siv.run();
228
229 std::process::exit(1);
230 }
231
232 fn display_setup_warning(siv: &mut Cursive, message: &str) {
233 siv.add_layer(Dialog::info(message).title("Warning"));
234 }
235
236 fn switch_to_next_screen(
237 siv: &mut Cursive,
238 step: InstallerStep,
239 constructor: &dyn Fn(&mut Cursive) -> InstallerView,
240 ) {
241 let state = siv.user_data::<InstallerState>().cloned().unwrap();
242 let is_first_screen = state.steps.is_empty();
243
244 // Check if the screen already exists; if yes, then simply switch to it.
245 if let Some(screen_id) = state.steps.get(&step) {
246 siv.set_screen(*screen_id);
247
248 // The summary view cannot be cached (otherwise it would display stale values). Thus
249 // replace it if the screen is switched to.
250 // TODO: Could be done by e.g. having all the main dialog views implement some sort of
251 // .refresh(), which can be called if the view is switched to.
252 if step == InstallerStep::Summary {
253 let view = constructor(siv);
254 siv.screen_mut().pop_layer();
255 siv.screen_mut().add_layer(view);
256 }
257
258 return;
259 }
260
261 let v = constructor(siv);
262 let screen = siv.add_active_screen();
263 siv.with_user_data(|state: &mut InstallerState| state.steps.insert(step, screen));
264
265 siv.screen_mut().add_transparent_layer_at(
266 XY {
267 x: Offset::Parent(0),
268 y: Offset::Parent(0),
269 },
270 InstallerBackgroundView::new(),
271 );
272
273 siv.screen_mut().add_layer(v);
274
275 // If this is the first screen to be added, execute our late setup first.
276 // Needs to be done here, at the end, to ensure that any potential layers get added to
277 // the right screen and are on top.
278 if is_first_screen {
279 installer_setup_late(siv);
280 }
281 }
282
283 fn switch_to_prev_screen(siv: &mut Cursive) {
284 let id = siv.active_screen().saturating_sub(1);
285 siv.set_screen(id);
286 }
287
288 fn prompt_dialog(
289 siv: &mut Cursive,
290 title: &str,
291 text: &str,
292 yes_text: &str,
293 callback_yes: Box<dyn Fn(&mut Cursive)>,
294 no_text: &str,
295 callback_no: Box<dyn Fn(&mut Cursive)>,
296 ) {
297 siv.add_layer(
298 Dialog::around(TextView::new(text))
299 .title(title)
300 .button(no_text, move |siv| {
301 siv.pop_layer();
302 callback_no(siv);
303 })
304 .button(yes_text, move |siv| {
305 siv.pop_layer();
306 callback_yes(siv);
307 }),
308 )
309 }
310
311 fn trigger_abort_install_dialog(siv: &mut Cursive) {
312 #[cfg(debug_assertions)]
313 siv.quit();
314
315 #[cfg(not(debug_assertions))]
316 prompt_dialog(
317 siv,
318 "Abort installation?",
319 "Are you sure you want to abort the installation?",
320 "Yes",
321 Box::new(Cursive::quit),
322 "No",
323 Box::new(|_| {}),
324 )
325 }
326
327 fn abort_install_button() -> Button {
328 Button::new("Abort", trigger_abort_install_dialog)
329 }
330
331 fn get_eula(setup: &SetupInfo) -> String {
332 let mut path = setup.locations.iso.clone();
333 path.push("EULA");
334
335 std::fs::read_to_string(path)
336 .unwrap_or_else(|_| "< Debug build - ignoring non-existing EULA >".to_owned())
337 }
338
339 fn license_dialog(siv: &mut Cursive) -> InstallerView {
340 let state = siv.user_data::<InstallerState>().unwrap();
341
342 let mut bbar = LinearLayout::horizontal()
343 .child(abort_install_button())
344 .child(DummyView.full_width())
345 .child(Button::new("I agree", |siv| {
346 switch_to_next_screen(siv, InstallerStep::Bootdisk, &bootdisk_dialog)
347 }));
348 let _ = bbar.set_focus_index(2); // ignore errors
349
350 let mut inner = LinearLayout::vertical()
351 .child(PaddedView::lrtb(
352 0,
353 0,
354 1,
355 0,
356 TextView::new("END USER LICENSE AGREEMENT (EULA)").center(),
357 ))
358 .child(Panel::new(ScrollView::new(
359 TextView::new(get_eula(&state.setup_info)).center(),
360 )))
361 .child(PaddedView::lrtb(1, 1, 1, 0, bbar));
362
363 let _ = inner.set_focus_index(2); // ignore errors
364
365 InstallerView::with_raw(state, inner)
366 }
367
368 fn bootdisk_dialog(siv: &mut Cursive) -> InstallerView {
369 let state = siv.user_data::<InstallerState>().cloned().unwrap();
370
371 InstallerView::new(
372 &state,
373 BootdiskOptionsView::new(siv, &state.runtime_info, &state.options.bootdisk)
374 .with_name("bootdisk-options"),
375 Box::new(|siv| {
376 let options = siv.call_on_name("bootdisk-options", BootdiskOptionsView::get_values);
377
378 match options {
379 Some(Ok(options)) => {
380 siv.with_user_data(|state: &mut InstallerState| {
381 state.options.bootdisk = options;
382 });
383
384 switch_to_next_screen(siv, InstallerStep::Timezone, &timezone_dialog);
385 }
386
387 Some(Err(err)) => siv.add_layer(Dialog::info(format!("Invalid values: {err}"))),
388 _ => siv.add_layer(Dialog::info("Invalid values")),
389 }
390 }),
391 true,
392 )
393 }
394
395 fn timezone_dialog(siv: &mut Cursive) -> InstallerView {
396 let state = siv.user_data::<InstallerState>().unwrap();
397 let options = &state.options.timezone;
398
399 InstallerView::new(
400 state,
401 TimezoneOptionsView::new(&state.locales, options).with_name("timezone-options"),
402 Box::new(|siv| {
403 let options = siv.call_on_name("timezone-options", TimezoneOptionsView::get_values);
404
405 match options {
406 Some(Ok(options)) => {
407 siv.with_user_data(|state: &mut InstallerState| {
408 state.options.timezone = options;
409 });
410
411 switch_to_next_screen(siv, InstallerStep::Password, &password_dialog);
412 }
413 Some(Err(err)) => siv.add_layer(Dialog::info(format!("Invalid values: {err}"))),
414 _ => siv.add_layer(Dialog::info("Invalid values")),
415 }
416 }),
417 true,
418 )
419 }
420
421 fn password_dialog(siv: &mut Cursive) -> InstallerView {
422 let state = siv.user_data::<InstallerState>().unwrap();
423 let options = &state.options.password;
424
425 let inner = FormView::new()
426 .child("Root password", EditView::new().secret())
427 .child("Confirm root password", EditView::new().secret())
428 .child(
429 "Administrator email",
430 EditView::new().content(&options.email),
431 )
432 .with_name("password-options");
433
434 InstallerView::new(
435 state,
436 inner,
437 Box::new(|siv| {
438 let options = siv.call_on_name("password-options", |view: &mut FormView| {
439 let root_password = view
440 .get_value::<EditView, _>(0)
441 .ok_or("failed to retrieve password")?;
442
443 let confirm_password = view
444 .get_value::<EditView, _>(1)
445 .ok_or("failed to retrieve password confirmation")?;
446
447 let email = view
448 .get_value::<EditView, _>(2)
449 .ok_or("failed to retrieve email")?;
450
451 let email_regex =
452 Regex::new(r"^[\w\+\-\~]+(\.[\w\+\-\~]+)*@[a-zA-Z0-9\-]+(\.[a-zA-Z0-9\-]+)*$")
453 .unwrap();
454
455 if root_password.len() < 5 {
456 Err("password too short, must be at least 5 characters long")
457 } else if root_password != confirm_password {
458 Err("passwords do not match")
459 } else if email == "mail@example.invalid" {
460 Err("invalid email address")
461 } else if !email_regex.is_match(&email) {
462 Err("Email does not look like a valid address (user@domain.tld)")
463 } else {
464 Ok(PasswordOptions {
465 root_password,
466 email,
467 })
468 }
469 });
470
471 match options {
472 Some(Ok(options)) => {
473 siv.with_user_data(|state: &mut InstallerState| {
474 state.options.password = options;
475 });
476
477 switch_to_next_screen(siv, InstallerStep::Network, &network_dialog);
478 }
479 Some(Err(err)) => siv.add_layer(Dialog::info(format!("Invalid values: {err}"))),
480 _ => siv.add_layer(Dialog::info("Invalid values")),
481 }
482 }),
483 false,
484 )
485 }
486
487 fn network_dialog(siv: &mut Cursive) -> InstallerView {
488 let state = siv.user_data::<InstallerState>().unwrap();
489 let options = &state.options.network;
490 let ifaces = state.runtime_info.network.interfaces.values();
491 let ifnames = ifaces
492 .clone()
493 .map(|iface| (iface.render(), iface.name.clone()));
494 let mut ifaces_selection = SelectView::new().popup().with_all(ifnames.clone());
495
496 ifaces_selection.sort();
497 ifaces_selection.set_selection(
498 ifnames
499 .clone()
500 .position(|iface| iface.1 == options.ifname)
501 .unwrap_or(ifaces.len() - 1),
502 );
503
504 let inner = FormView::new()
505 .child("Management interface", ifaces_selection)
506 .child(
507 "Hostname (FQDN)",
508 EditView::new().content(options.fqdn.to_string()),
509 )
510 .child(
511 "IP address (CIDR)",
512 CidrAddressEditView::new().content(options.address.clone()),
513 )
514 .child(
515 "Gateway address",
516 EditView::new().content(options.gateway.to_string()),
517 )
518 .child(
519 "DNS server address",
520 EditView::new().content(options.dns_server.to_string()),
521 )
522 .with_name("network-options");
523
524 InstallerView::new(
525 state,
526 inner,
527 Box::new(|siv| {
528 let options = siv.call_on_name("network-options", |view: &mut FormView| {
529 let ifname = view
530 .get_value::<SelectView, _>(0)
531 .ok_or("failed to retrieve management interface name")?;
532
533 let fqdn = view
534 .get_value::<EditView, _>(1)
535 .ok_or("failed to retrieve host FQDN")?
536 .parse::<Fqdn>()
537 .map_err(|err| format!("hostname does not look valid:\n\n{err}"))?;
538
539 let address = view
540 .get_value::<CidrAddressEditView, _>(2)
541 .ok_or("failed to retrieve host address")?;
542
543 let gateway = view
544 .get_value::<EditView, _>(3)
545 .ok_or("failed to retrieve gateway address")?
546 .parse::<IpAddr>()
547 .map_err(|err| err.to_string())?;
548
549 let dns_server = view
550 .get_value::<EditView, _>(4)
551 .ok_or("failed to retrieve DNS server address")?
552 .parse::<IpAddr>()
553 .map_err(|err| err.to_string())?;
554
555 if address.addr().is_ipv4() != gateway.is_ipv4() {
556 Err("host and gateway IP address version must not differ".to_owned())
557 } else if address.addr().is_ipv4() != dns_server.is_ipv4() {
558 Err("host and DNS IP address version must not differ".to_owned())
559 } else if fqdn.to_string().ends_with(".invalid") {
560 Err("hostname does not look valid".to_owned())
561 } else {
562 Ok(NetworkOptions {
563 ifname,
564 fqdn,
565 address,
566 gateway,
567 dns_server,
568 })
569 }
570 });
571
572 match options {
573 Some(Ok(options)) => {
574 siv.with_user_data(|state: &mut InstallerState| {
575 state.options.network = options;
576 });
577
578 switch_to_next_screen(siv, InstallerStep::Summary, &summary_dialog);
579 }
580 Some(Err(err)) => siv.add_layer(Dialog::info(format!("Invalid values: {err}"))),
581 _ => siv.add_layer(Dialog::info("Invalid values")),
582 }
583 }),
584 true,
585 )
586 }
587
588 pub struct SummaryOption {
589 name: &'static str,
590 value: String,
591 }
592
593 impl SummaryOption {
594 pub fn new<S: Into<String>>(name: &'static str, value: S) -> Self {
595 Self {
596 name,
597 value: value.into(),
598 }
599 }
600 }
601
602 impl TableViewItem for SummaryOption {
603 fn get_column(&self, name: &str) -> String {
604 match name {
605 "name" => self.name.to_owned(),
606 "value" => self.value.clone(),
607 _ => unreachable!(),
608 }
609 }
610 }
611
612 fn summary_dialog(siv: &mut Cursive) -> InstallerView {
613 let state = siv.user_data::<InstallerState>().unwrap();
614
615 let mut bbar = LinearLayout::horizontal()
616 .child(abort_install_button())
617 .child(DummyView.full_width())
618 .child(Button::new("Previous", switch_to_prev_screen))
619 .child(DummyView)
620 .child(Button::new("Install", |siv| {
621 let autoreboot = siv
622 .find_name("reboot-after-install")
623 .map(|v: ViewRef<Checkbox>| v.is_checked())
624 .unwrap_or_default();
625
626 siv.with_user_data(|state: &mut InstallerState| {
627 state.options.autoreboot = autoreboot;
628 });
629
630 switch_to_next_screen(siv, InstallerStep::Install, &install_progress_dialog);
631 }));
632
633 let _ = bbar.set_focus_index(2); // ignore errors
634
635 let mut inner = LinearLayout::vertical()
636 .child(PaddedView::lrtb(
637 0,
638 0,
639 1,
640 2,
641 TableView::new()
642 .columns(&[
643 ("name".to_owned(), "Option".to_owned()),
644 ("value".to_owned(), "Selected value".to_owned()),
645 ])
646 .items(state.options.to_summary(&state.locales)),
647 ))
648 .child(
649 LinearLayout::horizontal()
650 .child(DummyView.full_width())
651 .child(Checkbox::new().checked().with_name("reboot-after-install"))
652 .child(
653 TextView::new(" Automatically reboot after successful installation").no_wrap(),
654 )
655 .child(DummyView.full_width()),
656 )
657 .child(PaddedView::lrtb(1, 1, 1, 0, bbar));
658
659 let _ = inner.set_focus_index(2); // ignore errors
660
661 InstallerView::with_raw(state, inner)
662 }
663
664 fn install_progress_dialog(siv: &mut Cursive) -> InstallerView {
665 // Ensure the screen is updated independently of keyboard events and such
666 siv.set_autorefresh(true);
667
668 let state = siv.user_data::<InstallerState>().cloned().unwrap();
669 InstallerView::with_raw(&state, InstallProgressView::new(siv))
670 }