//! Wizard state: which step we're on, the results each step produces, and //! step-aware key handling (nav steps, the Configure form, and the SD-card step //! each behave differently). use std::sync::mpsc::{Receiver, TryRecvError}; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::config::{Config, Field, keychain_wifi_password}; use crate::preflight::Preflight; use crate::sdcard::{self, Card, CardInspect, SdEvent}; #[derive(Clone, Copy, PartialEq, Eq)] pub enum Step { Preflight, Configure, SdCard, Done, } impl Step { pub const ALL: [Step; 4] = [Step::Preflight, Step::Configure, Step::SdCard, Step::Done]; pub fn title(self) -> &'static str { match self { Step::Preflight => "Preflight", Step::Configure => "Configure", Step::SdCard => "SD card", Step::Done => "Done", } } fn index(self) -> usize { Step::ALL.iter().position(|&s| s == self).unwrap_or(0) } fn next(self) -> Step { Step::ALL[(self.index() + 1).min(Step::ALL.len() - 1)] } fn prev(self) -> Step { Step::ALL[self.index().saturating_sub(1)] } } pub enum SdState { Idle, /// The selected card already holds a repo; awaiting an explicit `y` to wipe. ConfirmWipe(CardInspect), Running, Done, Failed(String), } pub struct App { pub step: Step, pub preflight: Preflight, pub config: Config, /// Focused field index within the Configure form. pub focus: usize, /// Transient one-line feedback (e.g. the Keychain-lookup result). pub status: Option, // ── SD-card step ── pub cards: Vec, pub card_sel: usize, pub sd: SdState, pub sd_log: Vec, sd_rx: Option>, pub should_quit: bool, } impl App { pub fn new() -> Self { App { step: Step::Preflight, preflight: Preflight::run(), config: Config::derived(), focus: 0, status: None, cards: Vec::new(), card_sel: 0, sd: SdState::Idle, sd_log: Vec::new(), sd_rx: None, should_quit: false, } } pub fn on_key(&mut self, key: KeyEvent) { // Ctrl-C always quits, on any step (even mid-typing / mid-run). if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { self.should_quit = true; return; } self.status = None; match self.step { Step::Configure => self.on_key_configure(key), Step::SdCard => self.on_key_sdcard(key), _ => self.on_key_nav(key), } } /// Non-form steps: single-key navigation. fn on_key_nav(&mut self, key: KeyEvent) { match key.code { KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true, KeyCode::Enter | KeyCode::Tab | KeyCode::Down => self.next(), KeyCode::Up | KeyCode::BackTab => self.prev(), KeyCode::Char('r') if self.step == Step::Preflight => { self.preflight = Preflight::run(); } _ => {} } } /// Configure form: typing edits the focused field; field navigation spills /// over into step navigation at the ends. fn on_key_configure(&mut self, key: KeyEvent) { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let last = Field::ALL.len() - 1; match key.code { KeyCode::Esc => self.should_quit = true, KeyCode::Up | KeyCode::BackTab => { if self.focus > 0 { self.focus -= 1; } else { self.prev(); } } KeyCode::Down | KeyCode::Tab if self.focus < last => self.focus += 1, KeyCode::Enter => { if self.focus < last { self.focus += 1; } else { self.next(); } } KeyCode::Char('u') if ctrl => self.config.get_mut(self.focused_field()).clear(), KeyCode::Char('k') if ctrl => self.fill_wifi_from_keychain(), KeyCode::Backspace => { self.config.get_mut(self.focused_field()).pop(); } KeyCode::Char(c) if !ctrl => self.config.get_mut(self.focused_field()).push(c), _ => {} } } /// SD-card step: pick a card, then start (or confirm-wipe-then-start) the /// provision. fn on_key_sdcard(&mut self, key: KeyEvent) { match self.sd { SdState::Running => return, // input locked while the worker runs SdState::ConfirmWipe(_) => { match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => self.start_provision(true), KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { self.sd = SdState::Idle } _ => {} } return; } _ => {} } match key.code { KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true, KeyCode::Up | KeyCode::BackTab => { if self.card_sel > 0 { self.card_sel -= 1; } else { self.prev(); } } KeyCode::Down | KeyCode::Tab if self.card_sel + 1 < self.cards.len() => { self.card_sel += 1; } KeyCode::Char('r') => { self.sd = SdState::Idle; self.sd_log.clear(); self.refresh_cards(); } KeyCode::Enter => self.attempt_provision(), _ => {} } } pub fn focused_field(&self) -> Field { Field::ALL[self.focus.min(Field::ALL.len() - 1)] } fn fill_wifi_from_keychain(&mut self) { let ssid = self.config.wifi_ssid.clone(); self.status = Some(match keychain_wifi_password(&ssid) { Some(pw) => { self.config.wifi_pass = pw; format!("filled Wi-Fi password for “{ssid}” from Keychain") } None => "no Keychain password found (or the lookup was cancelled)".into(), }); } fn refresh_cards(&mut self) { self.cards = sdcard::detect_cards(); self.card_sel = 0; } fn selected_volume(&self) -> Option { self.cards .get(self.card_sel.min(self.cards.len().saturating_sub(1))) .map(|c| c.volume.clone()) } /// Enter on a card: validate, then either start a fresh provision or, if the /// card already holds a repo, drop into the wipe-confirm screen. fn attempt_provision(&mut self) { if self.cards.is_empty() { self.status = Some("no card detected — insert one and press r".into()); return; } if !self.config.missing_required().is_empty() { self.status = Some("fill the required fields on the Configure step first".into()); return; } let Some(vol) = self.selected_volume() else { return; }; if sdcard::card_has_repo(&vol) { self.sd = SdState::ConfirmWipe(sdcard::inspect_card(&vol)); } else { self.start_provision(false); } } fn start_provision(&mut self, wipe: bool) { let Some(card_volume) = self.selected_volume() else { return; }; let plan = sdcard::Plan { remote: self.config.remote_url.clone(), pat: self.config.pat.clone(), card_volume, conf_body: self.config.to_conf(), wipe, }; let (tx, rx) = std::sync::mpsc::channel(); self.sd_rx = Some(rx); self.sd = SdState::Running; self.sd_log.clear(); std::thread::spawn(move || sdcard::run_provision(plan, tx)); } /// Pull worker progress into the log; advance to Done on success. pub fn drain_worker(&mut self) { let Some(rx) = self.sd_rx.take() else { return; }; let mut done = None; loop { match rx.try_recv() { Ok(SdEvent::Log(l)) => self.sd_log.push(l), Ok(SdEvent::Done(r)) => { done = Some(r); break; } Err(TryRecvError::Empty) => break, Err(TryRecvError::Disconnected) => { done = Some(Err("worker thread stopped unexpectedly".into())); break; } } } match done { Some(Ok(())) => { self.sd = SdState::Done; self.step = Step::Done; } Some(Err(e)) => self.sd = SdState::Failed(e), None => self.sd_rx = Some(rx), } } fn next(&mut self) { self.step = self.step.next(); self.on_enter(); } fn prev(&mut self) { self.step = self.step.prev(); self.on_enter(); } fn on_enter(&mut self) { if self.step == Step::SdCard && matches!(self.sd, SdState::Idle) { self.refresh_cards(); } } }