Skip to main content

max / alloy

Split install's view into its step machine and its typing panes view.rs carried the wizard's step ordering, four panes that take typing, and the screen itself in one impl block. steps.rs holds the ordering and the walk past questions the medium already answered; fields.rs holds the hostname, account, encryption and recovery panes with the field-index constants that keep the render and key paths agreeing about which slot is which. Tests stay with the view: they drive InstallView through keypresses, which is view-level behaviour whichever pane the keys land in.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01WFBzMprSmNCfvdj2cGZyka
Author: Max Johnson <me@maxj.phd> · 2026-09-08 17:23 UTC
Signed with PGP, not checked
Commit: be174985fbd26ec85db4ced58dd152f0a62e1a2a
Parent: 2337315
5 files changed, +565 insertions, -455 deletions
@@ -6,7 +6,7 @@
6 6 //! field so the panes render the same on any machine.
7 7
8 8 use std::sync::Arc;
9 - use std::sync::atomic::{AtomicBool, Ordering};
9 + use std::sync::atomic::AtomicBool;
10 10 use std::time::Duration;
11 11
12 12 use alloy_tui::{AlloyBlock, AlloyList, Cursor, FocusRing, Hint, Severity, Theme, hint, text};
@@ -27,77 +27,18 @@
27 27 use crate::wizard::Steps;
28 28
29 29 use super::answers::{
30 - Answers, BAKED_KEY, DEFAULT_HOSTNAME, FromMedium, baked_pubkey_at, phrase_matches,
31 - validate_hostname, validate_passphrase, validate_password, validate_pubkey, validate_username,
30 + Answers, BAKED_KEY, DEFAULT_HOSTNAME, FromMedium, baked_pubkey_at, validate_hostname,
32 31 };
33 32 use super::disks::{Backend, Disk, detect};
34 33 use super::encryption::{EncryptionChoice, wreck_plan};
35 34 use super::plan::{Choices, install_plan, recover_plan};
36 35
36 + use fields::{ACCOUNT_FIELDS, ENCRYPT_SLOTS, HOSTNAME_SLOTS};
37 + use steps::{STEPS, Step};
38 +
39 + mod fields;
37 40 mod render;
38 -
39 - /// The questions, in the order they are asked.
40 - ///
41 - /// The disk comes first because it is the one that can be wrong in a way
42 - /// nothing later recovers from, and because a user who cannot see their disk in
43 - /// the list should find that out before typing anything.
44 - /// Encryption sits after the account rather than beside the disk it applies to.
45 - /// Both screens that take a secret are then adjacent, so the passphrases are
46 - /// typed in one stretch, and the review comes last of the questions.
47 - ///
48 - /// Credits sits after the review, which puts a screen between the summary and
49 - /// the disk being erased. That is a side effect rather than the reason: the
50 - /// page belongs at the end because it is the one screen that is not a question,
51 - /// and a user who has just read what is about to run is the one most likely to
52 - /// look at what it is made of.
53 - const STEPS: [Step; 6] = [
54 - Step::Disk,
55 - Step::Hostname,
56 - Step::Account,
57 - Step::Encryption,
58 - Step::Summary,
59 - Step::Credits,
60 - ];
61 -
62 - /// Which question the wizard is on.
63 - ///
64 - /// An enum rather than a bare index so `render` and `handle` match on what is
65 - /// being asked instead of on a number, and so adding a step is a compiler error
66 - /// everywhere it needs to be handled.
67 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
68 - enum Step {
69 - Disk,
70 - Hostname,
71 - Account,
72 - Encryption,
73 - Summary,
74 - Credits,
75 - }
76 -
77 - impl Step {
78 - /// Short enough that the title fits 80 columns on the longest step.
79 - ///
80 - /// The title is `install (bootc), step 6 of 6: <label>`, which is 38
81 - /// columns before the label, so the budget is real but not tight. "review
82 - /// and install" was the longest and is now merely the second longest, since
83 - /// it stopped being the step that installs.
84 - const fn label(self) -> &'static str {
85 - match self {
86 - Self::Disk => "select a disk",
87 - Self::Hostname => "name this machine",
88 - Self::Account => "create your account",
89 - Self::Encryption => "encrypt the disk",
90 - Self::Summary => "review",
91 - Self::Credits => "credits",
92 - }
93 - }
94 -
95 - /// Whether this step takes typing, which decides if the shell keeps
96 - /// claiming `q` as quit while it is on screen.
97 - const fn types(self) -> bool {
98 - matches!(self, Self::Hostname | Self::Account | Self::Encryption)
99 - }
100 - }
41 + mod steps;
101 42
102 43 /// One line of the credits page, before a theme has been near it.
103 44 ///
@@ -120,35 +61,6 @@
120 61 Note(&'static str),
121 62 }
122 63
123 - /// Which field of the account step has focus.
124 - ///
125 - /// Indices into a [`FocusRing`], named so the render and key paths agree about
126 - /// what slot 2 is.
127 - const FIELD_USERNAME: usize = 0;
128 - const FIELD_PASSWORD: usize = 1;
129 - const FIELD_CONFIRM: usize = 2;
130 - const FIELD_PUBKEY: usize = 3;
131 - const ACCOUNT_FIELDS: usize = 4;
132 -
133 - /// Which slot of the hostname step has focus.
134 - ///
135 - /// Two, because the step carries the machine's name and the one question that
136 - /// would otherwise have been a step of its own. Folding the timezone in here
137 - /// rather than after it is what keeps the wizard at four screens.
138 - const SLOT_HOSTNAME: usize = 0;
139 - const SLOT_TIMEZONE: usize = 1;
140 - const HOSTNAME_SLOTS: usize = 2;
141 -
142 - /// Which slot of the encryption step has focus.
143 - ///
144 - /// The checkbox leads because it decides whether the two fields under it mean
145 - /// anything, and a screen whose first slot is a field the answer may discard
146 - /// reads backwards.
147 - const SLOT_ENCRYPT: usize = 0;
148 - const FIELD_PASSPHRASE: usize = 1;
149 - const FIELD_PASSPHRASE_CONFIRM: usize = 2;
150 - const ENCRYPT_SLOTS: usize = 3;
151 -
152 64 /// Width of the right-aligned label column on the form panes.
153 65 ///
154 66 /// Named because two things depend on it agreeing: the label itself, and the
@@ -428,148 +340,6 @@
428 340 view
429 341 }
430 342
431 - /// Which question is on screen.
432 - fn step(&self) -> Step {
433 - STEPS[self.steps.current()]
434 - }
435 -
436 - /// Move to the next question, then past any the medium already answered.
437 - ///
438 - /// Every step's confirm goes through here rather than calling
439 - /// [`Steps::advance`] directly, so the skip cannot be forgotten on a step
440 - /// added later.
441 - fn advance(&mut self) {
442 - self.steps.advance();
443 - self.skip_answered();
444 - }
445 -
446 - /// Walk forward over steps the medium answers in full.
447 - ///
448 - /// Forward only, and never over a step the user has stood on. Those two
449 - /// rules are what keep a prefilled answer correctable: Esc walks back into
450 - /// a skipped step, which marks it visited, and from then on it is an
451 - /// ordinary question.
452 - ///
453 - /// A step that is only partly answered is not skipped. The account step is
454 - /// the permanent example: the medium may carry the username and the key,
455 - /// and it never carries the password, so the step is shown with one field
456 - /// left to fill.
457 - fn skip_answered(&mut self) {
458 - while !self.visited[self.steps.current()] && self.answer_from_medium(self.step()) {
459 - if !self.steps.advance() {
460 - break;
461 - }
462 - }
463 - self.focus_first_gap();
464 - }
465 -
466 - /// Put focus on the first thing the medium could not answer.
467 - ///
468 - /// The two steps that are never skipped are never skipped for the same
469 - /// reason: they hold a secret. On a fully seeded medium that leaves one
470 - /// field on each screen, and landing on the field above it means the first
471 - /// thing typed goes into an answer that was already correct.
472 - ///
473 - /// Found by driving the install in a VM rather than by reading this code:
474 - /// the account step opened on the prefilled username, so a password typed
475 - /// straight away was appended to the account name and the step failed on a
476 - /// confirmation that never got filled. See build/vmtest/install_preseeded.py.
477 - ///
478 - /// Only on arrival, and only where the value came from the medium. Focus a
479 - /// user moved is theirs, and stepping back does not come through here.
480 - fn focus_first_gap(&mut self) {
481 - match self.step() {
482 - Step::Account if self.from_medium.username && !self.username.value().is_empty() => {
483 - self.fields.focus(FIELD_PASSWORD);
484 - }
485 - Step::Encryption if self.from_medium.encrypt && self.encrypt => {
486 - self.crypt.focus(FIELD_PASSPHRASE);
487 - }
488 - _ => {}
489 - }
490 - }
491 -
492 - /// Take the medium's answer for one step, and say whether it answered it
493 - /// in full.
494 - ///
495 - /// Writing into [`Answers`] here rather than at the step's own confirm is
496 - /// what makes a skipped step still produce an answer. The summary then reads
497 - /// the same fields whichever way they were filled, and marks the ones nobody
498 - /// typed.
499 - fn answer_from_medium(&mut self, step: Step) -> bool {
500 - match step {
501 - Step::Disk => {
502 - let Some(rule) = self.preseed.disk else {
503 - return false;
504 - };
505 - match rule.resolve(&self.disks) {
506 - Ok(path) => {
507 - // Put the cursor on the disk the rule named, so a user
508 - // who steps back into this screen finds the row that
509 - // was chosen for them selected. Nothing downstream
510 - // reads the cursor -- the plan and the erase modal both
511 - // take `answers.disk` -- so this is about what the
512 - // screen says rather than about what gets erased.
513 - if let (Some(at), Some(now)) = (
514 - self.disks.iter().position(|d| d.path == path),
515 - self.cursor.selected(),
516 - ) {
517 - self.cursor.move_by(at as isize - now as isize);
518 - }
519 - self.answers.disk = Some(path);
520 - self.from_medium.disk = true;
521 - true
522 - }
523 - Err(failure) => {
524 - // Falling back to asking, and saying why. A recipe that
525 - // describes a machine this is not must never resolve to
526 - // "some disk"; it must resolve to a question.
527 - self.medium_note = Some(failure.reason(rule));
528 - false
529 - }
530 - }
531 - }
532 - Step::Hostname => {
533 - // Both of the step's answers or neither. The timezone checkbox
534 - // shares this screen, and skipping past an unanswered checkbox
535 - // would decide it by default while looking like the recipe had
536 - // decided it.
537 - let (Some(name), Some(locate)) =
538 - (self.preseed.hostname.clone(), self.preseed.locate_timezone)
539 - else {
540 - return false;
541 - };
542 - if let Err(message) = validate_hostname(&name) {
543 - self.medium_note =
544 - Some(format!("the medium's hostname is unusable: {message}"));
545 - return false;
546 - }
547 - self.answers.hostname = Some(name);
548 - self.answers.locate_timezone = locate;
549 - true
550 - }
551 - // Never skipped: the password is a secret and a secret is never on
552 - // the medium. The username and the key arrive seeded, so what is
553 - // left is the one field only a person can supply.
554 - Step::Account => false,
555 - Step::Encryption => {
556 - // `encrypt = false` answers the whole step, because an
557 - // unencrypted install has no second question. `encrypt = true`
558 - // answers only the checkbox: the passphrase is a secret, so the
559 - // step is shown with the decision already made and the secret
560 - // still to type.
561 - matches!(self.preseed.encrypt, Some(false)) && {
562 - self.answers.encrypt = false;
563 - true
564 - }
565 - }
566 - // Not questions. The review exists to be read before a disk is
567 - // erased, and skipping it would be the one prefill that removes a
568 - // safeguard rather than a keystroke.
569 - Step::Summary | Step::Credits => false,
570 - }
571 - }
572 -
573 343 fn refresh(&mut self, log: &mut CommandLog) {
574 344 match self.backend.list(log) {
575 345 Ok(disks) => {
@@ -627,216 +397,6 @@
627 397 Flow::Continue
628 398 }
629 399
630 - /// Keys for the hostname field.
631 - ///
632 - /// Every printable character is taken literally, which is only correct
633 - /// because [`View::text_entry`] tells the shell to stop claiming `q` while
634 - /// this step is on screen.
635 - fn edit_hostname(&mut self, key: KeyEvent) -> Flow {
636 - match key.code {
637 - KeyCode::Tab | KeyCode::Down => self.machine.next(),
638 - KeyCode::BackTab | KeyCode::Up => self.machine.prev(),
639 - // Enter submits from either slot, the same shape the account step
640 - // uses. The checkbox has a default, so committing from the name
641 - // field is a complete answer rather than a half-filled one.
642 - KeyCode::Enter => return self.name_machine(),
643 - KeyCode::Char(' ') if self.machine.current() == SLOT_TIMEZONE => {
644 - self.locate_timezone = !self.locate_timezone;
645 - }
646 - // Everything else is the field's, and reaches it only while the
647 - // field has focus. Typing into a checkbox should do nothing rather
648 - // than edit a name that is not on screen under the caret.
649 - _ if self.machine.current() == SLOT_TIMEZONE => {}
650 - KeyCode::Char(c) => self.hostname.insert(c),
651 - KeyCode::Backspace => self.hostname.backspace(),
652 - KeyCode::Delete => self.hostname.delete(),
653 - KeyCode::Left => self.hostname.left(),
654 - KeyCode::Right => self.hostname.right(),
655 - KeyCode::Home => self.hostname.home(),
656 - KeyCode::End => self.hostname.end(),
657 - _ => {}
658 - }
659 - Flow::Continue
660 - }
661 -
662 - /// The field the account step's focus ring is currently on.
663 - fn focused_field(&mut self) -> &mut TextField {
664 - match self.fields.current() {
665 - FIELD_PASSWORD => &mut self.password,
666 - FIELD_CONFIRM => &mut self.confirm,
667 - FIELD_PUBKEY => &mut self.pubkey,
668 - _ => &mut self.username,
669 - }
670 - }
671 -
672 - /// Take the account, if both halves are answerable.
673 - ///
674 - /// The username is checked before the passwords so the first complaint is
675 - /// about the field the user is most likely still looking at.
676 - fn create_account(&mut self) -> Flow {
677 - if let Err(message) = validate_username(self.username.value()) {
678 - self.error = Some(message);
679 - self.fields.focus(FIELD_USERNAME);
680 - return Flow::Continue;
681 - }
682 - if let Err(message) = validate_password(self.password.value(), self.confirm.value()) {
683 - self.error = Some(message);
684 - // Which field is at fault depends on which complaint it is, and
685 - // getting this wrong sent people to the wrong box. Enter from the
686 - // username field is how the form is walked when nothing has been
687 - // typed yet, and it landed the caret on `confirm` with `password`
688 - // still empty above it: the screen asked for the second half of a
689 - // pair whose first half did not exist. A mismatch is different, and
690 - // there the confirm is right, because a mismatch is far more often
691 - // a typo in the second one and the fix is to retype the field
692 - // already under the caret.
693 - //
694 - // Same split as [`encrypt_disk`], which had it right; this screen
695 - // is the one that did not.
696 - self.fields.focus(if self.password.value().is_empty() {
697 - FIELD_PASSWORD
698 - } else {
699 - FIELD_CONFIRM
700 - });
701 - return Flow::Continue;
702 - }
703 - if let Err(message) = validate_pubkey(self.pubkey.value()) {
704 - self.error = Some(message);
705 - self.fields.focus(FIELD_PUBKEY);
706 - return Flow::Continue;
707 - }
708 -
709 - self.answers.username = Some(self.username.value().to_string());
710 - // Trimmed and emptied to None together: a field holding only whitespace
711 - // is a field the user left alone, and it must not become a blank line in
712 - // authorized_keys.
713 - let key = self.pubkey.value().trim();
714 - self.answers.pubkey = (!key.is_empty()).then(|| key.to_string());
715 - self.error = None;
716 - self.advance();
717 - Flow::Continue
718 - }
719 -
720 - /// Keys for the account step's three fields.
721 - ///
722 - /// Enter submits from whichever field has focus, which is what the footer
723 - /// has always promised. Moving between fields is Tab's job. Submitting from
724 - /// the middle cannot leave the second half empty: [`create_account`]
725 - /// validates every field and focuses the first one that refuses.
726 - ///
727 - /// [`create_account`]: Self::create_account
728 - fn edit_account(&mut self, key: KeyEvent) -> Flow {
729 - match key.code {
730 - KeyCode::Tab | KeyCode::Down => self.fields.next(),
731 - KeyCode::BackTab | KeyCode::Up => self.fields.prev(),
732 - KeyCode::Enter => return self.create_account(),
733 - KeyCode::Char(c) => self.focused_field().insert(c),
734 - KeyCode::Backspace => self.focused_field().backspace(),
735 - KeyCode::Delete => self.focused_field().delete(),
736 - KeyCode::Left => self.focused_field().left(),
737 - KeyCode::Right => self.focused_field().right(),
738 - KeyCode::Home => self.focused_field().home(),
739 - KeyCode::End => self.focused_field().end(),
740 - _ => {}
741 - }
742 - Flow::Continue
743 - }
744 -
745 - /// The passphrase field the encryption step's focus ring is on, if it is on
746 - /// one. `None` means the checkbox has focus, which is not a field.
747 - fn focused_crypt_field(&mut self) -> Option<&mut TextField> {
748 - match self.crypt.current() {
749 - FIELD_PASSPHRASE => Some(&mut self.passphrase),
750 - FIELD_PASSPHRASE_CONFIRM => Some(&mut self.passphrase_confirm),
751 - _ => None,
752 - }
753 - }
754 -
755 - /// Take the encryption answer, if the passphrase it needs is answerable.
756 - ///
757 - /// Turning encryption off skips the pair entirely rather than validating
758 - /// empty fields: with no LUKS volume there is nothing for a passphrase to
759 - /// open, and complaining about a blank field the user deliberately left
760 - /// blank is how a screen teaches people to ignore it.
761 - fn encrypt_disk(&mut self) -> Flow {
762 - if self.encrypt
763 - && let Err(message) =
764 - validate_passphrase(self.passphrase.value(), self.passphrase_confirm.value())
765 - {
766 - self.error = Some(message);
767 - // The confirm, for the same reason the account step picks it: a
768 - // mismatch is far more often a typo in the second of the pair.
769 - self.crypt.focus(if self.passphrase.value().is_empty() {
770 - FIELD_PASSPHRASE
771 - } else {
772 - FIELD_PASSPHRASE_CONFIRM
773 - });
774 - return Flow::Continue;
775 - }
776 -
777 - self.answers.encrypt = self.encrypt;
778 - self.error = None;
779 - self.advance();
780 - Flow::Continue
781 - }
782 -
783 - /// Keys for the encryption step: a checkbox and the passphrase pair.
784 - ///
785 - /// Same shape as the hostname step's checkbox and the account step's
786 - /// fields, because it is both of them on one screen.
787 - fn edit_encryption(&mut self, key: KeyEvent) -> Flow {
788 - match key.code {
789 - KeyCode::Tab | KeyCode::Down => self.crypt.next(),
790 - KeyCode::BackTab | KeyCode::Up => self.crypt.prev(),
791 - KeyCode::Enter => return self.encrypt_disk(),
792 - KeyCode::Char(' ') if self.crypt.current() == SLOT_ENCRYPT => {
793 - self.encrypt = !self.encrypt;
794 - }
795 - // Typing into the checkbox edits nothing, the same rule the hostname
796 - // step follows. The fields below it stay typable while encryption is
797 - // off so that turning it back on does not lose what was already
798 - // entered; only the answer gates on the box.
799 - _ if self.crypt.current() == SLOT_ENCRYPT => {}
800 - KeyCode::Char(c) => {
801 - if let Some(field) = self.focused_crypt_field() {
802 - field.insert(c);
803 - }
804 - }
805 - KeyCode::Backspace => {
806 - if let Some(field) = self.focused_crypt_field() {
807 - field.backspace();
808 - }
809 - }
810 - KeyCode::Delete => {
811 - if let Some(field) = self.focused_crypt_field() {
812 - field.delete();
813 - }
814 - }
815 - KeyCode::Left => {
816 - if let Some(field) = self.focused_crypt_field() {
817 - field.left();
818 - }
819 - }
820 - KeyCode::Right => {
821 - if let Some(field) = self.focused_crypt_field() {
822 - field.right();
823 - }
824 - }
825 - KeyCode::Home => {
826 - if let Some(field) = self.focused_crypt_field() {
827 - field.home();
828 - }
829 - }
830 - KeyCode::End => {
831 - if let Some(field) = self.focused_crypt_field() {
832 - field.end();
833 - }
834 - }
835 - _ => {}
836 - }
837 - Flow::Continue
838 - }
839 -
840 400 /// Keys for the run screen: scrolling back through the output, and nothing
841 401 /// else.
842 402 ///
@@ -911,85 +471,6 @@
911 471 Flow::Continue
912 472 }
913 473
914 - /// Whether the recovery phrase is waiting to be written down.
915 - ///
916 - /// True while a phrase that opens a real disk has not been written down.
917 - ///
918 - /// Not "the install succeeded". A failed install leaving no disk to recover
919 - /// is the false invariant the commit point exists to replace: a failure past
920 - /// the commit point can
Lines truncated
@@ -21,7 +21,7 @@
21 21 use super::super::answers::pubkey_summary;
22 22 use super::super::disks::{Disk, row_columns};
23 23 use super::super::timezone::GEO_HOST;
24 - use super::{
24 + use super::fields::{
25 25 FIELD_CONFIRM, FIELD_PASSPHRASE, FIELD_PASSPHRASE_CONFIRM, FIELD_PASSWORD, FIELD_PUBKEY,
26 26 FIELD_USERNAME, SLOT_ENCRYPT, SLOT_HOSTNAME, SLOT_TIMEZONE,
27 27 };
@@ -4,6 +4,12 @@
4 4 use super::super::fixtures::{ED25519_BODY, configured, disks};
5 5 use super::super::image::UPDATE_IMAGE;
6 6 use super::super::target::{LUKS_HEADLESS_KARG, LUKS_PROMPTING_KARG};
7 + use std::sync::atomic::Ordering;
8 +
9 + use super::fields::{
10 + FIELD_CONFIRM, FIELD_PASSPHRASE, FIELD_PASSPHRASE_CONFIRM, FIELD_PASSWORD, FIELD_PUBKEY,
11 + FIELD_USERNAME, SLOT_ENCRYPT,
12 + };
7 13 use super::*;
8 14
9 15 // ---- Secure Boot ----
@@ -1,0 +1,338 @@
1 + //! The panes that take typing: hostname, account, encryption, and the
2 + //! recovery phrase written down at the end.
3 + //!
4 + //! One file because they are one shape — a focus ring over some fields, Enter
5 + //! to commit, and a validator that returns the message to show — and because
6 + //! the field-index constants below are what keeps the render and key paths
7 + //! agreeing about which slot is which.
8 +
9 + use std::sync::atomic::Ordering;
10 +
11 + use alloy_tui::TextField;
12 + use ratatui::crossterm::event::{KeyCode, KeyEvent};
13 +
14 + use super::super::answers::{
15 + phrase_matches, validate_passphrase, validate_password, validate_pubkey, validate_username,
16 + };
17 + use super::InstallView;
18 + use crate::shell::Flow;
19 +
20 + /// Which field of the account step has focus.
21 + ///
22 + /// Indices into a [`FocusRing`], named so the render and key paths agree about
23 + /// what slot 2 is.
24 + pub(super) const FIELD_USERNAME: usize = 0;
25 + pub(super) const FIELD_PASSWORD: usize = 1;
26 + pub(super) const FIELD_CONFIRM: usize = 2;
27 + pub(super) const FIELD_PUBKEY: usize = 3;
28 + pub(super) const ACCOUNT_FIELDS: usize = 4;
29 +
30 + /// Which slot of the hostname step has focus.
31 + ///
32 + /// Two, because the step carries the machine's name and the one question that
33 + /// would otherwise have been a step of its own. Folding the timezone in here
34 + /// rather than after it is what keeps the wizard at four screens.
35 + pub(super) const SLOT_HOSTNAME: usize = 0;
36 + pub(super) const SLOT_TIMEZONE: usize = 1;
37 + pub(super) const HOSTNAME_SLOTS: usize = 2;
38 +
39 + /// Which slot of the encryption step has focus.
40 + ///
41 + /// The checkbox leads because it decides whether the two fields under it mean
42 + /// anything, and a screen whose first slot is a field the answer may discard
43 + /// reads backwards.
44 + pub(super) const SLOT_ENCRYPT: usize = 0;
45 + pub(super) const FIELD_PASSPHRASE: usize = 1;
46 + pub(super) const FIELD_PASSPHRASE_CONFIRM: usize = 2;
47 + pub(super) const ENCRYPT_SLOTS: usize = 3;
48 +
49 + impl InstallView {
50 + /// Keys for the hostname field.
51 + ///
52 + /// Every printable character is taken literally, which is only correct
53 + /// because [`View::text_entry`] tells the shell to stop claiming `q` while
54 + /// this step is on screen.
55 + pub(super) fn edit_hostname(&mut self, key: KeyEvent) -> Flow {
56 + match key.code {
57 + KeyCode::Tab | KeyCode::Down => self.machine.next(),
58 + KeyCode::BackTab | KeyCode::Up => self.machine.prev(),
59 + // Enter submits from either slot, the same shape the account step
60 + // uses. The checkbox has a default, so committing from the name
61 + // field is a complete answer rather than a half-filled one.
62 + KeyCode::Enter => return self.name_machine(),
63 + KeyCode::Char(' ') if self.machine.current() == SLOT_TIMEZONE => {
64 + self.locate_timezone = !self.locate_timezone;
65 + }
66 + // Everything else is the field's, and reaches it only while the
67 + // field has focus. Typing into a checkbox should do nothing rather
68 + // than edit a name that is not on screen under the caret.
69 + _ if self.machine.current() == SLOT_TIMEZONE => {}
70 + KeyCode::Char(c) => self.hostname.insert(c),
71 + KeyCode::Backspace => self.hostname.backspace(),
72 + KeyCode::Delete => self.hostname.delete(),
73 + KeyCode::Left => self.hostname.left(),
74 + KeyCode::Right => self.hostname.right(),
75 + KeyCode::Home => self.hostname.home(),
76 + KeyCode::End => self.hostname.end(),
77 + _ => {}
78 + }
79 + Flow::Continue
80 + }
81 +
82 + /// The field the account step's focus ring is currently on.
83 + pub(super) fn focused_field(&mut self) -> &mut TextField {
84 + match self.fields.current() {
85 + FIELD_PASSWORD => &mut self.password,
86 + FIELD_CONFIRM => &mut self.confirm,
87 + FIELD_PUBKEY => &mut self.pubkey,
88 + _ => &mut self.username,
89 + }
90 + }
91 +
92 + /// Take the account, if both halves are answerable.
93 + ///
94 + /// The username is checked before the passwords so the first complaint is
95 + /// about the field the user is most likely still looking at.
96 + pub(super) fn create_account(&mut self) -> Flow {
97 + if let Err(message) = validate_username(self.username.value()) {
98 + self.error = Some(message);
99 + self.fields.focus(FIELD_USERNAME);
100 + return Flow::Continue;
101 + }
102 + if let Err(message) = validate_password(self.password.value(), self.confirm.value()) {
103 + self.error = Some(message);
104 + // Which field is at fault depends on which complaint it is, and
105 + // getting this wrong sent people to the wrong box. Enter from the
106 + // username field is how the form is walked when nothing has been
107 + // typed yet, and it landed the caret on `confirm` with `password`
108 + // still empty above it: the screen asked for the second half of a
109 + // pair whose first half did not exist. A mismatch is different, and
110 + // there the confirm is right, because a mismatch is far more often
111 + // a typo in the second one and the fix is to retype the field
112 + // already under the caret.
113 + //
114 + // Same split as [`encrypt_disk`], which had it right; this screen
115 + // is the one that did not.
116 + self.fields.focus(if self.password.value().is_empty() {
117 + FIELD_PASSWORD
118 + } else {
119 + FIELD_CONFIRM
120 + });
121 + return Flow::Continue;
122 + }
123 + if let Err(message) = validate_pubkey(self.pubkey.value()) {
124 + self.error = Some(message);
125 + self.fields.focus(FIELD_PUBKEY);
126 + return Flow::Continue;
127 + }
128 +
129 + self.answers.username = Some(self.username.value().to_string());
130 + // Trimmed and emptied to None together: a field holding only whitespace
131 + // is a field the user left alone, and it must not become a blank line in
132 + // authorized_keys.
133 + let key = self.pubkey.value().trim();
134 + self.answers.pubkey = (!key.is_empty()).then(|| key.to_string());
135 + self.error = None;
136 + self.advance();
137 + Flow::Continue
138 + }
139 +
140 + /// Keys for the account step's three fields.
141 + ///
142 + /// Enter submits from whichever field has focus, which is what the footer
143 + /// has always promised. Moving between fields is Tab's job. Submitting from
144 + /// the middle cannot leave the second half empty: [`create_account`]
145 + /// validates every field and focuses the first one that refuses.
146 + ///
147 + /// [`create_account`]: Self::create_account
148 + pub(super) fn edit_account(&mut self, key: KeyEvent) -> Flow {
149 + match key.code {
150 + KeyCode::Tab | KeyCode::Down => self.fields.next(),
151 + KeyCode::BackTab | KeyCode::Up => self.fields.prev(),
152 + KeyCode::Enter => return self.create_account(),
153 + KeyCode::Char(c) => self.focused_field().insert(c),
154 + KeyCode::Backspace => self.focused_field().backspace(),
155 + KeyCode::Delete => self.focused_field().delete(),
156 + KeyCode::Left => self.focused_field().left(),
157 + KeyCode::Right => self.focused_field().right(),
158 + KeyCode::Home => self.focused_field().home(),
159 + KeyCode::End => self.focused_field().end(),
160 + _ => {}
161 + }
162 + Flow::Continue
163 + }
164 +
165 + /// The passphrase field the encryption step's focus ring is on, if it is on
166 + /// one. `None` means the checkbox has focus, which is not a field.
167 + pub(super) fn focused_crypt_field(&mut self) -> Option<&mut TextField> {
168 + match self.crypt.current() {
169 + FIELD_PASSPHRASE => Some(&mut self.passphrase),
170 + FIELD_PASSPHRASE_CONFIRM => Some(&mut self.passphrase_confirm),
171 + _ => None,
172 + }
173 + }
174 +
175 + /// Take the encryption answer, if the passphrase it needs is answerable.
176 + ///
177 + /// Turning encryption off skips the pair entirely rather than validating
178 + /// empty fields: with no LUKS volume there is nothing for a passphrase to
179 + /// open, and complaining about a blank field the user deliberately left
180 + /// blank is how a screen teaches people to ignore it.
181 + pub(super) fn encrypt_disk(&mut self) -> Flow {
182 + if self.encrypt
183 + && let Err(message) =
184 + validate_passphrase(self.passphrase.value(), self.passphrase_confirm.value())
185 + {
186 + self.error = Some(message);
187 + // The confirm, for the same reason the account step picks it: a
188 + // mismatch is far more often a typo in the second of the pair.
189 + self.crypt.focus(if self.passphrase.value().is_empty() {
190 + FIELD_PASSPHRASE
191 + } else {
192 + FIELD_PASSPHRASE_CONFIRM
193 + });
194 + return Flow::Continue;
195 + }
196 +
197 + self.answers.encrypt = self.encrypt;
198 + self.error = None;
199 + self.advance();
200 + Flow::Continue
201 + }
202 +
203 + /// Keys for the encryption step: a checkbox and the passphrase pair.
204 + ///
205 + /// Same shape as the hostname step's checkbox and the account step's
206 + /// fields, because it is both of them on one screen.
207 + pub(super) fn edit_encryption(&mut self, key: KeyEvent) -> Flow {
208 + match key.code {
209 + KeyCode::Tab | KeyCode::Down => self.crypt.next(),
210 + KeyCode::BackTab | KeyCode::Up => self.crypt.prev(),
211 + KeyCode::Enter => return self.encrypt_disk(),
212 + KeyCode::Char(' ') if self.crypt.current() == SLOT_ENCRYPT => {
213 + self.encrypt = !self.encrypt;
214 + }
215 + // Typing into the checkbox edits nothing, the same rule the hostname
216 + // step follows. The fields below it stay typable while encryption is
217 + // off so that turning it back on does not lose what was already
218 + // entered; only the answer gates on the box.
219 + _ if self.crypt.current() == SLOT_ENCRYPT => {}
220 + KeyCode::Char(c) => {
221 + if let Some(field) = self.focused_crypt_field() {
222 + field.insert(c);
223 + }
224 + }
225 + KeyCode::Backspace => {
226 + if let Some(field) = self.focused_crypt_field() {
227 + field.backspace();
228 + }
229 + }
230 + KeyCode::Delete => {
231 + if let Some(field) = self.focused_crypt_field() {
232 + field.delete();
233 + }
234 + }
235 + KeyCode::Left => {
236 + if let Some(field) = self.focused_crypt_field() {
237 + field.left();
238 + }
239 + }
240 + KeyCode::Right => {
241 + if let Some(field) = self.focused_crypt_field() {
242 + field.right();
243 + }
244 + }
245 + KeyCode::Home => {
246 + if let Some(field) = self.focused_crypt_field() {
247 + field.home();
248 + }
249 + }
250 + KeyCode::End => {
251 + if let Some(field) = self.focused_crypt_field() {
252 + field.end();
253 + }
254 + }
255 + _ => {}
256 + }
257 + Flow::Continue
258 + }
259 +
260 + /// Whether the recovery phrase is waiting to be written down.
261 + ///
262 + /// True while a phrase that opens a real disk has not been written down.
263 + ///
264 + /// Not "the install succeeded". A failed install leaving no disk to recover
265 + /// is the false invariant the commit point exists to replace: a failure past
266 + /// the commit point can
267 + /// leave a perfectly good encrypted volume with this phrase in a keyslot,
268 + /// and not asking for it there is how the phrase gets thrown away by
269 + /// someone who was told their install broke.
270 + ///
271 + /// So the question is about the disk rather than about the run: is there a
272 + /// volume, and does this phrase open it. A success answers yes because
273 + /// [`acceptance_plan`](super::acceptance::acceptance_plan) asserted it. A
274 + /// failure answers yes only if the recovery repaired the header, and no if
275 + /// it wiped it, because a phrase for a volume that no longer exists is
276 + /// worth nothing and asking someone to transcribe it would be a lie about
277 + /// what they hold.
278 + pub(super) fn recovery_pending(&self) -> bool {
279 + if self.recovery_ack || self.recovery.is_none() {
280 + return false;
281 + }
282 + let Some(sequence) = self.running.as_ref() else {
283 + return false;
284 + };
285 + match sequence.outcome() {
286 + Some(Ok(())) => true,
287 + Some(Err(_)) => sequence.committed() && self.disk_intact.load(Ordering::Relaxed),
288 + None => false,
289 + }
290 + }
291 +
292 + /// Keys for the recovery pane: a field, and Enter to check it.
293 + pub(super) fn edit_recovery(&mut self, key: KeyEvent) -> Flow {
294 + match key.code {
295 + KeyCode::Enter => {
296 + let Some(expected) = self.recovery.as_deref() else {
297 + return Flow::Continue;
298 + };
299 + // Empty and wrong are different mistakes and get different
300 + // answers. Enter on an empty field is someone who has not typed
301 + // the phrase yet, usually because they are still writing it
302 + // down and pressed enter to see what happens; telling them it
303 + // "is not the phrase" reads as the check having rejected words
304 + // they never entered, and sends them back to compare a phrase
305 + // against a field they can see is blank.
306 + //
307 + // `validate_passphrase` draws exactly this distinction for the
308 + // disk passphrase. This is that rule applied to the one field
309 + // where the cost of giving up is highest.
310 + let (blank, matched) = {
311 + let typed = self.recovery_typed.value();
312 + (typed.trim().is_empty(), phrase_matches(expected, typed))
313 + };
314 + if blank {
315 + self.error = Some("type the phrase back before pressing enter".into());
316 + } else if matched {
317 + self.recovery_ack = true;
318 + self.error = None;
319 + // Dropped rather than left in the field. The words are on
320 + // screen above it anyway until this frame, but there is no
321 + // reason for a second copy to outlive the check.
322 + self.recovery_typed = TextField::new();
323 + } else {
324 + self.error = Some("that is not the phrase; check it word by word".into());
325 + }
326 + }
327 + KeyCode::Char(c) => self.recovery_typed.insert(c),
328 + KeyCode::Backspace => self.recovery_typed.backspace(),
329 + KeyCode::Delete => self.recovery_typed.delete(),
330 + KeyCode::Left => self.recovery_typed.left(),
331 + KeyCode::Right => self.recovery_typed.right(),
332 + KeyCode::Home => self.recovery_typed.home(),
333 + KeyCode::End => self.recovery_typed.end(),
334 + _ => {}
335 + }
336 + Flow::Continue
337 + }
338 + }
@@ -1,0 +1,213 @@
1 + //! The wizard's step machine: which question is on screen, and how the
2 + //! installer walks past the ones the medium already answered.
3 +
4 + use super::super::answers::validate_hostname;
5 + use super::InstallView;
6 + use super::fields::{FIELD_PASSPHRASE, FIELD_PASSWORD};
7 +
8 + /// The questions, in the order they are asked.
9 + ///
10 + /// The disk comes first because it is the one that can be wrong in a way
11 + /// nothing later recovers from, and because a user who cannot see their disk in
12 + /// the list should find that out before typing anything.
13 + /// Encryption sits after the account rather than beside the disk it applies to.
14 + /// Both screens that take a secret are then adjacent, so the passphrases are
15 + /// typed in one stretch, and the review comes last of the questions.
16 + ///
17 + /// Credits sits after the review, which puts a screen between the summary and
18 + /// the disk being erased. That is a side effect rather than the reason: the
19 + /// page belongs at the end because it is the one screen that is not a question,
20 + /// and a user who has just read what is about to run is the one most likely to
21 + /// look at what it is made of.
22 + pub(super) const STEPS: [Step; 6] = [
23 + Step::Disk,
24 + Step::Hostname,
25 + Step::Account,
26 + Step::Encryption,
27 + Step::Summary,
28 + Step::Credits,
29 + ];
30 +
31 + /// Which question the wizard is on.
32 + ///
33 + /// An enum rather than a bare index so `render` and `handle` match on what is
34 + /// being asked instead of on a number, and so adding a step is a compiler error
35 + /// everywhere it needs to be handled.
36 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
37 + pub(super) enum Step {
38 + Disk,
39 + Hostname,
40 + Account,
41 + Encryption,
42 + Summary,
43 + Credits,
44 + }
45 +
46 + impl Step {
47 + /// Short enough that the title fits 80 columns on the longest step.
48 + ///
49 + /// The title is `install (bootc), step 6 of 6: <label>`, which is 38
50 + /// columns before the label, so the budget is real but not tight. "review
51 + /// and install" was the longest and is now merely the second longest, since
52 + /// it stopped being the step that installs.
53 + pub(super) const fn label(self) -> &'static str {
54 + match self {
55 + Self::Disk => "select a disk",
56 + Self::Hostname => "name this machine",
57 + Self::Account => "create your account",
58 + Self::Encryption => "encrypt the disk",
59 + Self::Summary => "review",
60 + Self::Credits => "credits",
61 + }
62 + }
63 +
64 + /// Whether this step takes typing, which decides if the shell keeps
65 + /// claiming `q` as quit while it is on screen.
66 + pub(super) const fn types(self) -> bool {
67 + matches!(self, Self::Hostname | Self::Account | Self::Encryption)
68 + }
69 + }
70 +
71 + impl InstallView {
72 + /// Which question is on screen.
73 + pub(super) fn step(&self) -> Step {
74 + STEPS[self.steps.current()]
75 + }
76 +
77 + /// Move to the next question, then past any the medium already answered.
78 + ///
79 + /// Every step's confirm goes through here rather than calling
80 + /// [`Steps::advance`] directly, so the skip cannot be forgotten on a step
81 + /// added later.
82 + pub(super) fn advance(&mut self) {
83 + self.steps.advance();
84 + self.skip_answered();
85 + }
86 +
87 + /// Walk forward over steps the medium answers in full.
88 + ///
89 + /// Forward only, and never over a step the user has stood on. Those two
90 + /// rules are what keep a prefilled answer correctable: Esc walks back into
91 + /// a skipped step, which marks it visited, and from then on it is an
92 + /// ordinary question.
93 + ///
94 + /// A step that is only partly answered is not skipped. The account step is
95 + /// the permanent example: the medium may carry the username and the key,
96 + /// and it never carries the password, so the step is shown with one field
97 + /// left to fill.
98 + pub(super) fn skip_answered(&mut self) {
99 + while !self.visited[self.steps.current()] && self.answer_from_medium(self.step()) {
100 + if !self.steps.advance() {
101 + break;
102 + }
103 + }
104 + self.focus_first_gap();
105 + }
106 +
107 + /// Put focus on the first thing the medium could not answer.
108 + ///
109 + /// The two steps that are never skipped are never skipped for the same
110 + /// reason: they hold a secret. On a fully seeded medium that leaves one
111 + /// field on each screen, and landing on the field above it means the first
112 + /// thing typed goes into an answer that was already correct.
113 + ///
114 + /// Found by driving the install in a VM rather than by reading this code:
115 + /// the account step opened on the prefilled username, so a password typed
116 + /// straight away was appended to the account name and the step failed on a
117 + /// confirmation that never got filled. See build/vmtest/install_preseeded.py.
118 + ///
119 + /// Only on arrival, and only where the value came from the medium. Focus a
120 + /// user moved is theirs, and stepping back does not come through here.
121 + pub(super) fn focus_first_gap(&mut self) {
122 + match self.step() {
123 + Step::Account if self.from_medium.username && !self.username.value().is_empty() => {
124 + self.fields.focus(FIELD_PASSWORD);
125 + }
126 + Step::Encryption if self.from_medium.encrypt && self.encrypt => {
127 + self.crypt.focus(FIELD_PASSPHRASE);
128 + }
129 + _ => {}
130 + }
131 + }
132 +
133 + /// Take the medium's answer for one step, and say whether it answered it
134 + /// in full.
135 + ///
136 + /// Writing into [`Answers`] here rather than at the step's own confirm is
137 + /// what makes a skipped step still produce an answer. The summary then reads
138 + /// the same fields whichever way they were filled, and marks the ones nobody
139 + /// typed.
140 + pub(super) fn answer_from_medium(&mut self, step: Step) -> bool {
141 + match step {
142 + Step::Disk => {
143 + let Some(rule) = self.preseed.disk else {
144 + return false;
145 + };
146 + match rule.resolve(&self.disks) {
147 + Ok(path) => {
148 + // Put the cursor on the disk the rule named, so a user
149 + // who steps back into this screen finds the row that
150 + // was chosen for them selected. Nothing downstream
151 + // reads the cursor -- the plan and the erase modal both
152 + // take `answers.disk` -- so this is about what the
153 + // screen says rather than about what gets erased.
154 + if let (Some(at), Some(now)) = (
155 + self.disks.iter().position(|d| d.path == path),
156 + self.cursor.selected(),
157 + ) {
158 + self.cursor.move_by(at as isize - now as isize);
159 + }
160 + self.answers.disk = Some(path);
161 + self.from_medium.disk = true;
162 + true
163 + }
164 + Err(failure) => {
165 + // Falling back to asking, and saying why. A recipe that
166 + // describes a machine this is not must never resolve to
167 + // "some disk"; it must resolve to a question.
168 + self.medium_note = Some(failure.reason(rule));
169 + false
170 + }
171 + }
172 + }
173 + Step::Hostname => {
174 + // Both of the step's answers or neither. The timezone checkbox
175 + // shares this screen, and skipping past an unanswered checkbox
176 + // would decide it by default while looking like the recipe had
177 + // decided it.
178 + let (Some(name), Some(locate)) =
179 + (self.preseed.hostname.clone(), self.preseed.locate_timezone)
180 + else {
181 + return false;
182 + };
183 + if let Err(message) = validate_hostname(&name) {
184 + self.medium_note =
185 + Some(format!("the medium's hostname is unusable: {message}"));
186 + return false;
187 + }
188 + self.answers.hostname = Some(name);
189 + self.answers.locate_timezone = locate;
190 + true
191 + }
192 + // Never skipped: the password is a secret and a secret is never on
193 + // the medium. The username and the key arrive seeded, so what is
194 + // left is the one field only a person can supply.
195 + Step::Account => false,
196 + Step::Encryption => {
197 + // `encrypt = false` answers the whole step, because an
198 + // unencrypted install has no second question. `encrypt = true`
199 + // answers only the checkbox: the passphrase is a secret, so the
200 + // step is shown with the decision already made and the secret
201 + // still to type.
202 + matches!(self.preseed.encrypt, Some(false)) && {
203 + self.answers.encrypt = false;
204 + true
205 + }
206 + }
207 + // Not questions. The review exists to be read before a disk is
208 + // erased, and skipping it would be the one prefill that removes a
209 + // safeguard rather than a keystroke.
210 + Step::Summary | Step::Credits => false,
211 + }
212 + }
213 + }