use std::io;
use std::time::{Duration, Instant};
use crossterm::{
event::{self, Event, KeyCode, KeyModifiers},
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
prelude::*,
widgets::{Block, Paragraph},
};
use crate::app::{Installer, ScreenAction, Trap};
use crate::block;
use crate::firmware::{self, SetupModeStatus};
use crate::pipeline;
const LANGUAGES: &[(&str, &str)] = &[
("English (US)", "en_US.UTF-8"),
("English (UK)", "en_GB.UTF-8"),
("Finnish", "fi_FI.UTF-8"),
("Swedish", "sv_SE.UTF-8"),
("Norwegian", "nb_NO.UTF-8"),
("Danish", "da_DK.UTF-8"),
("German", "de_DE.UTF-8"),
("French", "fr_FR.UTF-8"),
("Spanish", "es_ES.UTF-8"),
("Italian", "it_IT.UTF-8"),
];
const KEYMAPS: &[(&str, &str)] = &[
("en_US.UTF-8", "us"),
("en_GB.UTF-8", "uk"),
("fi_FI.UTF-8", "fi"),
("sv_SE.UTF-8", "se"),
("nb_NO.UTF-8", "no"),
("da_DK.UTF-8", "dk"),
("de_DE.UTF-8", "de"),
("fr_FR.UTF-8", "fr"),
("es_ES.UTF-8", "es"),
("it_IT.UTF-8", "it"),
];
const SHELLS: &[(&str, &str)] = &[("bash", "/bin/bash"), ("zsh", "/bin/zsh")];
const REBOOT_DELAY: u64 = 30;
const MIN_SCREEN_COLS: u16 = 80;
const MIN_SCREEN_ROWS: u16 = 23;
const INSTALL_LOG_Y: u16 = 13;
const BG: Color = Color::Rgb(0x2E, 0x34, 0x40); const PANEL: Color = Color::Rgb(0x3B, 0x42, 0x52); const TITLE_BG: Color = Color::Rgb(0x5E, 0x81, 0xAC); const FG: Color = Color::Rgb(0xE5, 0xE9, 0xF0); const ACCENT: Color = Color::Rgb(0x88, 0xC0, 0xD0); const ON_ACCENT: Color = Color::Rgb(0x2E, 0x34, 0x40); const ERR: Color = Color::Rgb(0xBF, 0x61, 0x6A); const WARN: Color = Color::Rgb(0xEB, 0xCB, 0x8B); const OK: Color = Color::Rgb(0xA3, 0xBE, 0x8C);
const WELCOME_CHOICES: &[(ScreenAction, &str)] = &[
(ScreenAction::Install, "Install"),
(ScreenAction::Network, "Network"),
(ScreenAction::Shell, "Shell"),
(ScreenAction::Reboot, "Reboot"),
];
const ENROLL_CHOICES: &[(ScreenAction, &str)] = &[
(ScreenAction::Back, "Back"),
(ScreenAction::Install, "Enroll keys"),
];
const FALLBACK_CHOICES: &[(ScreenAction, &str)] = &[
(ScreenAction::Back, "Back"),
(ScreenAction::Install, "Continue"),
(ScreenAction::Reboot, "Reboot"),
];
#[derive(Clone, Copy)]
enum InstallStepState {
Pending,
Active,
Done,
Skipped,
Failed,
}
impl InstallStepState {
fn marker(self) -> &'static str {
match self {
Self::Active => ">>>",
Self::Done => "[*]",
Self::Skipped => "[-]",
Self::Failed => "[!]",
Self::Pending => "[ ]",
}
}
fn style(self) -> Style {
match self {
Self::Active => Style::default().fg(ON_ACCENT).bg(ACCENT),
Self::Done => Style::default().fg(OK).add_modifier(Modifier::BOLD),
Self::Failed => Style::default().fg(ERR).add_modifier(Modifier::BOLD),
Self::Skipped => Style::default().fg(ACCENT),
Self::Pending => Style::default(),
}
}
}
fn keymap_for_locale(locale: &str) -> &str {
KEYMAPS
.iter()
.find(|(l, _)| *l == locale)
.map_or("us", |(_, k)| *k)
}
fn network_tool() -> Option<Vec<String>> {
for tool in &["nmtui", "nmcli", "iwctl"] {
if crate::util::which_exists(tool) {
return Some(vec![(*tool).to_string()]);
}
}
None
}
fn tui_or_exit<T>(result: io::Result<T>, ctx: &str) -> T {
match result {
Ok(v) => v,
Err(e) => {
let _ = terminal::disable_raw_mode();
eprintln!("Terminal error ({ctx}): {e}");
std::process::exit(1);
}
}
}
fn valid_username(name: &str) -> bool {
if name.is_empty() || name.len() > 32 {
return false;
}
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
if !first.is_ascii_lowercase() && first != '_' {
return false;
}
for c in chars {
if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_' && c != '-' {
return false;
}
}
true
}
fn validate_account(username: &str, password: &str, password2: &str) -> String {
let username = username.trim();
if !valid_username(username) {
return "Username must match [a-z_][a-z0-9_-] and be at most 32 chars.".into();
}
if password.is_empty() {
return "Password must not be empty.".into();
}
if password != password2 {
return "Passwords do not match.".into();
}
String::new()
}
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
fn progress_bar(fraction: f64, width: usize) -> Line<'static> {
let bar_w = (width.saturating_sub(8)).clamp(10, 40);
let fraction = fraction.clamp(0.0, 1.0);
let percent = (fraction * 100.0) as usize;
let (filled, track) = if crate::util::terminal_is_utf8() {
let eighths = (bar_w as f64 * fraction * 8.0) as usize;
let full = eighths / 8;
let rem = eighths % 8;
let mut filled = "█".repeat(full);
let partial = rem > 0 && full < bar_w;
if partial {
filled.push(['▏', '▎', '▍', '▌', '▋', '▊', '▉'][rem - 1]);
}
let used = full + usize::from(partial);
(filled, "░".repeat(bar_w.saturating_sub(used)))
} else {
let filled = (bar_w as f64 * fraction) as usize;
("#".repeat(filled), "-".repeat(bar_w.saturating_sub(filled)))
};
Line::from(vec![
Span::raw("["),
Span::styled(filled, Style::default().fg(ACCENT)),
Span::styled(track, Style::default().fg(FG).add_modifier(Modifier::DIM)),
Span::raw(format!("] {percent:3}%")),
])
}
fn format_elapsed(d: Duration) -> String {
let secs = d.as_secs();
format!("{:02}:{:02}", secs / 60, secs % 60)
}
fn spinner(frame: usize) -> &'static str {
if crate::util::terminal_is_utf8() {
const FRAMES: [&str; 8] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"];
FRAMES[frame % FRAMES.len()]
} else {
const FRAMES: [&str; 4] = ["|", "/", "-", "\\"];
FRAMES[frame % FRAMES.len()]
}
}
impl Installer {
pub(crate) fn title(&self) -> String {
if self.ctx.image_version.is_empty() {
"Puu Installer".to_string()
} else {
format!("Puu {} Installer", self.ctx.image_version)
}
}
pub(crate) fn draw_frame(f: &mut Frame, title: &str, subtitle: &str, footer: &str) {
let area = f.area();
f.render_widget(Block::default().style(Style::default().fg(FG).bg(BG)), area);
f.render_widget(
Paragraph::new(format!(" {title}")).style(
Style::default()
.fg(FG)
.bg(TITLE_BG)
.add_modifier(Modifier::BOLD),
),
Rect::new(0, 0, area.width, 1),
);
if !subtitle.is_empty() {
let sub_area = Rect::new(0, 1, area.width, 1);
f.render_widget(
Paragraph::new(format!(" {subtitle}")).style(Style::default().fg(ACCENT)),
sub_area,
);
}
if !footer.is_empty() {
let foot_area = Rect::new(0, area.height.saturating_sub(1), area.width, 1);
f.render_widget(
Paragraph::new(format!(" {footer}")).style(Style::default().fg(ACCENT).bg(PANEL)),
foot_area,
);
}
}
#[allow(
clippy::unused_self,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_sign_loss
)]
pub(crate) fn screen_too_small(
&self,
term: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> bool {
let Ok(size) = term.size() else {
return true;
};
size.height < MIN_SCREEN_ROWS || size.width < MIN_SCREEN_COLS
}
pub(crate) fn welcome_screen(
&mut self,
term: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> ScreenAction {
let choices: Vec<&str> = WELCOME_CHOICES.iter().map(|(_, label)| *label).collect();
let mut selected: usize = 0;
loop {
if self.screen_too_small(term) {
term.draw(|f| {
Self::draw_frame(
f,
&self.title(),
"",
"Esc/Ctrl-C to quit, resize terminal to continue",
);
let area = f.area();
let msg = Paragraph::new(format!(
"Current size {}x{}; minimum {}x{}",
area.width, area.height, MIN_SCREEN_COLS, MIN_SCREEN_ROWS
))
.style(Style::default().fg(WARN));
f.render_widget(msg, Rect::new(2, 3, area.width.saturating_sub(4), 3));
})
.ok();
if let Ok(Event::Key(key)) = event::read() {
if matches!(key.code, KeyCode::Esc)
|| (matches!(key.code, KeyCode::Char('c'))
&& key.modifiers.contains(KeyModifiers::CONTROL))
{
return ScreenAction::Quit;
}
}
continue;
}
term.draw(|f| {
Self::draw_frame(
f,
&self.title(),
"",
"Arrows select, Enter activates, Esc quits",
);
let area = f.area();
let menu_start_y = 4.max((area.height as i16 - choices.len() as i16) / 2) as u16;
for (i, choice) in choices.iter().enumerate() {
let marker = if i == selected { ">" } else { " " };
let style = if i == selected {
Style::default()
.fg(ON_ACCENT)
.bg(ACCENT)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
f.render_widget(
Paragraph::new(format!("{marker} {choice}")).style(style),
Rect::new(2, menu_start_y + i as u16, 20, 1),
);
}
})
.ok();
if let Ok(Event::Key(key)) = event::read() {
match key.code {
KeyCode::Up => {
selected = selected.saturating_sub(1);
}
KeyCode::Down | KeyCode::Tab => {
selected = (selected + 1) % choices.len();
}
KeyCode::Enter => {
if selected < WELCOME_CHOICES.len() {
let (action, _) = &WELCOME_CHOICES[selected];
return *action;
}
return ScreenAction::Quit;
}
KeyCode::Esc => return ScreenAction::Quit,
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
return ScreenAction::Quit;
}
_ => {}
}
}
}
}
#[allow(clippy::unused_self, clippy::cast_possible_truncation)]
pub(crate) fn confirm(
&mut self,
term: &mut Terminal<CrosstermBackend<io::Stdout>>,
title: &str,
body: &str,
danger: bool,
) -> bool {
let mut selected = 0usize;
loop {
term.draw(|f| {
Self::draw_frame(
f,
title,
"",
"Left/Right select, Enter activates, Esc cancels",
);
let area = f.area();
let body_style = if danger {
Style::default().fg(ERR)
} else {
Style::default()
};
let lines: Vec<&str> = body.lines().collect();
for (i, line) in lines.iter().enumerate() {
f.render_widget(
Paragraph::new(*line).style(body_style),
Rect::new(4, 5 + i as u16, area.width.saturating_sub(8), 1),
);
}
let y = 8 + lines.len() as u16;
for (i, choice) in ["No", "Yes"].iter().enumerate() {
let sel_style = if i == selected {
Style::default()
.fg(ON_ACCENT)
.bg(ACCENT)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
f.render_widget(
Paragraph::new(format!("[ {choice} ]")).style(sel_style),
Rect::new(4 + i as u16 * 10, y, 10, 1),
);
}
})
.ok();
if let Ok(Event::Key(key)) = event::read() {
match key.code {
KeyCode::Left | KeyCode::Right | KeyCode::Tab => {
selected = 1 - selected;
}
KeyCode::Enter => return selected == 1,
KeyCode::Esc => return false,
_ => {}
}
}
}
}
#[allow(clippy::too_many_lines, clippy::cast_possible_truncation)]
pub(crate) fn target_screen(
&mut self,
term: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> ScreenAction {
let mut selected: usize = 0;
let mut top: usize = 0;
let mut devices: Vec<block::Device> = Vec::new();
let mut status = String::new();
let refresh = |devices: &mut Vec<block::Device>,
selected: &mut usize,
status: &mut String,
pre_drive: &str| {
match block::list_disks() {
Ok(devs) => {
*devices = devs;
if !pre_drive.is_empty() {
for (i, dev) in devices.iter().enumerate() {
if dev.path == pre_drive {
*selected = i;
break;
}
}
}
if !devices.is_empty() {
*selected = (*selected).min(devices.len() - 1);
}
*status = format!("{} disk(s) detected", devices.len());
}
Err(e) => {
devices.clear();
*status = format!("Failed to enumerate devices: {e}");
}
}
};
refresh(&mut devices, &mut selected, &mut status, &self.ctx.drive);
loop {
let size = term.size().unwrap_or(Size {
width: 80,
height: 24,
});
let visible = (size.height.saturating_sub(8)).max(1) as usize;
top = top.min(selected);
if selected >= top + visible {
top = selected - visible + 1;
}
let drive_for_refresh = self.ctx.drive.clone();
term.draw(|f| {
Self::draw_frame(
f,
"Select Target Drive",
"",
"Up/Down select, Enter continue, Ctrl-R refresh, Esc back",
);
let area = f.area();
f.render_widget(
Paragraph::new(format!(
" {:<19} {:<10} {:<8} {:<20} {}",
"Device", "Size", "Bus", "Model", "Status"
))
.style(Style::default().add_modifier(Modifier::BOLD)),
Rect::new(0, 4, area.width, 1),
);
f.render_widget(
Paragraph::new("-".repeat(area.width as usize)),
Rect::new(0, 5, area.width, 1),
);
for (row, dev) in devices[top..(top + visible).min(devices.len())]
.iter()
.enumerate()
{
let idx = top + row;
let status_text = if dev.busy { "BUSY" } else { "available" };
let marker = if idx == selected { ">" } else { " " };
let style = if idx == selected {
Style::default()
.fg(ON_ACCENT)
.bg(ACCENT)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let model: String = dev.model.chars().take(20).collect();
let line = format!(
"{marker} {:<19} {:<10} {:<8} {model:<20} {status_text}",
dev.path, dev.size, dev.transport
);
f.render_widget(
Paragraph::new(line).style(style),
Rect::new(0, 6 + row as u16, area.width, 1),
);
}
let status_style = if status.contains("Failed") || status.contains("busy") {
Style::default().fg(ERR)
} else {
Style::default().fg(ACCENT)
};
f.render_widget(
Paragraph::new(status.as_str()).style(status_style),
Rect::new(
2,
area.height.saturating_sub(3),
area.width.saturating_sub(4),
1,
),
);
})
.ok();
if let Ok(Event::Key(key)) = event::read() {
match key.code {
KeyCode::Up if !devices.is_empty() => {
selected = selected.saturating_sub(1);
}
KeyCode::Down | KeyCode::Tab if !devices.is_empty() => {
selected = (selected + 1) % devices.len();
}
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
refresh(&mut devices, &mut selected, &mut status, &drive_for_refresh);
}
KeyCode::Esc => return ScreenAction::Back,
KeyCode::Enter => {
if devices.is_empty() {
status = "No target disks detected".into();
continue;
}
let dev = &devices[selected];
if dev.busy {
status = format!("{} is busy: {}", dev.path, dev.busy_reason);
continue;
}
self.ctx.drive.clone_from(&dev.path);
self.ctx.drive_label =
format!("{} - {}, {}", dev.path, dev.model, dev.size);
return ScreenAction::Continue;
}
_ => {}
}
}
}
}
pub(crate) fn language_screen(
&mut self,
term: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> ScreenAction {
let current_locale = if self.ctx.locale.is_empty() {
"en_US.UTF-8"
} else {
&self.ctx.locale
};
let mut selected = LANGUAGES
.iter()
.position(|(_, l)| *l == current_locale)
.unwrap_or(0);
let mut top: usize = 0;
loop {
let size = term.size().unwrap_or(Size {
width: 80,
height: 24,
});
let visible = (size.height.saturating_sub(8)).max(1) as usize;
top = top.min(selected);
if selected >= top + visible {
top = selected - visible + 1;
}
let (_, sel_locale) = LANGUAGES[selected];
self.ctx.locale = sel_locale.to_string();
self.ctx.keymap = keymap_for_locale(sel_locale).to_string();
term.draw(|f| {
Self::draw_frame(
f,
"Language",
"Select the language used by the installed system.",
"Up/Down select, Enter continue, Esc back",
);
let area = f.area();
let end = (top + visible).min(LANGUAGES.len());
for (row, (label, locale)) in LANGUAGES[top..end].iter().enumerate() {
let idx = top + row;
let style = if idx == selected {
Style::default()
.fg(ON_ACCENT)
.bg(ACCENT)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
f.render_widget(
Paragraph::new(format!("{label:<18} {locale}")).style(style),
Rect::new(4, 4 + row as u16, area.width.saturating_sub(8), 1),
);
}
let sel_text = format!(
"Selected: {} ({}), keymap {}",
LANGUAGES[selected].0, LANGUAGES[selected].1, self.ctx.keymap
);
f.render_widget(
Paragraph::new(sel_text),
Rect::new(
2,
area.height.saturating_sub(4),
area.width.saturating_sub(4),
1,
),
);
})
.ok();
if let Ok(Event::Key(key)) = event::read() {
match key.code {
KeyCode::Up => selected = selected.saturating_sub(1),
KeyCode::Down | KeyCode::Tab => selected = (selected + 1) % LANGUAGES.len(),
KeyCode::Esc => return ScreenAction::Back,
KeyCode::Enter => return ScreenAction::Continue,
_ => {}
}
}
}
}
#[allow(
clippy::too_many_lines,
clippy::cast_possible_truncation,
unused_assignments
)]
pub(crate) fn account_screen(
&mut self,
term: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> ScreenAction {
let mut username = self.ctx.username.clone();
let mut password = String::new();
let mut password2 = String::new();
let mut shell_idx: usize = SHELLS
.iter()
.position(|(_, v)| *v == self.ctx.user_shell)
.unwrap_or(0);
let mut field_idx: usize = 0;
let mut reveal = false;
let mut status = String::new();
let labels = ["Username", "Password", "Repeat password", "Login shell"];
loop {
let fields = [
username.clone(),
password.clone(),
password2.clone(),
SHELLS[shell_idx].0.to_string(),
];
term.draw(|f| {
Self::draw_frame(
f,
"Create User Account",
"This account will own the new system.",
"Tab next, Enter submit, Esc back, Ctrl-U clear, Ctrl-R reveal",
);
let area = f.area();
for (i, label) in labels.iter().enumerate() {
let y = 5 + i as u16 * 2;
let selected = i == field_idx;
let style = if selected {
Style::default()
.fg(ON_ACCENT)
.bg(ACCENT)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let count = fields[i].chars().count();
let value = if (i == 1 || i == 2) && !reveal {
"*".repeat(count.min(32))
} else if count > 32 {
fields[i].chars().skip(count - 32).collect()
} else {
fields[i].clone()
};
f.render_widget(
Paragraph::new(format!("{label:<16}")),
Rect::new(4, y, 16, 1),
);
f.render_widget(
Paragraph::new(format!("[ {value:<32} ]")).style(style),
Rect::new(22, y, 36, 1),
);
}
if reveal {
f.render_widget(
Paragraph::new("Password reveal is on")
.style(Style::default().fg(WARN).add_modifier(Modifier::BOLD)),
Rect::new(4, 13, area.width, 1),
);
}
if !status.is_empty() {
f.render_widget(
Paragraph::new(status.as_str()).style(Style::default().fg(ERR)),
Rect::new(4, 15, area.width, 1),
);
}
})
.ok();
if let Ok(Event::Key(key)) = event::read() {
match key.code {
KeyCode::Esc => return ScreenAction::Back,
KeyCode::Tab | KeyCode::Down => {
field_idx = (field_idx + 1) % labels.len();
}
KeyCode::Up => {
field_idx = field_idx.checked_sub(1).unwrap_or(labels.len() - 1);
}
KeyCode::Left | KeyCode::Right if field_idx == 3 => {
shell_idx = 1 - shell_idx;
}
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
reveal = !reveal;
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
match field_idx {
0 => username.clear(),
1 => password.clear(),
2 => password2.clear(),
_ => {}
}
}
KeyCode::Backspace => match field_idx {
0 => {
username.pop();
}
1 => {
password.pop();
}
2 => {
password2.pop();
}
_ => {}
},
KeyCode::Enter => {
let result = validate_account(&username, &password, &password2);
if result.is_empty() {
self.ctx.username = username.trim().to_string();
self.ctx.user_password.clone_from(&password);
self.ctx.user_shell = SHELLS[shell_idx].1.to_string();
return ScreenAction::Continue;
}
if field_idx < 3 {
field_idx += 1;
} else {
status = result;
}
}
KeyCode::Char(c) if field_idx < 3 && c.is_ascii() && c >= ' ' => {
match field_idx {
0 if username.len() < 32 => username.push(c),
1 => password.push(c),
2 => password2.push(c),
_ => {}
}
}
_ => {}
}
}
}
}
pub(crate) fn secureboot_screen(
&mut self,
term: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> ScreenAction {
let status = firmware::detect_setup_mode();
if status.setup_mode == Some(true) {
self.secureboot_enroll_screen(term, &status)
} else {
self.secureboot_fallback_screen(term, &status)
}
}
pub(crate) fn secureboot_enroll_screen(
&mut self,
term: &mut Terminal<CrosstermBackend<io::Stdout>>,
status: &SetupModeStatus,
) -> ScreenAction {
let mut selected = 1usize;
let state = if status.secure_boot == Some(true) {
"enabled"
} else {
"disabled"
};
loop {
term.draw(|f| {
Self::draw_frame(
f,
"Secure Boot",
"Puu can enroll its own Secure Boot keys when firmware Setup Mode is on.",
"Left/Right select, Enter activates, Esc back",
);
let area = f.area();
f.render_widget(
Paragraph::new(format!(
"Setup Mode is on. Secure Boot is currently {state}."
)),
Rect::new(4, 5, area.width.saturating_sub(8), 1),
);
f.render_widget(
Paragraph::new("This writes firmware Secure Boot variables.")
.style(Style::default().fg(WARN).add_modifier(Modifier::BOLD)),
Rect::new(4, 7, area.width.saturating_sub(8), 1),
);
for (i, choice) in ["Back", "Enroll keys"].iter().enumerate() {
let style = if i == selected {
Style::default()
.fg(ON_ACCENT)
.bg(ACCENT)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
f.render_widget(
Paragraph::new(format!("[ {choice} ]")).style(style),
Rect::new(4 + i as u16 * 16, 10, 16, 1),
);
}
})
.ok();
if let Ok(Event::Key(key)) = event::read() {
match key.code {
KeyCode::Esc => return ScreenAction::Back,
KeyCode::Left | KeyCode::Right | KeyCode::Tab => {
selected = 1 - selected;
}
KeyCode::Enter => {
let (action, _) = &ENROLL_CHOICES[selected];
if *action == ScreenAction::Install {
self.ctx.secure_boot = true;
}
return *action;
}
_ => {}
}
}
}
}
pub(crate) fn secureboot_fallback_screen(
&mut self,
term: &mut Terminal<CrosstermBackend<io::Stdout>>,
status: &SetupModeStatus,
) -> ScreenAction {
let mut selected = 1usize;
let (title, body) = if status.setup_mode == Some(false) {
(
"Setup Mode is off",
"Secure Boot keys can only be enrolled while firmware Setup Mode is on.\n\
Reboot to firmware setup, or continue without Secure Boot.",
)
} else {
(
"Setup Mode not detected",
"The installer could not read firmware Setup Mode.\n\
Continue without Secure Boot, or reboot and check firmware settings.",
)
};
let body = if status.reason.is_empty() {
body.to_string()
} else {
format!("{body}\n{}", status.reason)
};
loop {
term.draw(|f| {
Self::draw_frame(
f,
"Secure Boot",
title,
"Left/Right select, Enter activates, Esc back",
);
let area = f.area();
let lines: Vec<&str> = body.lines().collect();
for (i, line) in lines.iter().enumerate() {
f.render_widget(
Paragraph::new(*line)
.style(Style::default().fg(WARN).add_modifier(Modifier::BOLD)),
Rect::new(4, 5 + i as u16, area.width.saturating_sub(8), 1),
);
}
let y = 8 + lines.len() as u16;
let choices: Vec<&str> = FALLBACK_CHOICES.iter().map(|(_, label)| *label).collect();
for (i, choice) in choices.iter().enumerate() {
let style = if i == selected {
Style::default()
.fg(ON_ACCENT)
.bg(ACCENT)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
f.render_widget(
Paragraph::new(format!("[ {choice} ]")).style(style),
Rect::new(4 + i as u16 * 14, y, 14, 1),
);
}
})
.ok();
if let Ok(Event::Key(key)) = event::read() {
match key.code {
KeyCode::Esc => return ScreenAction::Back,
KeyCode::Left => selected = selected.saturating_sub(1),
KeyCode::Right | KeyCode::Tab => {
selected = (selected + 1) % 3;
}
KeyCode::Enter => {
let (action, _) = &FALLBACK_CHOICES[selected];
if *action == ScreenAction::Install {
self.ctx.secure_boot = false;
}
return *action;
}
_ => {}
}
}
}
}
#[allow(clippy::too_many_lines, clippy::cast_possible_truncation)]
pub(crate) fn install_screen(
&mut self,
term: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> ScreenAction {
let steps = &*pipeline::STEPS;
let start_index = (self.ctx.start_from).saturating_sub(1);
let skip_indices: Vec<usize> = self
.ctx
.skip_steps
.iter()
.map(|s| s.saturating_sub(1))
.collect();
let mut states = vec![InstallStepState::Pending; steps.len()];
for i in 0..start_index {
if i < states.len() {
states[i] = InstallStepState::Skipped;
}
}
for &idx in &skip_indices {
if idx < states.len() {
states[idx] = InstallStepState::Skipped;
}
}
if !self.ctx.secure_boot {
for (i, step) in steps.iter().enumerate() {
if step.requires_secure_boot {
states[i] = InstallStepState::Skipped;
}
}
}
let mut worker = crate::app::InstallTask::start(&self.ctx, start_index, &skip_indices);
let mut logs: Vec<String> = Vec::new();
let mut state = "Starting install".to_string();
let mut step_fraction = 0.0f64;
let mut current_step = start_index;
let mut log_scroll: usize = 0;
let mut running = true;
let mut failed = false;
let mut complete = false;
let mut frame: usize = 0;
let started = Instant::now();
let mut finished: Option<Instant> = None;
loop {
frame = frame.wrapping_add(1);
while let Ok(evt) = worker.events_rx.try_recv() {
match evt {
crate::app::TaskEvent::Log(line) => {
logs.push(line);
if logs.len() > 2000 {
logs.drain(0..logs.len() - 2000);
}
}
crate::app::TaskEvent::Progress {
step_index,
status: s,
fraction,
} => {
current_step = step_index;
state = s;
step_fraction = fraction;
if step_index < states.len() {
states[step_index] = if fraction >= 1.0 {
InstallStepState::Done
} else {
InstallStepState::Active
};
}
}
crate::app::TaskEvent::Done => {
running = false;
complete = true;
finished.get_or_insert_with(Instant::now);
state = "Install complete.".into();
}
crate::app::TaskEvent::Failed { at, error } => {
running = false;
failed = true;
finished.get_or_insert_with(Instant::now);
if at < states.len() {
states[at] = InstallStepState::Failed;
}
state = format!("Install failed at step {}: {error}", at + 1);
self.ctx.last_error = error;
}
}
}
let _size = term.size().unwrap_or(Size {
width: 80,
height: 24,
});
term.draw(|f| {
Self::draw_frame(
f,
"Installing Puu",
&format!("Target: {}", self.ctx.drive_label),
if complete {
"Enter continue"
} else if failed {
"Enter back to menu"
} else {
"Esc cancel, PgUp/PgDn scroll"
},
);
let area = f.area();
f.render_widget(
Paragraph::new("Steps").style(Style::default().add_modifier(Modifier::BOLD)),
Rect::new(2, 4, 40, 1),
);
let visible_steps = (area.height.saturating_sub(INSTALL_LOG_Y) as usize).max(1);
let step_start = current_step.saturating_sub(visible_steps / 2);
for (row, i) in
(step_start..steps.len().min(step_start + visible_steps)).enumerate()
{
let st = states.get(i).copied().unwrap_or(InstallStepState::Pending);
let marker = if running && matches!(st, InstallStepState::Active) {
format!("{:<3}", spinner(frame))
} else {
st.marker().to_string()
};
let style = st.style();
f.render_widget(
Paragraph::new(format!("{marker} {:02} {}", i + 1, steps[i].title))
.style(style),
Rect::new(2, 5 + row as u16, 40, 1),
);
}
let rx = 44u16;
let rw = area.width.saturating_sub(rx + 2).max(20);
let st_style = if failed {
Style::default().fg(ERR)
} else if complete {
Style::default().fg(OK).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
f.render_widget(
Paragraph::new("Current step")
.style(Style::default().add_modifier(Modifier::BOLD)),
Rect::new(rx, 4, rw, 1),
);
let status_line = if running {
format!("{} {state}", spinner(frame))
} else {
state.clone()
};
f.render_widget(
Paragraph::new(status_line).style(st_style),
Rect::new(rx, 5, rw, 1),
);
f.render_widget(
Paragraph::new(progress_bar(step_fraction, rw as usize)),
Rect::new(rx, 7, rw, 1),
);
f.render_widget(
Paragraph::new("Overall").style(Style::default().add_modifier(Modifier::BOLD)),
Rect::new(rx, 9, rw, 1),
);
let overall = (current_step as f64
+ if step_fraction >= 1.0 {
1.0
} else {
step_fraction
})
/ steps.len() as f64;
f.render_widget(
Paragraph::new(progress_bar(overall, rw as usize)),
Rect::new(rx, 10, rw, 1),
);
let elapsed = finished
.unwrap_or_else(Instant::now)
.duration_since(started);
f.render_widget(
Paragraph::new(format!("Elapsed {}", format_elapsed(elapsed)))
.style(Style::default().fg(ACCENT)),
Rect::new(rx, 11, rw, 1),
);
let log_header = if log_scroll > 0 {
let arrow = if crate::util::terminal_is_utf8() {
"↑"
} else {
"^"
};
format!("Log {arrow}{log_scroll} PgDn to follow")
} else {
"Log".to_string()
};
let log_style = if log_scroll > 0 {
Style::default().fg(WARN).add_modifier(Modifier::BOLD)
} else {
Style::default().add_modifier(Modifier::BOLD)
};
f.render_widget(
Paragraph::new(log_header).style(log_style),
Rect::new(rx, 12, rw, 1),
);
let log_h = (area.height.saturating_sub(INSTALL_LOG_Y) as usize).max(1);
let end = logs.len().saturating_sub(log_scroll);
let start = end.saturating_sub(log_h);
for (row, line) in logs[start..end].iter().enumerate() {
f.render_widget(
Paragraph::new(line.as_str()),
Rect::new(rx, INSTALL_LOG_Y + row as u16, rw, 1),
);
}
})
.ok();
if event::poll(Duration::from_millis(100)).unwrap_or(false) {
if let Ok(Event::Key(key)) = event::read() {
match key.code {
KeyCode::PageDown => {
log_scroll = log_scroll.saturating_sub(10);
}
KeyCode::PageUp => {
log_scroll = (log_scroll + 10).min(logs.len().saturating_sub(1));
}
KeyCode::Esc | KeyCode::Char('c')
if (key.code != KeyCode::Char('c')
|| key.modifiers.contains(KeyModifiers::CONTROL))
&& running
&& self.confirm(
term,
"Cancel install?",
"The installer will stop and clean up mounts.",
false,
) =>
{
state = "Cancelling install...".into();
worker.cancel();
}
KeyCode::Enter if complete => {
worker.join();
return ScreenAction::Reboot;
}
KeyCode::Enter if failed => {
worker.join();
return ScreenAction::Menu;
}
_ => {}
}
}
}
}
}
pub(crate) fn reboot_screen(
&mut self,
term: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Trap {
let mut selected = 1usize;
#[allow(clippy::cast_possible_wrap)]
let mut remaining = REBOOT_DELAY as i64;
let mut last_tick = Instant::now();
let mut countdown = true;
loop {
if countdown {
let now = Instant::now();
if now.duration_since(last_tick) >= Duration::from_secs(1) {
remaining -= 1;
last_tick = now;
}
if remaining <= 0 {
return Trap::Reboot;
}
}
term.draw(|f| {
Self::draw_frame(
f,
"Install Complete",
"Puu is ready to boot.",
"Left/Right select, Enter activates",
);
let area = f.area();
f.render_widget(
Paragraph::new(format!("Target: {}", self.ctx.drive_label)),
Rect::new(4, 5, area.width.saturating_sub(8), 1),
);
f.render_widget(
Paragraph::new("Remove the installation media before rebooting.")
.style(Style::default().fg(ACCENT)),
Rect::new(4, 7, area.width.saturating_sub(8), 1),
);
let countdown_line = if countdown {
format!("Auto-reboot in {remaining}s")
} else {
"Auto-reboot cancelled; select an option.".to_string()
};
f.render_widget(
Paragraph::new(countdown_line),
Rect::new(4, 9, area.width.saturating_sub(8), 1),
);
for (i, choice) in ["Shell", "Reboot"].iter().enumerate() {
let style = if i == selected {
Style::default()
.fg(ON_ACCENT)
.bg(ACCENT)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
f.render_widget(
Paragraph::new(format!("[ {choice} ]")).style(style),
Rect::new(4 + i as u16 * 14, 12, 14, 1),
);
}
})
.ok();
if event::poll(Duration::from_millis(200)).unwrap_or(false) {
if let Ok(Event::Key(key)) = event::read() {
countdown = false;
match key.code {
KeyCode::Left | KeyCode::Right | KeyCode::Tab => {
selected = 1 - selected;
}
KeyCode::Enter => {
if selected == 0 {
tui_or_exit(terminal::disable_raw_mode(), "disable_raw_mode");
crossterm::execute!(term.backend_mut(), LeaveAlternateScreen).ok();
let shell =
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into());
let _ = std::process::Command::new(&shell).status();
tui_or_exit(terminal::enable_raw_mode(), "enable_raw_mode");
crossterm::execute!(term.backend_mut(), EnterAlternateScreen).ok();
} else {
return Trap::Reboot;
}
}
_ => {}
}
}
}
}
}
#[allow(clippy::unused_self)]
pub(crate) fn run_network(&mut self, term: &mut Terminal<CrosstermBackend<io::Stdout>>) {
let tool = network_tool();
if tool.is_none() {
term.draw(|f| {
Self::draw_frame(f, "Network", "", "");
f.render_widget(
Paragraph::new("No network CLI found. Expected nmtui, nmcli, or iwctl.")
.style(Style::default().fg(ERR)),
Rect::new(4, 5, f.area().width.saturating_sub(8), 1),
);
f.render_widget(
Paragraph::new("Press any key to continue..."),
Rect::new(4, 7, f.area().width, 1),
);
})
.ok();
let _ = event::read();
return;
}
let Some(argv) = tool else {
return;
};
tui_or_exit(terminal::disable_raw_mode(), "disable_raw_mode");
tui_or_exit(
crossterm::execute!(term.backend_mut(), LeaveAlternateScreen),
"LeaveAlternateScreen",
);
let _ = std::process::Command::new(&argv[0])
.args(&argv[1..])
.status();
tui_or_exit(terminal::enable_raw_mode(), "enable_raw_mode");
tui_or_exit(
crossterm::execute!(term.backend_mut(), EnterAlternateScreen),
"EnterAlternateScreen",
);
}
}