Skip to main content

max / alloy

Tell the two phrase mistakes apart, and offer the reboot Both found by Max installing fw12 off the current stick. An empty recovery field took the wrong-phrase branch, because normalize_phrase turns "" into "" and "" is not the phrase. So pressing enter before typing anything answered "that is not the phrase; check it word by word", which sends someone off to compare a phrase word by word against a field they can see is blank. validate_passphrase already separates "a passphrase is required" from "the passphrases do not match" twenty lines earlier; this is that distinction applied to the field where giving up costs the most. And the finished screen offered esc and nothing else, which leaves the user at a shell on the live medium: a correct place to be, and not the one anyone installing an operating system was trying to reach. `r` now raises a confirm and hands the terminal to systemctl reboot. Three things the reboot is deliberately not: - Not offered after a failure. There is nothing to boot into yet. - Not reachable while the phrase is owed. handle routes to the phrase pane first, so the key never arrives, and a test pins that: a reboot before the phrase is written down is the exact loss that pane exists to prevent. - Not a bare keypress. It confirms, and the modal is where the installer medium gets mentioned. confirmed() serves both modals now, so reboot_pending says which is outstanding; without it, agreeing to reboot would re-enter the install path and start a second install over the first.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-14 18:47 UTC
Signed with PGP, not checked
Commit: d49e67e594cac6dd93850572de17e56c03bfb8f5
Parent: 343f974
1 file changed, +186 insertions, -1 deletion
@@ -3121,6 +3121,14 @@
3121 3121 /// reboots without these words written down has a machine whose disk dies
3122 3122 /// with its TPM, and nothing will ever show them again.
3123 3123 recovery_ack: bool,
3124 + /// Set between raising the reboot modal and the answer coming back.
3125 + ///
3126 + /// [`confirmed`](View::confirmed) is one hook for every modal a view
3127 + /// raises, and this one raises two: the install itself and the reboot at
3128 + /// the end. Without a flag saying which is outstanding, agreeing to reboot
3129 + /// would re-enter the install path and start a second install over the
3130 + /// first.
3131 + reboot_pending: bool,
3124 3132 /// Whether the disk ended the run with a keyslot a person can use.
3125 3133 ///
3126 3134 /// Written by [`recover_plan`]'s check, which runs on a thread the view
@@ -3199,6 +3207,7 @@
3199 3207 recovery: None,
3200 3208 recovery_typed: TextField::new(),
3201 3209 recovery_ack: false,
3210 + reboot_pending: false,
3202 3211 disk_intact: Arc::new(AtomicBool::new(false)),
3203 3212 encrypt: true,
3204 3213 credits_scroll: 0,
@@ -3925,6 +3934,23 @@
3925 3934 let Some(sequence) = self.running.as_ref() else {
3926 3935 return Flow::Continue;
3927 3936 };
3937 + // The last thing an install owes the user is the next step, and the
3938 + // next step is always a reboot. Esc alone left them at a shell on the
3939 + // live medium, which is a correct place to be and not the place anyone
3940 + // installing an operating system was trying to reach.
3941 + //
3942 + // Offered only on success. After a failure the machine is not something
3943 + // to boot into yet, and after `recovery_pending` this key never arrives
3944 + // at all, because `handle` routes to the phrase pane first: a reboot
3945 + // before the phrase is written down is the exact loss that pane exists
3946 + // to prevent.
3947 + if key.code == KeyCode::Char('r') && matches!(sequence.outcome(), Some(Ok(()))) {
3948 + self.reboot_pending = true;
3949 + return Flow::Confirm(Confirm::destructive(
3950 + "reboot",
3951 + "Reboot into the installed system? Remove the installer medium first.".to_string(),
3952 + ));
3953 + }
3928 3954 let viewport = self.run_viewport.get().max(1);
3929 3955 let max = sequence.output().len().saturating_sub(viewport);
3930 3956 let page = viewport.max(2) - 1;
@@ -4009,7 +4035,24 @@
4009 4035 let Some(expected) = self.recovery.as_deref() else {
4010 4036 return Flow::Continue;
4011 4037 };
4012 - if phrase_matches(expected, self.recovery_typed.value()) {
4038 + // Empty and wrong are different mistakes and get different
4039 + // answers. Enter on an empty field is someone who has not typed
4040 + // the phrase yet, usually because they are still writing it
4041 + // down and pressed enter to see what happens; telling them it
4042 + // "is not the phrase" reads as the check having rejected words
4043 + // they never entered, and sends them back to compare a phrase
4044 + // against a field they can see is blank.
4045 + //
4046 + // `validate_passphrase` draws exactly this distinction for the
4047 + // disk passphrase. This is that rule applied to the one field
4048 + // where the cost of giving up is highest.
4049 + let (blank, matched) = {
4050 + let typed = self.recovery_typed.value();
4051 + (typed.trim().is_empty(), phrase_matches(expected, typed))
4052 + };
4053 + if blank {
4054 + self.error = Some("type the phrase back before pressing enter".into());
4055 + } else if matched {
4013 4056 self.recovery_ack = true;
4014 4057 self.error = None;
4015 4058 // Dropped rather than left in the field. The words are on
@@ -4396,6 +4439,11 @@
4396 4439 let mut hints = vec![hint("j/k", "scroll")];
4397 4440 // Esc leaves, but only once there is nothing left to interrupt.
4398 4441 if sequence.is_done() {
4442 + // Named before esc because it is what the user came for. A
4443 + // failed run offers only esc: there is nothing to reboot into.
4444 + if matches!(sequence.outcome(), Some(Ok(()))) {
4445 + hints.push(hint("r", "reboot"));
4446 + }
4399 4447 hints.push(hint("esc", "done"));
4400 4448 }
4401 4449 return hints;
@@ -4548,7 +4596,25 @@
4548 4596 /// next tick, so the run screen is on screen before anything touches the
4549 4597 /// disk. Nothing here blocks, which is the whole point — `bootc install
4550 4598 /// to-disk` takes minutes and the frame has to keep drawing for all of them.
4599 + /// Declining the reboot leaves the user on the finished screen.
4600 + ///
4601 + /// Without this the flag outlives the modal it was raised for, and the next
4602 + /// confirm to come back yes reboots a machine nobody asked to reboot.
4603 + fn cancelled(&mut self) {
4604 + self.reboot_pending = false;
4605 + }
4606 +
4551 4607 fn confirmed(&mut self, _log: &mut CommandLog) -> Flow {
4608 + // Before the install path: this hook serves both modals, and the reboot
4609 + // one is the only case where the install has already happened.
4610 + //
4611 + // Suspend rather than a stage. The TUI comes down first, so what the
4612 + // user watches is systemd's own shutdown on a plain console rather than
4613 + // an installer frame that stops repainting mid-reboot.
4614 + if std::mem::take(&mut self.reboot_pending) {
4615 + return Flow::Suspend(Invocation::new("systemctl").arg("reboot").command());
4616 + }
4617 +
4552 4618 // Generated once, here, rather than inside the plan: the same phrase has
4553 4619 // to be enrolled and then shown to the user, and a plan that made its
4554 4620 // own would enroll one nobody ever sees. Held on the view because the
@@ -4900,6 +4966,7 @@
4900 4966 recovery: None,
4901 4967 recovery_typed: TextField::new(),
4902 4968 recovery_ack: false,
4969 + reboot_pending: false,
4903 4970 encrypt: true,
4904 4971 credits_scroll: 0,
4905 4972 run_scroll: 0,
@@ -5732,6 +5799,124 @@
5732 5799 type_into(view, text, &mut log);
5733 5800 }
5734 5801
5802 + /// A finished, successful install with nothing left to confirm.
5803 + fn at_finished_install() -> (InstallView, CommandLog) {
5804 + let (mut view, mut log) = at_summary();
5805 + view.running = Some(Sequence::new(Vec::new()));
5806 + view.tick(&mut log);
5807 +
5808 + assert!(
5809 + !view.recovery_pending(),
5810 + "the fixture must be past the phrase pane"
5811 + );
5812 + (view, log)
5813 + }
5814 +
5815 + // Pressing enter on a blank field is someone who has not typed the phrase
5816 + // yet. Telling them it "is not the phrase" sends them off to compare words
5817 + // against a field they can see is empty.
5818 + #[test]
5819 + fn an_empty_field_is_not_reported_as_a_wrong_phrase() {
5820 + let (mut view, mut log) = at_recovery("alpha bravo charlie delta");
5821 +
5822 + view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
5823 +
5824 + assert!(!view.recovery_ack);
5825 + assert!(view.recovery_pending(), "an empty field got through");
5826 + let error = view
5827 + .error
5828 + .clone()
5829 + .expect("an empty field must say something");
5830 + assert!(error.contains("type the phrase back"), "{error}");
5831 + assert!(
5832 + !error.contains("not the phrase"),
5833 + "an empty field must not read as a failed comparison: {error}"
5834 + );
5835 + }
5836 +
5837 + // The two mistakes must not be told apart only by the user's memory of
5838 + // what they typed.
5839 + #[test]
5840 + fn empty_and_wrong_do_not_give_the_same_message() {
5841 + let (mut view, mut log) = at_recovery("alpha bravo charlie delta");
5842 + view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
5843 + let empty = view.error.clone().expect("empty must complain");
5844 +
5845 + type_phrase(&mut view, "alpha bravo charlie echo");
5846 + view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
5847 + let wrong = view.error.clone().expect("wrong must complain");
5848 +
5849 + assert_ne!(empty, wrong);
5850 + }
5851 +
5852 + // Whitespace is not an entry. The normaliser collapses it to nothing, so
5853 + // it takes the same path as a blank field rather than reading as words.
5854 + #[test]
5855 + fn whitespace_alone_counts_as_empty() {
5856 + let (mut view, mut log) = at_recovery("alpha bravo charlie delta");
5857 + type_phrase(&mut view, " ");
5858 +
5859 + view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
5860 +
5861 + let error = view.error.clone().expect("whitespace must complain");
5862 + assert!(error.contains("type the phrase back"), "{error}");
5863 + }
5864 +
5865 + // What the user came for. Esc alone left them at a shell on the live
5866 + // medium, which is a correct place to be and not the one they wanted.
5867 + #[test]
5868 + fn a_finished_install_offers_a_reboot() {
5869 + let (mut view, mut log) = at_finished_install();
5870 +
5871 + assert!(
5872 + view.hints().iter().any(|hint| hint.key == "r"),
5873 + "the finished screen must name the reboot"
5874 + );
5875 +
5876 + let flow = view.handle(KeyEvent::from(KeyCode::Char('r')), &mut log);
5877 + assert!(matches!(flow, Flow::Confirm(_)), "reboot must be confirmed");
5878 + assert!(view.reboot_pending);
5879 +
5880 + let flow = view.confirmed(&mut log);
5881 + let Flow::Suspend(command) = flow else {
5882 + panic!("agreeing must hand the terminal over to the reboot");
5883 + };
5884 + assert_eq!(command.get_program(), "systemctl");
5885 + assert!(
5886 + !view.reboot_pending,
5887 + "the flag must not outlive the modal it was raised for"
5888 + );
5889 + }
5890 +
5891 + // Declining must leave the machine alone, and must not leave a flag that
5892 + // turns some later yes into a reboot.
5893 + #[test]
5894 + fn declining_the_reboot_clears_the_flag() {
5895 + let (mut view, mut log) = at_finished_install();
5896 + view.handle(KeyEvent::from(KeyCode::Char('r')), &mut log);
5897 + assert!(view.reboot_pending);
5898 +
5899 + view.cancelled();
5900 +
5901 + assert!(!view.reboot_pending);
5902 + }
5903 +
5904 + // The phrase pane owns every key while it is up, so `r` cannot reach the
5905 + // run screen. Rebooting before the phrase is written down is the exact
5906 + // loss that pane exists to prevent, and this pins it.
5907 + #[test]
5908 + fn reboot_is_unreachable_while_the_phrase_is_owed() {
5909 + let (mut view, mut log) = at_recovery("alpha bravo charlie delta");
5910 +
5911 + let flow = view.handle(KeyEvent::from(KeyCode::Char('r')), &mut log);
5912 +
5913 + assert!(matches!(flow, Flow::Continue), "r must not raise a modal");
5914 + assert!(!view.reboot_pending);
5915 + // It went into the field, which is where a letter belongs here.
5916 + assert_eq!(view.recovery_typed.value(), "r");
5917 + assert!(!view.hints().iter().any(|hint| hint.key == "r"));
5918 + }
5919 +
5735 5920 #[test]
5736 5921 fn the_phrase_is_shown_and_the_pane_waits_for_it() {
5737 5922 let (mut view, mut log) = at_recovery("alpha bravo charlie delta");