Skip to main content

max / alloy

Add the credits page as the installer's last step The last screen before a disk is repartitioned now says whose work is about to be written to it: every project Alloy ships, its SPDX identifier, its url, and a note where the name does not explain itself. Enter on the summary advances to it; Enter here raises the destructive confirm. The install starting from the credits rather than from the summary is the ordering the page argues for, and it costs one keypress. credits.toml is embedded with include_str!, the precedent being the recovery wordlist and the rio schema. The page has to draw from installer media, from a read-only ostree deployment, and mid-install with no network; a file on disk could be absent in all three and bytes in the binary cannot be. A malformed manifest is a failing test rather than a panic on the screen before an install, and if a broken one ever ships the page comes up empty instead of taking the installer down. The page lays out as rows first and styles them second. The key path needs the line count to clamp End and page-down, and it has no Theme to build spans with, so a second function counting what the renderer draws would be a drift waiting to happen: one list, counted and styled from the same shape. One string per row, because a url with its note appended reached 127 columns and a Paragraph that does not wrap truncates rather than folds, which would have hidden the longest note behind the right edge. Every url line now fits in 80. A Paragraph rather than an AlloyList. A list's offset derives from its selection, so it paints a highlighted row that moves as you scroll, and there is nothing here to select; the highlight would promise an Enter that does something else entirely. The viewport height is a Cell because only render knows it and render takes &self. Without it End cannot be spelled, and clamping in render alone would leave the stored offset past the end, so the first few Ups after End would move nothing while the number came back down. The summary's label loses "and install", which stopped being true of it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-31 00:44 UTC
Signed with PGP, not checked
Commit: bf5dc80e50107635975e4da41fbc88af4e930927
Parent: 0f8b04b
5 files changed, +452 insertions, -12 deletions
@@ -223,6 +223,7 @@
223 223 - **Shipped.** `alloy net`, `alloy audio`, `alloy mesh`, `alloy setup`, and all three `alloy pkg` tabs (`box`, plus `install` and `update` fronting `rpm-ostree status --json`). Plus the shell they share: frame, reserved keys, focus, command-log pane, background tick. `alloy pkg` forced three more pieces into it: `AlloyTabs`, a Cancel that views see before the shell claims it (a confirm needs a cancel that is not "exit the app"), and terminal suspend, so entering a box can hand the TTY to another interactive program.
224 224 - **In progress.** `alloy settings`, with schemas for the v0-adopted TOML configs (rio, yazi, mako, and others; the sway config takes the text-edit fallback). The largest remaining piece: schema-DSL v1 parser, `toml_edit` roundtrip layer, and the form widgets together. The view architecture is settled (see "View architecture" above); build order is (1) schema parser and (2) the `Bind` seam with its `toml_edit` implementation, both pure and testable against `rio.toml.schema`, **both shipped**; (3) `AlloyForm` + `AlloyField` + `TextField` promotion as the `alloy_tui` 1.2 release, **shipped**; (4) tab chrome and the Applications tab, **shipped**; (5) per-field edit plus save and the quit-confirm, **shipped**, then the filterable pick overlay, **shipped**, which was the last field type that could not be changed; (6) the System tab, time rows first, **shipped** — one front, one enum, one bool, one display-only row, which is the smallest complete slice of the command side; (7) the remaining System rows, **shipped** except theme, which needs somewhere to persist a choice and a shell that can re-theme mid-run; (8) presets and live diagnostics, the collapsible sections having landed with the tab; (9) the text-edit fallback, save and dirty tracking having landed with the edit; (10) `AlloyTable` read-only.
225 225 - **Written against a real capture, still short one case.** `alloy display` fronts `swaymsg` alone. `wlr-randr` is gone from this line and kanshi never arrived: neither is in the image, and sway re-applies stored `output` config on hotplug by connector name or by the `make model serial` identifier, which is the feature kanshi would have been carried for. The verb's one string serves both consumers, since `output eDP-1 scale 1.25` in a config file and `swaymsg output eDP-1 scale 1.25` at runtime are the same words after the command name; the console runs it and writes the identical text to `~/.config/sway/config.d/50-display.conf`, which the shipped sway config includes after `/etc/sway/config.d/*` so the user's file wins. The parser is written against a verbatim `swaymsg -t get_outputs` capture from the FW12 install (2026-07-29, sway 1.11) and re-checkable inside a session with `reads_this_machines_real_outputs`. The installer seeds that same file from the same generator, so a fresh machine boots at the scale its panel wants rather than at 1.0: there is no compositor to ask during an install, so the panel is read from sysfs and its physical size from EDID, and the scale is that density snapped to the ladder the `s` key walks. A panel that cannot be read seeds nothing, which is 1.0 and one keypress from correct. What is still missing is a multi-output capture: nobody has attached a second display to an Alloy machine, and that is the case parsers break on, so the mode picker is deliberately unbuilt (the one testable panel advertises exactly one mode).
226 + - **Shipped, and the only screen that is not a question.** `alloy install`'s credits page, added after the summary as a sixth step. It names each project Alloy ships, its SPDX identifier and its URL, off `crates/alloy/credits.toml` embedded in the binary. The install now starts from this screen rather than from the summary, which is the ordering the page argues for: the last screen before a disk is repartitioned should be the one that says whose work is about to be written to it. Curated rather than generated, so it drifts by design and keeping it current is a release step (see [IMAGE.md](IMAGE.md)). Not a source offer and not a license-text viewer: the full texts stay in `/usr/share/licenses` on the installed system.
226 227 - **Then.** `alloy theme`, which swaps the runtime theme in place (makeover consumer, no re-login). The first-boot flow (see [CONTINUITY.md](CONTINUITY.md)) shipped as `alloy setup`: two rows over the same `mesh` and `sync` backend seams, so there is no third enrollment path to keep in agreement with them. It survives the teardown mesh enrollment costs by refreshing on the first tick after a suspend rather than on its poll counter. The shell's post-suspend refresh arrives as one `tick` call, and a counter would swallow four out of five of them, leaving the row the user just enrolled reading "not enrolled" for several seconds. The screen appears once: the session autostart runs `alloy setup --if-first-boot`, and the console records that it asked in the same config store the theme lives in.
227 228 - **v1.x.** Additional adopted-tool schemas as the v0 stack grows. (`alloy hinged` was shelved with the FW12 tablet flow in the pivot.)
228 229 - **v2+.** Third-party subcommand registration (a well-known directory of ratatui adapters the console discovers at runtime), if a real ecosystem case emerges. Not planned.
@@ -96,6 +96,8 @@
96 96 8. **Branding:** os-release, plymouth splash.
97 97 9. **Validation:** `bootc container lint` runs at build.
98 98
99 + **A package added here gets a line in `crates/alloy/credits.toml`.** The installer's last screen names the projects Alloy ships and their licenses, off a hand-curated manifest rather than a generated closure, so nothing adds itself. The manifest is embedded in the console binary with `include_str!`, which means the page cannot go missing on installer media or a read-only deployment and also means a manifest edit needs a rebuild. Its own header says which license to record: for anything packaged out of Rust or Go, Fedora's `%{LICENSE}` is the whole vendored closure rather than the project's own terms, so read upstream's LICENSE for those and use `rpm -q --qf '%{LICENSE}'` only for the C packages.
100 +
99 101 The browser ships as an RPM baked into the image: one code path, no first-boot delay, and no first-run network dependency. Alloy ships no configuration for it at all, which is why it is the browser Alloy ships (see [STACK.md](STACK.md#browser)). The `flatpak` client is included so users can pull Flathub-only apps on demand post-install; no Flatpaks are provisioned at build or first-boot time.
100 102
101 103 ## Update cadence
@@ -5,8 +5,12 @@
5 5 //! command, not a reimplementation of Anaconda. See docs/CONSOLE.md and the
6 6 //! wiki note `alloy-console`.
7 7 //!
8 - //! Four questions: which disk, what to call the machine, who logs in, and a
9 - //! summary that shows the exact commands before running any of them.
8 + //! Four questions: which disk, what to call the machine, who logs in, and
9 + //! whether to encrypt. Then a summary that shows the exact commands before
10 + //! running any of them, and a credits page naming what is about to be
11 + //! installed and under what terms. The install starts from the credits, not
12 + //! from the summary: the last screen is the one that acts, and this is the
13 + //! only screen of the six that asks nothing.
10 14 //!
11 15 //! The install runs through [`Sequence`], so the frame keeps drawing for the
12 16 //! minutes `bootc` takes.
@@ -82,13 +86,20 @@
82 86 /// the list should find that out before typing anything.
83 87 /// Encryption sits after the account rather than beside the disk it applies to.
84 88 /// Both screens that take a secret are then adjacent, so the passphrases are
85 - /// typed in one stretch, and the review screen stays last.
86 - const STEPS: [Step; 5] = [
89 + /// typed in one stretch, and the review comes last of the questions.
90 + ///
91 + /// Credits sits after the review, which puts a screen between the summary and
92 + /// the disk being erased. That is a side effect rather than the reason: the
93 + /// page belongs at the end because it is the one screen that is not a question,
94 + /// and a user who has just read what is about to run is the one most likely to
95 + /// look at what it is made of.
96 + const STEPS: [Step; 6] = [
87 97 Step::Disk,
88 98 Step::Hostname,
89 99 Step::Account,
90 100 Step::Encryption,
91 101 Step::Summary,
102 + Step::Credits,
92 103 ];
93 104
94 105 /// Which question the wizard is on.
@@ -103,16 +114,24 @@
103 114 Account,
104 115 Encryption,
105 116 Summary,
117 + Credits,
106 118 }
107 119
108 120 impl Step {
121 + /// Short enough that the title fits 80 columns on the longest step.
122 + ///
123 + /// The title is `install (bootc), step 6 of 6: <label>`, which is 38
124 + /// columns before the label, so the budget is real but not tight. "review
125 + /// and install" was the longest and is now merely the second longest, since
126 + /// it stopped being the step that installs.
109 127 const fn label(self) -> &'static str {
110 128 match self {
111 129 Self::Disk => "select a disk",
112 130 Self::Hostname => "name this machine",
113 131 Self::Account => "create your account",
114 132 Self::Encryption => "encrypt the disk",
115 - Self::Summary => "review and install",
133 + Self::Summary => "review",
134 + Self::Credits => "credits",
116 135 }
117 136 }
118 137
@@ -123,6 +142,27 @@
123 142 }
124 143 }
125 144
145 + /// One line of the credits page, before a theme has been near it.
146 + ///
147 + /// Borrowed from the manifest, which is `'static`, so laying the page out costs
148 + /// no copies of strings that are about to be formatted anyway.
149 + #[derive(Debug, Clone, Copy)]
150 + enum Row {
151 + /// The gap between sections.
152 + Blank,
153 + /// A section heading.
154 + Title(&'static str),
155 + /// A project and the license it is under.
156 + Project {
157 + name: &'static str,
158 + license: &'static str,
159 + },
160 + /// Where to find it.
161 + Detail(&'static str),
162 + /// What it is doing here, when the name does not say it.
163 + Note(&'static str),
164 + }
165 +
126 166 /// Which field of the account step has focus.
127 167 ///
128 168 /// Indices into a [`FocusRing`], named so the render and key paths agree about
@@ -2146,6 +2186,19 @@
2146 2186 /// The encryption checkbox, until the step is confirmed and it becomes an
2147 2187 /// answer. Starts ticked: Alloy encrypts unless told not to.
2148 2188 encrypt: bool,
2189 + /// How far the credits page is scrolled, in lines.
2190 + credits_scroll: usize,
2191 + /// How many lines of the credits page last fitted on screen.
2192 + ///
2193 + /// A [`Cell`](std::cell::Cell) because only `render` knows the height and
2194 + /// `render` takes `&self`. Page-down and End cannot be spelled without it,
2195 + /// and neither can an honest bottom stop: clamping in `render` alone would
2196 + /// leave the stored offset running past the end, so the first few Ups after
2197 + /// End would move nothing while the number came back down.
2198 + ///
2199 + /// Zero until the page has been drawn once, which is why every reader
2200 + /// treats zero as "one screen" rather than as a real measurement.
2201 + credits_viewport: std::cell::Cell<usize>,
2149 2202 answers: Answers,
2150 2203 error: Option<String>,
2151 2204 /// The install, once it has been confirmed and started.
@@ -2189,6 +2242,8 @@
2189 2242 recovery_typed: TextField::new(),
2190 2243 recovery_ack: false,
2191 2244 encrypt: true,
2245 + credits_scroll: 0,
2246 + credits_viewport: std::cell::Cell::new(0),
2192 2247 answers: Answers::default(),
2193 2248 error: None,
2194 2249 running: None,
@@ -2791,6 +2846,126 @@
2791 2846 frame.render_widget(Paragraph::new(lines), area);
2792 2847 }
2793 2848
2849 + /// The credits page as rows, before anything styles or scrolls them.
2850 + ///
2851 + /// Rows rather than [`Line`]s because the key path needs to know how many
2852 + /// there are and has no [`Theme`] to build spans with, and because a second
2853 + /// function counting what a first function draws is a drift waiting to
2854 + /// happen. One list, counted and styled from the same shape.
2855 + fn credits_rows() -> Vec<Row> {
2856 + let mut rows = Vec::new();
2857 + for section in &crate::credits::credits().sections {
2858 + if !rows.is_empty() {
2859 + rows.push(Row::Blank);
2860 + }
2861 + rows.push(Row::Title(&section.title));
2862 + for project in &section.projects {
2863 + // One string per row, in the order someone wants them: what it
2864 + // is and what it is under, then where to find it, then why it
2865 + // is here. Three columns would need the widest url to fit and
2866 + // they run past 50 characters; a url with its note appended
2867 + // reached 127, and a `Paragraph` that does not wrap truncates
2868 + // rather than folds, so the longest note would have been the
2869 + // one nobody could read.
2870 + rows.push(Row::Project {
2871 + name: &project.name,
2872 + license: &project.license,
2873 + });
2874 + rows.push(Row::Detail(&project.url));
2875 + if let Some(note) = project.note.as_deref() {
2876 + rows.push(Row::Note(note));
2877 + }
2878 + }
2879 + }
2880 + rows
2881 + }
2882 +
2883 + /// The rows, styled.
2884 + ///
2885 + /// Built fresh per frame rather than cached on the view: a few hundred
2886 + /// short spans off a manifest that is already parsed and held static, on a
2887 + /// screen that redraws at the shell's idle tick. A cache would have to be
2888 + /// invalidated on the one thing that can change under it, the theme.
2889 + fn credits_lines(theme: &Theme) -> Vec<Line<'static>> {
2890 + Self::credits_rows()
2891 + .into_iter()
2892 + .map(|row| match row {
2893 + Row::Blank => Line::default(),
2894 + Row::Title(title) => Line::from(text::secondary(theme, title.to_string())),
2895 + Row::Project { name, license } => Line::from(vec![
2896 + text::primary(theme, format!(" {name:<24}")),
2897 + text::muted(theme, license.to_string()),
2898 + ]),
2899 + Row::Detail(url) => Line::from(text::muted(theme, format!(" {url}"))),
2900 + Row::Note(note) => Line::from(text::muted(theme, format!(" {note}"))),
2901 + })
2902 + .collect()
2903 + }
2904 +
2905 + /// How many lines the page has.
2906 + ///
2907 + /// One row is one line, so this is the row count. The key path asks for it
2908 + /// without a theme, which is the whole reason rows exist separately.
2909 + fn credits_len() -> usize {
2910 + Self::credits_rows().len()
2911 + }
2912 +
2913 + /// The last line the page can be scrolled to, given what fits on screen.
2914 + ///
2915 + /// Zero when everything fits, which is what makes every key on this page a
2916 + /// no-op on a tall terminal rather than a scroll into blank space.
2917 + fn credits_max_scroll(&self, lines: usize) -> usize {
2918 + let viewport = self.credits_viewport.get().max(1);
2919 + lines.saturating_sub(viewport)
2920 + }
2921 +
2922 + /// The credits page: what Alloy ships, and under what terms.
2923 + ///
2924 + /// A [`Paragraph`] with a scroll offset rather than an
2925 + /// [`AlloyList`](alloy_tui::AlloyList). A list's offset derives from its
2926 + /// selection, so it draws a highlighted row that moves as you scroll, and
2927 + /// there is nothing here to select: this is prose, and the highlight would
2928 + /// be an affordance promising an Enter that does something else.
2929 + fn render_credits(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
2930 + let lines = Self::credits_lines(theme);
2931 + // Recorded for the key path, which cannot ask the frame how tall it is.
2932 + self.credits_viewport.set(area.height as usize);
2933 +
2934 + let offset = self
2935 + .credits_scroll
2936 + .min(self.credits_max_scroll(lines.len()));
2937 + frame.render_widget(
2938 + Paragraph::new(lines).scroll((u16::try_from(offset).unwrap_or(u16::MAX), 0)),
2939 + area,
2940 + );
2941 + }
2942 +
2943 + /// Keys for the credits page: scrolling, and nothing else.
2944 + ///
2945 + /// Enter is not here. It starts the install, which is [`handle`](Self::handle)'s
2946 + /// to route, so this stays the one place that only moves the page.
2947 + fn scroll_credits(&mut self, key: KeyEvent) -> Flow {
2948 + let max = self.credits_max_scroll(Self::credits_len());
2949 + // A page is one screen less a line, so the line you were reading at the
2950 + // bottom is at the top after the jump and nothing crosses the fold
2951 + // unread.
2952 + let page = self.credits_viewport.get().max(2) - 1;
2953 +
2954 + self.credits_scroll = match key.code {
2955 + KeyCode::Char('j') | KeyCode::Down => self.credits_scroll.saturating_add(1),
2956 + KeyCode::Char('k') | KeyCode::Up => self.credits_scroll.saturating_sub(1),
2957 + KeyCode::PageDown => self.credits_scroll.saturating_add(page),
2958 + KeyCode::PageUp => self.credits_scroll.saturating_sub(page),
2959 + KeyCode::Home => 0,
2960 + KeyCode::End => max,
2961 + _ => self.credits_scroll,
2962 + }
2963 + // Clamped at both ends, and no wrap. A page that jumped back to the top
2964 + // from the bottom would read as a redraw rather than as a move.
2965 + .min(max);
2966 + Flow::Continue
2967 + }
2968 +
2794 2969 /// Whether the recovery phrase is waiting to be written down.
2795 2970 ///
2796 2971 /// True only between a successful install and the user typing the phrase
@@ -3156,7 +3331,8 @@
3156 3331 hint("space", "toggle"),
3157 3332 hint("enter", "next"),
3158 3333 ],
3159 - Step::Summary => vec![hint("enter", "install")],
3334 + Step::Summary => vec![hint("enter", "next")],
3335 + Step::Credits => vec![hint("j/k", "scroll"), hint("enter", "install")],
3160 3336 };
3161 3337 if !self.steps.is_first() {
3162 3338 hints.push(hint("esc", "back"));
@@ -3197,6 +3373,7 @@
3197 3373 Step::Account => return self.render_account(frame, inner, theme),
3198 3374 Step::Encryption => return self.render_encryption(frame, inner, theme),
3199 3375 Step::Summary => return self.render_summary(frame, inner, theme),
3376 + Step::Credits => return self.render_credits(frame, inner, theme),
3200 3377 Step::Disk => {}
3201 3378 }
3202 3379
@@ -3244,11 +3421,20 @@
3244 3421 Step::Hostname => return self.edit_hostname(key),
3245 3422 Step::Account => return self.edit_account(key),
3246 3423 Step::Encryption => return self.edit_encryption(key),
3424 + // The review no longer installs: it advances to the credits, which
3425 + // is the step that does. Enter meaning two different things one
3426 + // screen apart is the whole of what moved here.
3247 3427 Step::Summary => {
3428 + if key.code == KeyCode::Enter {
3429 + self.steps.advance();
3430 + }
3431 + return Flow::Continue;
3432 + }
3433 + Step::Credits => {
3248 3434 if key.code == KeyCode::Enter {
3249 3435 return self.confirm_install();
3250 3436 }
3251 - return Flow::Continue;
3437 + return self.scroll_credits(key);
3252 3438 }
3253 3439 Step::Disk => {}
3254 3440 }
@@ -3594,6 +3780,8 @@
3594 3780 recovery_typed: TextField::new(),
3595 3781 recovery_ack: false,
3596 3782 encrypt: true,
3783 + credits_scroll: 0,
3784 + credits_viewport: std::cell::Cell::new(0),
3597 3785 answers: Answers::default(),
3598 3786 error: None,
3599 3787 running: None,
@@ -4133,21 +4321,129 @@
4133 4321 (view, log)
4134 4322 }
4135 4323
4136 - // The summary is not the gate. Enter there raises the modal; only answering
4137 - // that runs anything.
4324 + /// One step past the summary, which is where the install is started from.
4325 + fn at_credits() -> (InstallView, CommandLog) {
4326 + let (mut view, mut log) = at_summary();
4327 + view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
4328 + assert_eq!(view.step(), Step::Credits, "fixture stalled on the summary");
4329 + (view, log)
4330 + }
4331 +
4332 + // The summary stopped being the gate when the credits page went in after
4333 + // it. Enter here advances, and the disk is not mentioned yet.
4138 4334 #[test]
4139 - fn enter_on_the_summary_raises_a_destructive_confirm() {
4335 + fn enter_on_the_summary_advances_to_the_credits() {
4140 4336 let (mut view, mut log) = at_summary();
4141 4337
4142 4338 let flow = view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
4143 4339
4340 + assert!(
4341 + matches!(flow, Flow::Continue),
4342 + "the summary asked to install"
4343 + );
4344 + assert_eq!(view.step(), Step::Credits);
4345 + }
4346 +
4347 + // The credits page is the gate. Enter there raises the modal; only
4348 + // answering that runs anything.
4349 + #[test]
4350 + fn enter_on_the_credits_raises_a_destructive_confirm() {
4351 + let (mut view, mut log) = at_credits();
4352 +
4353 + let flow = view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
4354 +
4144 4355 let Flow::Confirm(confirm) = flow else {
4145 - panic!("the summary ran without confirming");
4356 + panic!("the credits page ran without confirming");
4146 4357 };
4147 4358 assert_eq!(confirm.severity, Severity::Error);
4148 4359 assert!(confirm.message.contains("/dev/sda"), "{}", confirm.message);
4149 4360 }
4150 4361
4362 + // Esc from the credits is a way back to the answers, not a way out. A
4363 + // review screen you cannot return to from the last page would make the
4364 + // credits a trap rather than a step.
4365 + #[test]
4366 + fn back_from_the_credits_returns_to_the_summary_with_the_answers_intact() {
4367 + let (mut view, _log) = at_credits();
4368 +
4369 + let flow = view.cancel();
4370 +
4371 + assert!(matches!(flow, Flow::Continue), "esc left the installer");
4372 + assert_eq!(view.step(), Step::Summary);
4373 + assert_eq!(view.answers.disk.as_deref(), Some("/dev/sda"));
4374 + assert_eq!(view.answers.hostname.as_deref(), Some(DEFAULT_HOSTNAME));
4375 + assert_eq!(view.answers.username.as_deref(), Some("max"));
4376 + }
4377 +
4378 + // The page is longer than any terminal, so it scrolls, and it stops at both
4379 + // ends. Running off the bottom into blank space is how a reader concludes
4380 + // the list ended where the blank started.
4381 + #[test]
4382 + fn the_credits_scroll_clamps_at_both_ends() {
4383 + let (mut view, _log) = at_credits();
4384 + // As if it had been drawn into a short pane once.
4385 + view.credits_viewport.set(10);
4386 + let max = view.credits_max_scroll(InstallView::credits_len());
4387 + assert!(max > 0, "the manifest is shorter than ten lines");
4388 +
4389 + view.scroll_credits(KeyEvent::from(KeyCode::Up));
4390 + assert_eq!(view.credits_scroll, 0, "scrolled above the first line");
4391 +
4392 + view.scroll_credits(KeyEvent::from(KeyCode::End));
4393 + assert_eq!(view.credits_scroll, max);
4394 + view.scroll_credits(KeyEvent::from(KeyCode::Down));
4395 + assert_eq!(view.credits_scroll, max, "scrolled past the last line");
4396 +
4397 + // And back, one press at a time rather than several that do nothing:
4398 + // End stores the real bottom, not an offset the render clamps later.
4399 + view.scroll_credits(KeyEvent::from(KeyCode::Up));
4400 + assert_eq!(view.credits_scroll, max - 1);
4401 +
4402 + view.scroll_credits(KeyEvent::from(KeyCode::Home));
4403 + assert_eq!(view.credits_scroll, 0);
4404 + }
4405 +
4406 + // A page that fits needs no scrolling, and every key on it should do
4407 + // nothing rather than scroll the text off the top of its own pane.
4408 + #[test]
4409 + fn a_credits_page_that_fits_does_not_scroll() {
4410 + let (mut view, _log) = at_credits();
4411 + view.credits_viewport.set(InstallView::credits_len() + 5);
4412 +
4413 + view.scroll_credits(KeyEvent::from(KeyCode::End));
4414 + assert_eq!(view.credits_scroll, 0);
4415 + view.scroll_credits(KeyEvent::from(KeyCode::PageDown));
4416 + assert_eq!(view.credits_scroll, 0);
4417 + }
4418 +
4419 + // Every section is a title, every project is two rows and a third if it
4420 + // carries a note, with a blank between sections. An entry that lost a row
4421 + // would leave the page scrolling short of its own last line.
4422 + #[test]
4423 + fn the_rows_are_the_manifest_laid_out() {
4424 + let sections = &crate::credits::credits().sections;
4425 + let titles = sections.len();
4426 + let blanks = titles.saturating_sub(1);
4427 + let projects: usize = sections.iter().map(|section| section.projects.len()).sum();
4428 + let notes = sections
4429 + .iter()
4430 + .flat_map(|section| &section.projects)
4431 + .filter(|project| project.note.is_some())
4432 + .count();
4433 + assert_eq!(
4434 + InstallView::credits_len(),
4435 + titles + blanks + projects * 2 + notes
4436 + );
4437 + }
4438 +
4439 + // The credits page reads; it does not type. Without this the shell stops
4440 + // treating q as quit on the last screen before an install.
4441 + #[test]
4442 + fn the_credits_page_is_not_a_typing_step() {
4443 + let (view, _log) = at_credits();
4444 + assert!(!view.text_entry(), "the shell should claim q again here");
4445 + }
4446 +
4151 4447 #[test]
4152 4448 fn the_summary_is_not_a_typing_step() {
4153 4449 let (view, _log) = at_summary();
@@ -5845,6 +6141,6 @@
5845 6141 let (view, _log) = view();
5846 6142 let title = view.title();
5847 6143 assert!(title.contains("mock"), "{title}");
5848 - assert!(title.contains("step 1 of 5"), "{title}");
6144 + assert!(title.contains("step 1 of 6"), "{title}");
5849 6145 }
5850 6146 }
@@ -9,6 +9,7 @@
9 9 mod audio;
10 10 mod bind;
11 11 mod cli;
12 + mod credits;
12 13 mod display;
13 14 mod install;
14 15 mod mesh;
@@ -1,0 +1,140 @@
1 + //! The credits manifest: who wrote what Alloy ships, read at the last screen
2 + //! before an install.
3 + //!
4 + //! Curated rather than generated, and the reasoning for that is in
5 + //! `credits.toml`'s own header. This module is only the shape of that file and
6 + //! the promise that it parses.
7 + //!
8 + //! **Embedded with `include_str!`, like [`recovery`](crate::recovery)'s
9 + //! wordlist and the rio schema.** The page has to draw from installer media, an
10 + //! ostree deployment whose `/usr` is read-only, and a machine mid-install with
11 + //! no network. A file on disk could be absent in all three; bytes in the binary
12 + //! cannot be. The cost is that changing the manifest means rebuilding, which is
13 + //! true of everything else the console embeds.
14 + //!
15 + //! <!-- wiki: alloy-console -->
16 +
17 + use std::sync::OnceLock;
18 +
19 + use serde::Deserialize;
20 +
21 + /// The manifest, as source.
22 + const MANIFEST: &str = include_str!("../credits.toml");
23 +
24 + /// Everything the page draws, in the order it draws it.
25 + #[derive(Debug, Default, Deserialize)]
26 + pub(crate) struct Credits {
27 + #[serde(default, rename = "section")]
28 + pub(crate) sections: Vec<Section>,
29 + }
30 +
31 + /// One heading and the projects under it.
32 + #[derive(Debug, Deserialize)]
33 + pub(crate) struct Section {
34 + pub(crate) title: String,
35 + #[serde(default, rename = "project")]
36 + pub(crate) projects: Vec<Project>,
37 + }
38 +
39 + /// One project: what it is called, what it is under, and where to find it.
40 + #[derive(Debug, Deserialize)]
41 + pub(crate) struct Project {
42 + pub(crate) name: String,
43 + /// An SPDX identifier or expression, as the project itself states it.
44 + pub(crate) license: String,
45 + pub(crate) url: String,
46 + /// What this project is doing here, when the name does not say it.
47 + pub(crate) note: Option<String>,
48 + }
49 +
50 + /// The parsed manifest, parsed once.
51 + ///
52 + /// A malformed manifest yields an empty one rather than a panic. The guard
53 + /// against that happening at all is [`the_manifest_parses`](tests), which fails
54 + /// the build's tests; what this decides is only what a broken edit that somehow
55 + /// shipped does to someone mid-install, and an empty page they can still press
56 + /// Enter through beats taking the installer down on the screen before the
57 + /// install.
58 + pub(crate) fn credits() -> &'static Credits {
59 + static PARSED: OnceLock<Credits> = OnceLock::new();
60 + PARSED.get_or_init(|| toml::from_str(MANIFEST).unwrap_or_default())
61 + }
62 +
63 + #[cfg(test)]
64 + mod tests {
65 + use super::*;
66 +
67 + /// The manifest is embedded, so a malformed edit is a compile-and-test
68 + /// problem rather than a runtime one. This is the test that makes it so.
69 + #[test]
70 + fn the_manifest_parses() {
71 + let credits: Credits = toml::from_str(MANIFEST).expect("credits.toml parses");
72 + assert!(!credits.sections.is_empty(), "the manifest has sections");
73 + }
74 +
75 + /// Every field the page prints has something in it.
76 + ///
77 + /// A blank license or url renders as a gap that reads like a rendering bug
78 + /// rather than like a missing entry, and the screen it renders on is the
79 + /// one nobody is going to file a report about.
80 + #[test]
81 + fn every_project_states_a_name_a_license_and_a_url() {
82 + for section in &credits().sections {
83 + assert!(!section.title.trim().is_empty(), "a section has no title");
84 + assert!(
85 + !section.projects.is_empty(),
86 + "section '{}' lists no projects",
87 + section.title
88 + );
89 + for project in &section.projects {
90 + assert!(
91 + !project.name.trim().is_empty(),
92 + "a project in '{}' has no name",
93 + section.title
94 + );
95 + assert!(
96 + !project.license.trim().is_empty(),
97 + "'{}' states no license",
98 + project.name
99 + );
100 + assert!(
101 + !project.url.trim().is_empty(),
102 + "'{}' states no url",
103 + project.name
104 + );
105 + }
106 + }
107 + }
108 +
109 + /// A url that is not one is a url nobody can follow off a screen they
110 + /// cannot click. Both schemes are here because the manifest carries neither
111 + /// mail nor git remotes, and anything else is a typo.
112 + #[test]
113 + fn every_url_is_a_url() {
114 + for project in credits().sections.iter().flat_map(|s| &s.projects) {
115 + assert!(
116 + project.url.starts_with("https://") || project.url.starts_with("http://"),
117 + "'{}' has '{}' where a url goes",
118 + project.name,
119 + project.url
120 + );
121 + }
122 + }
123 +
124 + /// Alloy names itself first.
125 + ///
126 + /// Not vanity: the page exists to say what the install is made of, and the
127 + /// thing being installed is the first answer to that. It is also the entry
128 + /// whose license the repo can change under this file, which is why it is
129 + /// worth a test that names it.
130 + #[test]
131 + fn alloy_credits_itself_first_and_under_its_own_license() {
132 + let first = credits()
133 + .sections
134 + .first()
135 + .and_then(|section| section.projects.first())
136 + .expect("the manifest opens with a project");
137 + assert_eq!(first.name, "Alloy");
138 + assert_eq!(first.license, "MIT");
139 + }
140 + }