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