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:
Julien Calixte
2026-07-14 15:56:34 +02:00
parent 99574ef00c
commit 691319cbd1
11 changed files with 3173 additions and 1 deletions

297
installer/src/app.rs Normal file
View 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();
}
}
}