A ratatui TUI that provisions the SD card for a pre-flashed Typoena: Preflight → Configure → SD card → Done. Clones the notes repo onto the card with system git (PAT in http.extraHeader, never in origin), seeds .typoena.toml, writes typoena.conf, strips AppleDouble, ejects. An already-provisioned card is handled by a y-confirmed wipe-and-reclone that only ever removes repo/ + the dirty journal. This is the target of typoena.dev's `curl … | sh`. Also records firmware auto-update options in docs/macroplan.md (v1.x).
298 lines
9.2 KiB
Rust
298 lines
9.2 KiB
Rust
//! 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<String>,
|
|
// ── SD-card step ──
|
|
pub cards: Vec<Card>,
|
|
pub card_sel: usize,
|
|
pub sd: SdState,
|
|
pub sd_log: Vec<String>,
|
|
sd_rx: Option<Receiver<SdEvent>>,
|
|
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<std::path::PathBuf> {
|
|
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();
|
|
}
|
|
}
|
|
}
|