max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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"); |