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:
1659
installer/Cargo.lock
generated
Normal file
1659
installer/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
9
installer/Cargo.toml
Normal file
9
installer/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "typoena-installer"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.103"
|
||||
base64 = "0.22.1"
|
||||
ratatui = "0.30.2"
|
||||
95
installer/DESIGN.md
Normal file
95
installer/DESIGN.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Typoena installer — design
|
||||
|
||||
A self-contained macOS CLI (ratatui TUI) that prepares an SD card so a
|
||||
**pre-flashed** Typoena is ready to use the moment the card goes in. The public
|
||||
entry point is the one-liner on typoena.dev:
|
||||
|
||||
curl -fsSL https://typoena.dev/install.sh | sh
|
||||
|
||||
`install.sh` downloads this prebuilt binary; the binary does the rest. The user
|
||||
needs **no repo checkout and no Rust toolchain** — just the card.
|
||||
|
||||
## Decisions (2026-07-14)
|
||||
|
||||
- **Self-contained end-user tool.** No `just`, no typewriter checkout. The
|
||||
binary bundles what it needs (config templates, snippet catalog). The proven
|
||||
`just` bash (`firmware/justfile`) is the *reference spec* for the safety
|
||||
behaviours, ported to Rust — not shelled out to.
|
||||
- **The installer never flashes.** Devices ship **pre-flashed from
|
||||
manufacturing**; setup is SD-card-only. Firmware field updates
|
||||
(auto-update) are a **device/roadmap** concern, not the installer's — see
|
||||
[`docs/macroplan.md`](../docs/macroplan.md) (v1.x note).
|
||||
- **The card's repo is a fresh `git clone` from the remote** (HTTPS + PAT),
|
||||
written straight onto the card. There is no local source clone to mirror, so
|
||||
none of the rsync machinery applies: **no `--ff-only` refresh, no `.gitignore`
|
||||
excludes, no repack** — a fresh clone already contains only tracked files and
|
||||
is a single pack, and its origin is already the HTTPS URL we cloned from.
|
||||
- **Lives in `typewriter/installer/`**, tracked in the firmware repo so it
|
||||
versions in lockstep with the config templates + snippet catalog it ships.
|
||||
|
||||
## Phase pipeline (the wizard)
|
||||
|
||||
1. **Preflight** — a mounted card + git present. Advisory; warnings don't block.
|
||||
2. **Configure** — collect Wi-Fi SSID/pass, git remote, GitHub user, PAT, and
|
||||
commit identity. Pre-fill via the derive ladder (below).
|
||||
3. **SD card** — pick the card (refuse on ambiguity), `git clone` the remote
|
||||
onto `/repo`, seed `.typoena.toml` + snippets if absent, write
|
||||
`typoena.conf`, strip `._*`, eject.
|
||||
4. **Done** — "put the card in your Typoena and power on."
|
||||
|
||||
## Safety behaviours to keep (from `firmware/justfile` — do NOT regress)
|
||||
|
||||
- **Card-ambiguity refusal** — never guess when >1 removable volume; a wrong
|
||||
guess lets a write hit the wrong disk. Refuse and ask.
|
||||
- **`.typoena-dirty` guard** — refuse to overwrite a card that carries
|
||||
unpublished device edits; offer backup-and-discard.
|
||||
- **AppleDouble `._*`** — `dot_clean` before eject; `._pack-*.idx` corrupts the
|
||||
pack scan (Mac git *and* device libgit2).
|
||||
- **PAT never derived** — always typed; fine-grained, `contents:write` on the
|
||||
one repo; plaintext on FAT means physical custody is the control. The clone
|
||||
uses the PAT so private notes repos work.
|
||||
|
||||
_Dropped vs. the old `just load` (now moot with clone-from-remote): rsync
|
||||
mirror, `--ff-only` source refresh, `.gitignore` exclude list, `git repack -ad`._
|
||||
|
||||
## Config derive ladder (Configure step)
|
||||
|
||||
Each value: explicit input → derived from this Mac → prompt.
|
||||
`author` ← `git config user.{name,email}` · `gh_user` ← `gh api user` ·
|
||||
`ssid` ← active Wi-Fi (`networksetup`) · `wifi_pass` ← Keychain (on ^K, may
|
||||
prompt macOS) · `remote`, `pat` ← typed (PAT never derived).
|
||||
|
||||
## Architecture / crates
|
||||
|
||||
- `ratatui` + its crossterm backend — TUI.
|
||||
- `git2` — clone the remote onto the card, confirm origin. _[SD slice]_
|
||||
- Config templates + snippet catalog embedded via `include_str!`
|
||||
(self-contained).
|
||||
|
||||
## Open items (not blocking the current slices)
|
||||
|
||||
- **Hosting** — where the CLI binary lives for the `curl | sh` download (Gitea
|
||||
release vs typoena.dev static asset). Dev runs via `cargo run` meanwhile.
|
||||
- **Non-macOS** — Linux/Windows later; slice work is macOS-first.
|
||||
- **Clone target** — cloning ~hundreds of MB directly onto FAT via a reader;
|
||||
measure, and fall back to clone-to-temp-then-copy if it's too slow.
|
||||
- **Re-provision** — DONE for the destructive case: an existing card is handled
|
||||
by an explicit **wipe-and-reclone** (`y`-confirmed screen showing origin +
|
||||
HEAD + unpublished-edit count; removes only `repo/` + the dirty journal, then
|
||||
clones fresh). Follow-ups: a config-only rewrite that rotates the PAT /
|
||||
switches Wi-Fi *without* recloning (like `just provision`), and backing up
|
||||
`.typoena-dirty` edits before wiping instead of only warning.
|
||||
|
||||
## Slice plan
|
||||
|
||||
1. **App shell + Preflight** — DONE 2026-07-14. Branded wizard; card + git
|
||||
detection; `--check` headless mode.
|
||||
2. **Configure** — DONE 2026-07-14. Form + derive ladder, masked secrets,
|
||||
Keychain fill, required-field validation.
|
||||
3. **SD card** — DONE 2026-07-14 (fresh-card path). Pick card (boot disk
|
||||
excluded) → `git clone` onto it (single pack, clean HTTPS origin) → seed
|
||||
`.typoena.toml` → write `typoena.conf` → strip `._*` → eject; the long clone
|
||||
runs on a worker thread streaming progress. Verified: card detection on real
|
||||
hardware and clone + seed + conf via `--list-cards` / `--dry-run-sd`. Full
|
||||
interactive run + real write/eject await a blank card + a TTY.
|
||||
4. **install.sh + release/hosting** — checksums, polish.
|
||||
3
installer/rust-toolchain.toml
Normal file
3
installer/rust-toolchain.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Host binary — build with stable, never the `esp` channel (that's firmware/).
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
190
installer/src/config.rs
Normal file
190
installer/src/config.rs
Normal file
@@ -0,0 +1,190 @@
|
||||
//! The runtime config the device reads from the card (`typoena.conf`), plus the
|
||||
//! derive ladder that pre-fills it from this Mac. Mirrors the `_write-conf`
|
||||
//! recipe in `firmware/justfile`.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Field {
|
||||
WifiSsid,
|
||||
WifiPass,
|
||||
RemoteUrl,
|
||||
GhUser,
|
||||
Pat,
|
||||
AuthorName,
|
||||
AuthorEmail,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
pub const ALL: [Field; 7] = [
|
||||
Field::WifiSsid,
|
||||
Field::WifiPass,
|
||||
Field::RemoteUrl,
|
||||
Field::GhUser,
|
||||
Field::Pat,
|
||||
Field::AuthorName,
|
||||
Field::AuthorEmail,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Field::WifiSsid => "Wi-Fi SSID",
|
||||
Field::WifiPass => "Wi-Fi password",
|
||||
Field::RemoteUrl => "Git remote URL",
|
||||
Field::GhUser => "GitHub username",
|
||||
Field::Pat => "GitHub PAT",
|
||||
Field::AuthorName => "Commit author name",
|
||||
Field::AuthorEmail => "Commit author email",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn secret(self) -> bool {
|
||||
matches!(self, Field::WifiPass | Field::Pat)
|
||||
}
|
||||
|
||||
/// The `typoena.conf` key this field writes.
|
||||
pub fn conf_key(self) -> &'static str {
|
||||
match self {
|
||||
Field::WifiSsid => "TW_WIFI_SSID",
|
||||
Field::WifiPass => "TW_WIFI_PASS",
|
||||
Field::RemoteUrl => "TW_REMOTE_URL",
|
||||
Field::GhUser => "TW_GH_USER",
|
||||
Field::Pat => "TW_PAT",
|
||||
Field::AuthorName => "TW_AUTHOR_NAME",
|
||||
Field::AuthorEmail => "TW_AUTHOR_EMAIL",
|
||||
}
|
||||
}
|
||||
|
||||
/// Required to ship a working device (a blank one bricks). Matches the
|
||||
/// four-var guard in `firmware/build.rs` minus the runtime-defaulted author.
|
||||
pub fn required(self) -> bool {
|
||||
matches!(self, Field::WifiSsid | Field::RemoteUrl | Field::Pat)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Config {
|
||||
pub wifi_ssid: String,
|
||||
pub wifi_pass: String,
|
||||
pub remote_url: String,
|
||||
pub gh_user: String,
|
||||
pub pat: String,
|
||||
pub author_name: String,
|
||||
pub author_email: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Pre-fill from this Mac (the derive ladder). The PAT is never derived —
|
||||
/// a broad token on plaintext removable media defeats the scoped-token model.
|
||||
pub fn derived() -> Self {
|
||||
Config {
|
||||
wifi_ssid: active_wifi_ssid().unwrap_or_default(),
|
||||
wifi_pass: String::new(),
|
||||
remote_url: String::new(),
|
||||
gh_user: gh_login().unwrap_or_default(),
|
||||
pat: String::new(),
|
||||
author_name: git_config("user.name").unwrap_or_default(),
|
||||
author_email: git_config("user.email").unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, f: Field) -> &str {
|
||||
match f {
|
||||
Field::WifiSsid => &self.wifi_ssid,
|
||||
Field::WifiPass => &self.wifi_pass,
|
||||
Field::RemoteUrl => &self.remote_url,
|
||||
Field::GhUser => &self.gh_user,
|
||||
Field::Pat => &self.pat,
|
||||
Field::AuthorName => &self.author_name,
|
||||
Field::AuthorEmail => &self.author_email,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self, f: Field) -> &mut String {
|
||||
match f {
|
||||
Field::WifiSsid => &mut self.wifi_ssid,
|
||||
Field::WifiPass => &mut self.wifi_pass,
|
||||
Field::RemoteUrl => &mut self.remote_url,
|
||||
Field::GhUser => &mut self.gh_user,
|
||||
Field::Pat => &mut self.pat,
|
||||
Field::AuthorName => &mut self.author_name,
|
||||
Field::AuthorEmail => &mut self.author_email,
|
||||
}
|
||||
}
|
||||
|
||||
/// Required fields still blank (the SD step refuses to write a dead conf).
|
||||
pub fn missing_required(&self) -> Vec<Field> {
|
||||
Field::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|f| f.required() && self.get(*f).trim().is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Render the `typoena.conf` body — written to the card by the SD step.
|
||||
pub fn to_conf(&self) -> String {
|
||||
let mut s = String::new();
|
||||
s.push_str("# Typoena runtime config — generated by the installer.\n");
|
||||
s.push_str("# Plaintext secrets on removable media: keep the card safe; scope TW_PAT\n");
|
||||
s.push_str("# to contents:write on just the notes repo.\n");
|
||||
for f in Field::ALL {
|
||||
s.push_str(f.conf_key());
|
||||
s.push('=');
|
||||
s.push_str(self.get(f));
|
||||
s.push('\n');
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
fn git_config(key: &str) -> Option<String> {
|
||||
non_empty(Command::new("git").args(["config", key]).output().ok()?)
|
||||
}
|
||||
|
||||
fn gh_login() -> Option<String> {
|
||||
non_empty(
|
||||
Command::new("gh")
|
||||
.args(["api", "user", "--jq", ".login"])
|
||||
.output()
|
||||
.ok()?,
|
||||
)
|
||||
}
|
||||
|
||||
fn active_wifi_ssid() -> Option<String> {
|
||||
// The Wi-Fi device on a Mac is usually en0; ask networksetup for its SSID.
|
||||
let out = Command::new("networksetup")
|
||||
.args(["-getairportnetwork", "en0"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("Current Wi-Fi Network: "))
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Look up a saved Wi-Fi password in the System keychain. Pops a macOS auth
|
||||
/// dialog, so call only on explicit user action. Mirrors the justfile's
|
||||
/// `security find-generic-password -wa <ssid>`.
|
||||
pub fn keychain_wifi_password(ssid: &str) -> Option<String> {
|
||||
if ssid.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
non_empty(
|
||||
Command::new("security")
|
||||
.args(["find-generic-password", "-wa", ssid])
|
||||
.output()
|
||||
.ok()?,
|
||||
)
|
||||
}
|
||||
|
||||
fn non_empty(out: std::process::Output) -> Option<String> {
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let v = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
(!v.is_empty()).then_some(v)
|
||||
}
|
||||
120
installer/src/main.rs
Normal file
120
installer/src/main.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
mod app;
|
||||
mod config;
|
||||
mod preflight;
|
||||
mod sdcard;
|
||||
mod ui;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use app::App;
|
||||
use config::{Config, Field};
|
||||
use preflight::{Preflight, Status};
|
||||
use ratatui::crossterm::event::{self, Event, KeyEventKind};
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
// Headless preflight + derived config (scriptable, no TTY needed).
|
||||
if args.iter().any(|a| a == "--check") {
|
||||
return run_check();
|
||||
}
|
||||
// Read-only: list the removable cards the SD step would offer.
|
||||
if args.iter().any(|a| a == "--list-cards") {
|
||||
return list_cards();
|
||||
}
|
||||
// Verify the (optionally wipe +) clone + config-write path without a card
|
||||
// (clones to a temp dir, no eject).
|
||||
// Usage: --dry-run-sd <remote-url> [dest-dir] [--wipe]
|
||||
if args.iter().any(|a| a == "--dry-run-sd") {
|
||||
let wipe = args.iter().any(|a| a == "--wipe");
|
||||
// positional args (flags stripped): [0] = remote, [1] = optional dest
|
||||
let positionals: Vec<String> = args
|
||||
.iter()
|
||||
.skip(1)
|
||||
.filter(|a| !a.starts_with("--"))
|
||||
.cloned()
|
||||
.collect();
|
||||
let remote = positionals.first().cloned().unwrap_or_default();
|
||||
if remote.is_empty() {
|
||||
anyhow::bail!("usage: --dry-run-sd <remote-url> [dest-dir] [--wipe]");
|
||||
}
|
||||
let dest = positionals
|
||||
.get(1)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| std::env::temp_dir().join("typoena-dryrun"));
|
||||
println!(
|
||||
"dry-run SD provision{}: clone {remote} → {}/repo",
|
||||
if wipe { " (wipe first)" } else { "" },
|
||||
dest.display()
|
||||
);
|
||||
return sdcard::dry_run(&remote, &dest, wipe);
|
||||
}
|
||||
|
||||
let mut terminal = ratatui::init();
|
||||
let result = run(&mut terminal);
|
||||
ratatui::restore();
|
||||
result
|
||||
}
|
||||
|
||||
fn run(terminal: &mut ratatui::DefaultTerminal) -> anyhow::Result<()> {
|
||||
let mut app = App::new();
|
||||
while !app.should_quit {
|
||||
app.drain_worker();
|
||||
terminal.draw(|frame| ui::render(frame, &app))?;
|
||||
// Poll so worker progress can repaint even without a keypress.
|
||||
if event::poll(Duration::from_millis(100))?
|
||||
&& let Event::Key(key) = event::read()?
|
||||
&& key.kind == KeyEventKind::Press
|
||||
{
|
||||
app.on_key(key);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_check() -> anyhow::Result<()> {
|
||||
let pf = Preflight::run();
|
||||
for c in &pf.checks {
|
||||
let tag = match c.status {
|
||||
Status::Ok => "OK ",
|
||||
Status::Warn => "WARN",
|
||||
Status::Missing => "MISS",
|
||||
};
|
||||
println!("[{tag}] {:<16} {}", c.label, c.detail);
|
||||
}
|
||||
println!("ready: {}", pf.ready());
|
||||
|
||||
println!("--- derived config (secrets hidden) ---");
|
||||
let cfg = Config::derived();
|
||||
for f in Field::ALL {
|
||||
let v = cfg.get(f);
|
||||
let shown = if f.secret() {
|
||||
if v.is_empty() { "(unset)" } else { "(set)" }
|
||||
} else if v.is_empty() {
|
||||
"(unset)"
|
||||
} else {
|
||||
v
|
||||
};
|
||||
println!(" {:<22} {}", f.label(), shown);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn list_cards() -> anyhow::Result<()> {
|
||||
let cards = sdcard::detect_cards();
|
||||
if cards.is_empty() {
|
||||
println!("no removable card detected under /Volumes");
|
||||
return Ok(());
|
||||
}
|
||||
for c in cards {
|
||||
let fat = if c.fat {
|
||||
"FAT"
|
||||
} else {
|
||||
"NOT FAT — device may not mount"
|
||||
};
|
||||
println!("{} [{}] ({})", c.name, c.fs, fat);
|
||||
println!(" {}", c.volume.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
83
installer/src/preflight.rs
Normal file
83
installer/src/preflight.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
//! Environment detection for the Preflight step.
|
||||
//!
|
||||
//! The device ships pre-flashed, so setup is SD-card-only: this checks what the
|
||||
//! card prep needs — a mounted card and git. Everything here is advisory; a
|
||||
//! warning informs the user, it never blocks. The real, destructive card work
|
||||
//! (diskutil, rsync) lands in the SD-card step; this only reports what's there.
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
Ok,
|
||||
Warn,
|
||||
Missing,
|
||||
}
|
||||
|
||||
pub struct Check {
|
||||
pub label: &'static str,
|
||||
pub status: Status,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
pub struct Preflight {
|
||||
pub checks: Vec<Check>,
|
||||
}
|
||||
|
||||
impl Preflight {
|
||||
pub fn run() -> Self {
|
||||
Preflight {
|
||||
checks: vec![detect_sd_card(), detect_git()],
|
||||
}
|
||||
}
|
||||
|
||||
/// Nothing is outright `Missing` (warnings are allowed to pass).
|
||||
pub fn ready(&self) -> bool {
|
||||
self.checks.iter().all(|c| c.status != Status::Missing)
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_sd_card() -> Check {
|
||||
let label = "SD card";
|
||||
let vols: Vec<String> = std::fs::read_dir("/Volumes")
|
||||
.map(|rd| {
|
||||
rd.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| !n.starts_with('.'))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// Slice-level detection only reports what's mounted; true removable/FAT
|
||||
// identification (diskutil) — and the ambiguity refusal — land with the
|
||||
// SD-card step.
|
||||
match vols.len() {
|
||||
0 => Check {
|
||||
label,
|
||||
status: Status::Missing,
|
||||
detail: "no volumes under /Volumes — insert a card".into(),
|
||||
},
|
||||
_ => Check {
|
||||
label,
|
||||
status: Status::Warn,
|
||||
detail: format!(
|
||||
"{} volume(s): {} — the card is chosen in the SD-card step",
|
||||
vols.len(),
|
||||
vols.join(", ")
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_git() -> Check {
|
||||
let label = "git";
|
||||
match std::process::Command::new("git").arg("--version").output() {
|
||||
Ok(o) if o.status.success() => Check {
|
||||
label,
|
||||
status: Status::Ok,
|
||||
detail: String::from_utf8_lossy(&o.stdout).trim().to_string(),
|
||||
},
|
||||
_ => Check {
|
||||
label,
|
||||
status: Status::Warn,
|
||||
detail: "not found — needed to clone your notes repo (xcode-select --install)".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
284
installer/src/sdcard.rs
Normal file
284
installer/src/sdcard.rs
Normal file
@@ -0,0 +1,284 @@
|
||||
//! SD-card provisioning: pick the card, clone the notes repo onto it, seed the
|
||||
//! git-tracked prefs, write `typoena.conf`, and eject. Ports the safety
|
||||
//! behaviours of the `just init`/`load` recipes; the repo copy is a fresh clone
|
||||
//! from the remote (no rsync / .gitignore excludes / repack — see DESIGN.md).
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use anyhow::{Context, bail};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
|
||||
/// A candidate removable volume.
|
||||
pub struct Card {
|
||||
pub volume: PathBuf,
|
||||
pub name: String,
|
||||
pub fs: String,
|
||||
pub fat: bool,
|
||||
}
|
||||
|
||||
/// Read-only summary of an already-provisioned card, shown on the wipe-confirm
|
||||
/// screen so the user sees exactly what they're about to erase.
|
||||
pub struct CardInspect {
|
||||
pub origin: Option<String>,
|
||||
pub head: Option<String>,
|
||||
pub dirty: usize,
|
||||
}
|
||||
|
||||
pub struct Plan {
|
||||
pub remote: String,
|
||||
pub pat: String,
|
||||
pub card_volume: PathBuf,
|
||||
pub conf_body: String,
|
||||
/// Erase an existing `repo/` + dirty journal before cloning.
|
||||
pub wipe: bool,
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
fn repo_dir(&self) -> PathBuf {
|
||||
self.card_volume.join("repo")
|
||||
}
|
||||
fn conf_path(&self) -> PathBuf {
|
||||
self.card_volume.join("typoena.conf")
|
||||
}
|
||||
}
|
||||
|
||||
pub enum SdEvent {
|
||||
Log(String),
|
||||
Done(Result<(), String>),
|
||||
}
|
||||
|
||||
/// Detect removable/SD volumes under /Volumes (via diskutil). Mirrors the
|
||||
/// justfile `_card` heuristics; the internal boot disk never matches.
|
||||
pub fn detect_cards() -> Vec<Card> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(rd) = std::fs::read_dir("/Volumes") else {
|
||||
return out;
|
||||
};
|
||||
for entry in rd.flatten() {
|
||||
let vol = entry.path();
|
||||
if !vol.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let info = match Command::new("diskutil").arg("info").arg(&vol).output() {
|
||||
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(),
|
||||
_ => continue,
|
||||
};
|
||||
if !is_removable(&info) {
|
||||
continue;
|
||||
}
|
||||
let fs = field(&info, "File System Personality").unwrap_or_default();
|
||||
let up = fs.to_uppercase();
|
||||
out.push(Card {
|
||||
name: vol
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default(),
|
||||
fat: up.contains("FAT") || up.contains("MS-DOS"),
|
||||
fs,
|
||||
volume: vol,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn is_removable(info: &str) -> bool {
|
||||
// Test the VALUE of each key, not the whole line: the label "Removable
|
||||
// Media" itself contains "Removable", so a line-substring test matches every
|
||||
// disk — including the internal boot volume (found on real hardware, 07-14).
|
||||
let val = |k| field(info, k).unwrap_or_default();
|
||||
val("Protocol").contains("Secure Digital")
|
||||
|| val("Removable Media").contains("Removable")
|
||||
|| val("Ejectable") == "Yes"
|
||||
|| val("Device Location") == "External"
|
||||
}
|
||||
|
||||
fn field(info: &str, key: &str) -> Option<String> {
|
||||
info.lines().find_map(|l| {
|
||||
let rest = l.trim().strip_prefix(key)?.trim_start();
|
||||
let val = rest.strip_prefix(':')?.trim();
|
||||
(!val.is_empty()).then(|| val.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// True if the card already carries a working copy at `repo/`.
|
||||
pub fn card_has_repo(vol: &Path) -> bool {
|
||||
vol.join("repo").exists()
|
||||
}
|
||||
|
||||
/// Read-only inspection of an existing card (origin, HEAD, unpublished-edit count).
|
||||
pub fn inspect_card(vol: &Path) -> CardInspect {
|
||||
let repo = vol.join("repo");
|
||||
let git = |args: &[&str]| -> Option<String> {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(args)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let v = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
(!v.is_empty()).then_some(v)
|
||||
};
|
||||
CardInspect {
|
||||
origin: git(&["remote", "get-url", "origin"]),
|
||||
head: git(&["rev-parse", "--short", "HEAD"]),
|
||||
dirty: std::fs::read_to_string(vol.join(".typoena-dirty"))
|
||||
.map(|s| s.lines().filter(|l| !l.trim().is_empty()).count())
|
||||
.unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the full provision on a worker thread, streaming progress to `tx`.
|
||||
pub fn run_provision(plan: Plan, tx: Sender<SdEvent>) {
|
||||
let mut log = |m: String| {
|
||||
let _ = tx.send(SdEvent::Log(m));
|
||||
};
|
||||
let res = provision(&plan, &mut log).map_err(|e| format!("{e:#}"));
|
||||
let _ = tx.send(SdEvent::Done(res));
|
||||
}
|
||||
|
||||
fn provision(plan: &Plan, log: &mut dyn FnMut(String)) -> anyhow::Result<()> {
|
||||
if plan.wipe {
|
||||
wipe_card(&plan.card_volume, log)?;
|
||||
}
|
||||
clone(&plan.remote, &plan.repo_dir(), &plan.pat, log)?;
|
||||
log("seeding .typoena.toml (if absent)…".into());
|
||||
seed_prefs(&plan.repo_dir())?;
|
||||
log(format!("writing {}", plan.conf_path().display()));
|
||||
std::fs::write(plan.conf_path(), &plan.conf_body).context("writing typoena.conf")?;
|
||||
log("stripping AppleDouble ._ files…".into());
|
||||
dot_clean(&plan.card_volume);
|
||||
log("ejecting…".into());
|
||||
match eject(&plan.card_volume) {
|
||||
Ok(()) => log("card ejected — remove it and insert into Typoena.".into()),
|
||||
Err(e) => log(format!(
|
||||
"⚠ could not eject ({e}); eject from Finder before removing."
|
||||
)),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Erase an existing working copy before a re-provision. Only ever removes
|
||||
/// `repo/` and the `.typoena-dirty` journal — never the volume itself, `ca.pem`,
|
||||
/// or `/local`. The path guard rejects a bogus (root/empty) volume.
|
||||
fn wipe_card(vol: &Path, log: &mut dyn FnMut(String)) -> anyhow::Result<()> {
|
||||
if !vol.is_dir() || vol.parent().is_none() {
|
||||
bail!(
|
||||
"refusing to wipe: '{}' is not a mounted volume",
|
||||
vol.display()
|
||||
);
|
||||
}
|
||||
let repo = vol.join("repo");
|
||||
log(format!("wiping {} …", repo.display()));
|
||||
if repo.exists() {
|
||||
std::fs::remove_dir_all(&repo).with_context(|| format!("removing {}", repo.display()))?;
|
||||
}
|
||||
let _ = std::fs::remove_file(vol.join(".typoena-dirty"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clone `remote` into `dest` with the system git. The PAT (if any) rides in an
|
||||
/// HTTP Authorization header, so it never lands in the cloned repo's origin URL
|
||||
/// — origin stays the clean HTTPS URL the device authenticates against.
|
||||
fn clone(remote: &str, dest: &Path, pat: &str, log: &mut dyn FnMut(String)) -> anyhow::Result<()> {
|
||||
if dest.exists() {
|
||||
bail!(
|
||||
"{} already exists — wipe the card first, or use a fresh one",
|
||||
dest.display()
|
||||
);
|
||||
}
|
||||
log(format!("cloning {remote} → {}", dest.display()));
|
||||
let mut cmd = Command::new("git");
|
||||
if !pat.is_empty() {
|
||||
let token = STANDARD.encode(format!("x-access-token:{pat}"));
|
||||
cmd.arg("-c")
|
||||
.arg(format!("http.extraHeader=Authorization: Basic {token}"));
|
||||
}
|
||||
cmd.arg("clone")
|
||||
.arg("--progress")
|
||||
.arg(remote)
|
||||
.arg(dest)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let mut child = cmd.spawn().context("spawning git (is it installed?)")?;
|
||||
if let Some(err) = child.stderr.take() {
|
||||
for line in BufReader::new(err).lines().map_while(Result::ok) {
|
||||
let line = line.trim_end();
|
||||
if !line.is_empty() {
|
||||
log(line.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
let status = child.wait().context("waiting for git clone")?;
|
||||
if !status.success() {
|
||||
bail!("git clone failed (exit {:?})", status.code());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const PREFS_TEMPLATE: &str = "\
|
||||
# Typoena editor preferences — hand-editable, git-tracked.
|
||||
save_on_idle = true
|
||||
format_on_save = true
|
||||
line_numbers = true
|
||||
theme = \"light\"
|
||||
auto_sync = \"10m\"
|
||||
";
|
||||
|
||||
/// Seed a starter `.typoena.toml` only if the cloned repo doesn't already carry
|
||||
/// one (a repo with a synced prefs file keeps its own). Mirrors `_seed-configs`.
|
||||
fn seed_prefs(repo_dir: &Path) -> anyhow::Result<()> {
|
||||
let p = repo_dir.join(".typoena.toml");
|
||||
if p.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::write(&p, PREFS_TEMPLATE).with_context(|| format!("seeding {}", p.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dot_clean(vol: &Path) {
|
||||
// Best-effort: strip the AppleDouble `._` companions macOS writes on FAT,
|
||||
// which otherwise corrupt the pack scan (`._pack-*.idx`). Failure never blocks.
|
||||
let _ = Command::new("dot_clean").arg("-m").arg(vol).status();
|
||||
}
|
||||
|
||||
fn eject(vol: &Path) -> anyhow::Result<()> {
|
||||
let _ = Command::new("sync").status();
|
||||
let status = Command::new("diskutil")
|
||||
.arg("eject")
|
||||
.arg(vol)
|
||||
.status()
|
||||
.context("running diskutil eject")?;
|
||||
if !status.success() {
|
||||
bail!("diskutil eject exited {:?}", status.code());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Headless verification: (optionally wipe) + clone + seed + write a sample conf
|
||||
/// into `dest`, with no card selection and no eject. Backs `--dry-run-sd`.
|
||||
pub fn dry_run(remote: &str, dest: &Path, wipe: bool) -> anyhow::Result<()> {
|
||||
let plan = Plan {
|
||||
remote: remote.to_string(),
|
||||
pat: String::new(),
|
||||
card_volume: dest.to_path_buf(),
|
||||
conf_body: "# sample typoena.conf (dry run)\nTW_WIFI_SSID=example\n".to_string(),
|
||||
wipe,
|
||||
};
|
||||
let mut log = |m: String| println!(" {m}");
|
||||
if plan.wipe {
|
||||
wipe_card(&plan.card_volume, &mut log)?;
|
||||
}
|
||||
clone(&plan.remote, &plan.repo_dir(), &plan.pat, &mut log)?;
|
||||
log("seeding .typoena.toml (if absent)…".into());
|
||||
seed_prefs(&plan.repo_dir())?;
|
||||
log(format!("writing {}", plan.conf_path().display()));
|
||||
std::fs::write(plan.conf_path(), &plan.conf_body).context("writing typoena.conf")?;
|
||||
log("dry run complete (no card write, no eject).".into());
|
||||
Ok(())
|
||||
}
|
||||
414
installer/src/ui.rs
Normal file
414
installer/src/ui.rs
Normal file
@@ -0,0 +1,414 @@
|
||||
//! Rendering. Theme-agnostic: no hard-coded background (the terminal's own),
|
||||
//! bold/reverse for emphasis, and the conventional green/yellow/red for status
|
||||
//! so it reads on any color scheme.
|
||||
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Constraint, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, List, ListItem, Paragraph, Wrap},
|
||||
};
|
||||
|
||||
use crate::app::{App, SdState, Step};
|
||||
use crate::config::Field;
|
||||
use crate::preflight::Status;
|
||||
|
||||
pub fn render(frame: &mut Frame, app: &App) {
|
||||
let [header, body, footer] = Layout::vertical([
|
||||
Constraint::Length(2),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.areas(frame.area());
|
||||
|
||||
render_header(frame, header);
|
||||
let [steps, main] =
|
||||
Layout::horizontal([Constraint::Length(22), Constraint::Min(0)]).areas(body);
|
||||
render_steps(frame, steps, app);
|
||||
render_main(frame, main, app);
|
||||
render_footer(frame, footer, app);
|
||||
}
|
||||
|
||||
fn render_header(frame: &mut Frame, area: Rect) {
|
||||
let title = Line::from(vec![
|
||||
Span::styled("TYPOENA", Style::new().add_modifier(Modifier::BOLD)),
|
||||
Span::styled(" installer", Style::new().fg(Color::DarkGray)),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(title), area);
|
||||
}
|
||||
|
||||
fn render_steps(frame: &mut Frame, area: Rect, app: &App) {
|
||||
let items: Vec<ListItem> = Step::ALL
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &s)| {
|
||||
let active = s == app.step;
|
||||
let marker = if active { "▸ " } else { " " };
|
||||
let style = if active {
|
||||
Style::new().add_modifier(Modifier::BOLD | Modifier::REVERSED)
|
||||
} else {
|
||||
Style::new().fg(Color::DarkGray)
|
||||
};
|
||||
ListItem::new(Line::styled(
|
||||
format!("{marker}{}. {}", i + 1, s.title()),
|
||||
style,
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
frame.render_widget(
|
||||
List::new(items).block(Block::bordered().title(" steps ")),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_main(frame: &mut Frame, area: Rect, app: &App) {
|
||||
let block = Block::bordered().title(format!(" {} ", app.step.title()));
|
||||
match app.step {
|
||||
Step::Preflight => render_preflight(frame, area, app, block),
|
||||
Step::Configure => render_configure(frame, area, app, block),
|
||||
Step::SdCard => render_sdcard(frame, area, app, block),
|
||||
Step::Done => render_done(frame, area, block),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_preflight(frame: &mut Frame, area: Rect, app: &App, block: Block) {
|
||||
let mut lines = vec![
|
||||
Line::styled(
|
||||
"Checking your Mac and the card.",
|
||||
Style::new().fg(Color::DarkGray),
|
||||
),
|
||||
Line::from(""),
|
||||
];
|
||||
for c in &app.preflight.checks {
|
||||
let (glyph, color) = status_glyph(c.status);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {glyph} "),
|
||||
Style::new().fg(color).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!("{:<16}", c.label),
|
||||
Style::new().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(c.detail.clone(), Style::new().fg(Color::DarkGray)),
|
||||
]));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
lines.push(if app.preflight.ready() {
|
||||
Line::styled(
|
||||
"Ready. Press Enter to continue.",
|
||||
Style::new().fg(Color::Green),
|
||||
)
|
||||
} else {
|
||||
Line::styled(
|
||||
"Fix the ✗ items, then press r to re-check.",
|
||||
Style::new().fg(Color::Yellow),
|
||||
)
|
||||
});
|
||||
frame.render_widget(paragraph(lines, block), area);
|
||||
}
|
||||
|
||||
fn render_configure(frame: &mut Frame, area: Rect, app: &App, block: Block) {
|
||||
let mut lines: Vec<Line> = vec![
|
||||
Line::styled(
|
||||
"Pre-filled from this Mac where possible. Type to edit · ↑/↓ move · Enter next field.",
|
||||
Style::new().fg(Color::DarkGray),
|
||||
),
|
||||
Line::from(""),
|
||||
];
|
||||
|
||||
for (i, &f) in Field::ALL.iter().enumerate() {
|
||||
let focused = i == app.focus;
|
||||
let val = app.config.get(f);
|
||||
let empty = val.trim().is_empty();
|
||||
let shown: String = if f.secret() && !empty {
|
||||
"•".repeat(val.chars().count())
|
||||
} else {
|
||||
val.to_string()
|
||||
};
|
||||
|
||||
let mut spans = vec![
|
||||
Span::styled(
|
||||
if focused { "▸ " } else { " " },
|
||||
Style::new().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!("{:<22}", f.label()),
|
||||
if focused {
|
||||
Style::new().add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::new()
|
||||
},
|
||||
),
|
||||
];
|
||||
if focused {
|
||||
spans.push(Span::raw(shown));
|
||||
spans.push(Span::styled(
|
||||
" ",
|
||||
Style::new().add_modifier(Modifier::REVERSED),
|
||||
)); // block caret
|
||||
} else if empty {
|
||||
let (text, color) = if f.required() {
|
||||
("(required)", Color::Yellow)
|
||||
} else {
|
||||
("(optional)", Color::DarkGray)
|
||||
};
|
||||
spans.push(Span::styled(text, Style::new().fg(color)));
|
||||
} else {
|
||||
spans.push(Span::raw(shown));
|
||||
}
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
|
||||
lines.push(Line::from(""));
|
||||
if let Some(msg) = &app.status {
|
||||
lines.push(Line::styled(msg.clone(), Style::new().fg(Color::Cyan)));
|
||||
} else {
|
||||
let missing = app.config.missing_required();
|
||||
if missing.is_empty() {
|
||||
lines.push(Line::styled(
|
||||
"All required fields set — Enter on the last field goes to the SD-card step.",
|
||||
Style::new().fg(Color::Green),
|
||||
));
|
||||
} else {
|
||||
let names: Vec<&str> = missing.iter().map(|f| f.label()).collect();
|
||||
lines.push(Line::styled(
|
||||
format!("Required still empty: {}", names.join(", ")),
|
||||
Style::new().fg(Color::Yellow),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(hint) = field_hint(app.focused_field()) {
|
||||
lines.push(Line::styled(hint, Style::new().fg(Color::DarkGray)));
|
||||
}
|
||||
|
||||
frame.render_widget(paragraph(lines, block), area);
|
||||
}
|
||||
|
||||
fn render_sdcard(frame: &mut Frame, area: Rect, app: &App, block: Block) {
|
||||
let dim = |s: String| Line::styled(s, Style::new().fg(Color::DarkGray));
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
match &app.sd {
|
||||
SdState::ConfirmWipe(info) => {
|
||||
let vol = app
|
||||
.cards
|
||||
.get(app.card_sel.min(app.cards.len().saturating_sub(1)))
|
||||
.map(|c| c.volume.display().to_string())
|
||||
.unwrap_or_default();
|
||||
lines.push(Line::styled(
|
||||
format!("⚠ {vol} already holds a Typoena card."),
|
||||
Style::new().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
lines.push(Line::from(""));
|
||||
if let Some(o) = &info.origin {
|
||||
lines.push(dim(format!(" origin: {o}")));
|
||||
}
|
||||
if let Some(h) = &info.head {
|
||||
lines.push(dim(format!(" HEAD: {h}")));
|
||||
}
|
||||
if info.dirty > 0 {
|
||||
lines.push(Line::styled(
|
||||
format!(
|
||||
" {} unpublished edit(s) will be LOST (not yet published from the device).",
|
||||
info.dirty
|
||||
),
|
||||
Style::new().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(format!(
|
||||
"This ERASES repo/ (and the dirty journal) and re-clones {} onto the card.",
|
||||
app.config.remote_url
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::styled(
|
||||
"Press y to wipe and continue · n to cancel.",
|
||||
Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
SdState::Idle => {
|
||||
if app.cards.is_empty() {
|
||||
lines.push(Line::styled(
|
||||
"No removable card detected.",
|
||||
Style::new().fg(Color::Yellow),
|
||||
));
|
||||
lines.push(Line::from(""));
|
||||
lines.push(dim("Insert a FAT32 SD card, then press r to rescan.".into()));
|
||||
} else {
|
||||
lines.push(dim("Choose the card to write (↑/↓), then Enter:".into()));
|
||||
lines.push(Line::from(""));
|
||||
for (i, c) in app.cards.iter().enumerate() {
|
||||
let sel = i == app.card_sel;
|
||||
let marker = if sel { "▸ " } else { " " };
|
||||
let warn = if c.fat {
|
||||
String::new()
|
||||
} else {
|
||||
" (not FAT32 — the device may not mount it)".to_string()
|
||||
};
|
||||
let style = if sel {
|
||||
Style::new().add_modifier(Modifier::BOLD | Modifier::REVERSED)
|
||||
} else {
|
||||
Style::new()
|
||||
};
|
||||
lines.push(Line::styled(
|
||||
format!("{marker}{} [{}]{}", c.name, c.fs, warn),
|
||||
style,
|
||||
));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
if app.config.missing_required().is_empty() {
|
||||
lines.push(Line::styled(
|
||||
"Enter writes the card: clone → seed config → typoena.conf → eject.",
|
||||
Style::new().fg(Color::Green),
|
||||
));
|
||||
} else {
|
||||
lines.push(Line::styled(
|
||||
"Configure the required fields first (↑ to go back a step).",
|
||||
Style::new().fg(Color::Yellow),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(msg) = &app.status {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::styled(msg.clone(), Style::new().fg(Color::Cyan)));
|
||||
}
|
||||
}
|
||||
rest => {
|
||||
lines.push(match rest {
|
||||
SdState::Running => Line::styled(
|
||||
"Provisioning the card… (Ctrl-C aborts)",
|
||||
Style::new().fg(Color::Yellow),
|
||||
),
|
||||
SdState::Failed(e) => {
|
||||
Line::styled(format!("Failed: {e}"), Style::new().fg(Color::Red))
|
||||
}
|
||||
_ => Line::styled(
|
||||
"Card ready ✓ — remove it and insert into Typoena.",
|
||||
Style::new().fg(Color::Green),
|
||||
),
|
||||
});
|
||||
lines.push(Line::from(""));
|
||||
let start = app.sd_log.len().saturating_sub(12);
|
||||
for l in &app.sd_log[start..] {
|
||||
lines.push(dim(l.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
frame.render_widget(paragraph(lines, block), area);
|
||||
}
|
||||
|
||||
fn render_done(frame: &mut Frame, area: Rect, block: Block) {
|
||||
let lines = vec![
|
||||
Line::styled(
|
||||
"All set.",
|
||||
Style::new().fg(Color::Green).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Line::from(""),
|
||||
Line::from("Remove the card and insert it into your Typoena, then power on."),
|
||||
Line::styled(
|
||||
"Open lid → write → push. Nothing else runs on it.",
|
||||
Style::new().fg(Color::DarkGray),
|
||||
),
|
||||
];
|
||||
frame.render_widget(paragraph(lines, block), area);
|
||||
}
|
||||
|
||||
fn field_hint(f: Field) -> Option<&'static str> {
|
||||
match f {
|
||||
Field::Pat => Some(
|
||||
"Fine-grained PAT, contents:write on the notes repo. Never derived; masked. ^U clears.",
|
||||
),
|
||||
Field::WifiPass => {
|
||||
Some("^K fills this from your Keychain for the current SSID (may prompt macOS).")
|
||||
}
|
||||
Field::RemoteUrl => {
|
||||
Some("HTTPS URL of your notes repo, e.g. https://github.com/you/notes.git")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn status_glyph(s: Status) -> (&'static str, Color) {
|
||||
match s {
|
||||
Status::Ok => ("✓", Color::Green),
|
||||
Status::Warn => ("!", Color::Yellow),
|
||||
Status::Missing => ("✗", Color::Red),
|
||||
}
|
||||
}
|
||||
|
||||
fn paragraph<'a>(lines: Vec<Line<'a>>, block: Block<'a>) -> Paragraph<'a> {
|
||||
Paragraph::new(Text::from(lines))
|
||||
.block(block)
|
||||
.wrap(Wrap { trim: false })
|
||||
}
|
||||
|
||||
fn render_footer(frame: &mut Frame, area: Rect, app: &App) {
|
||||
let key = |k: &str| {
|
||||
Span::styled(
|
||||
format!(" {k} "),
|
||||
Style::new().add_modifier(Modifier::REVERSED),
|
||||
)
|
||||
};
|
||||
let lbl = |l: &'static str| Span::styled(l, Style::new().fg(Color::DarkGray));
|
||||
let sep = || Span::raw(" ");
|
||||
|
||||
let spans = if matches!(app.sd, SdState::ConfirmWipe(_)) && app.step == Step::SdCard {
|
||||
vec![
|
||||
key("y"),
|
||||
lbl(" wipe & continue"),
|
||||
sep(),
|
||||
key("n"),
|
||||
lbl(" cancel"),
|
||||
sep(),
|
||||
key("^C"),
|
||||
lbl(" quit"),
|
||||
]
|
||||
} else {
|
||||
match app.step {
|
||||
Step::Configure => vec![
|
||||
key("↑/↓"),
|
||||
lbl(" field"),
|
||||
sep(),
|
||||
key("Enter"),
|
||||
lbl(" next"),
|
||||
sep(),
|
||||
key("^K"),
|
||||
lbl(" wifi pw"),
|
||||
sep(),
|
||||
key("^U"),
|
||||
lbl(" clear"),
|
||||
sep(),
|
||||
key("Esc"),
|
||||
lbl(" quit"),
|
||||
],
|
||||
Step::SdCard => vec![
|
||||
key("↑/↓"),
|
||||
lbl(" card"),
|
||||
sep(),
|
||||
key("r"),
|
||||
lbl(" rescan"),
|
||||
sep(),
|
||||
key("Enter"),
|
||||
lbl(" write"),
|
||||
sep(),
|
||||
key("q"),
|
||||
lbl(" quit"),
|
||||
],
|
||||
_ => vec![
|
||||
key("↑/↓ Tab"),
|
||||
lbl(" step"),
|
||||
sep(),
|
||||
key("Enter"),
|
||||
lbl(" next"),
|
||||
sep(),
|
||||
key("r"),
|
||||
lbl(" re-check"),
|
||||
sep(),
|
||||
key("q"),
|
||||
lbl(" quit"),
|
||||
],
|
||||
}
|
||||
};
|
||||
frame.render_widget(Paragraph::new(Line::from(spans)), area);
|
||||
}
|
||||
Reference in New Issue
Block a user