feat(installer): self-contained macOS SD-card setup CLI
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).
This commit is contained in:
297
installer/src/app.rs
Normal file
297
installer/src/app.rs
Normal file
@@ -0,0 +1,297 @@
|
||||
//! 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user