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

1659
installer/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

9
installer/Cargo.toml Normal file
View 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
View 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.

View 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
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();
}
}
}

190
installer/src/config.rs Normal file
View 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
View 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(())
}

View 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
View 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
View 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);
}