Skip to main content

max / alloy

setup: offer both enrollments at first boot The first-boot flow docs/CONTINUITY.md has specced since v0.5, as `alloy setup`. Two rows over the same mesh and sync backend seams the two views drive, so there is no third enrollment path to keep in agreement with them. The mesh row leaves the TUI and the sync row does not, which is the one asymmetry a two-row screen has to carry. The user comes back from `run0 tailscale up` to take the other row, and the shell refreshes a rebuilt view with a single tick call; a five-tick poll counter would swallow four out of five of those and leave the row just enrolled reading "not enrolled". Hence the stale flag. The screen appears once: `--if-first-boot` is what the session autostart calls, and the marker is a Local key in the console's store, written on open so a closed lid does not re-ask at every login. It never replicates. A synced copy would suppress the screen on the next machine set up, which is the one that needs it. The config store moves out of theme.rs into store.rs. theme declared it while the theme was the only key; a first-boot marker read out of a module named for palettes is the wrong place to look for it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-25 21:19 UTC
Signed with PGP, not checked
Commit: a6b06be0fc14615c9340531dc968b9fa7ea79ba4
Parent: 818c439
8 files changed, +703 insertions, -107 deletions
M docs/CONSOLE.md +3 -2
@@ -23,6 +23,7 @@
23 23 alloy pkg box # containers: the isolation dial [shipped]
24 24 alloy update # system updates: rpm-ostree front
25 25 alloy sync # syncthing front (see CONTINUITY.md) [shipped]
26 + alloy setup # the first-boot offer: mesh and sync [shipped]
26 27 alloy settings # system settings and app configs: two tabs over one form
27 28 alloy config <path> # one config file, opened directly, without the tab chrome
28 29 alloy theme <name> # swap the runtime theme; reads makeover's themes/*.toml
@@ -194,10 +195,10 @@
194 195
195 196 The ordering below is the reverse of what this document originally planned, which put `alloy config` alone at v0.5 and every live-state subcommand at v1. The live-state views went first instead. They are small enough to carve one at a time, and each one forced a piece of shared machinery into existence against something real: the shell chrome and log pane from `net`, the second list and the `Cursor` from `audio`, the two-pane layout and `AlloyConnector` from `audio`'s routing, the background tick from watching streams appear. `alloy settings` needs the form widgets and the schema parser at once, and it is a better shape to build on a shell that has already carried three screens.
196 197
197 - - **Shipped.** `alloy net`, `alloy audio`, `alloy mesh`, 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.
198 + - **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.
198 199 - **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.
199 200 - **Blocked on the target machine.** `alloy display` fronts `swaymsg`/`wlr-randr`, neither of which exists on a non-sway development box, so its parser cannot be checked against real output here — the way the two box-parser bugs got written. The rpm-ostree tabs were in this category until their output was captured from a booted Alloy install in QEMU (2026-07-22); the `install` and `system` parsers are written against that real capture and re-checkable with `parses_this_machines_real_status` on any ostree box. `alloy display` still wants the image or real hardware.
200 - - **Then.** `alloy theme`, which swaps the runtime theme in place (makeover consumer, no re-login). First-boot flow (see [CONTINUITY.md](CONTINUITY.md)) is a thin shim over `alloy mesh` and `alloy sync` enrollments, and both halves now exist, so the shim is two calls into screens that already work. One thing it inherits: mesh enrollment is a terminal suspend rather than an in-place command, because `run0 tailscale up` escalates and then blocks on a browser login. A first-boot screen that offers both has to survive tearing down and rebuilding between the two rows.
201 + - **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.
201 202 - **v1.x.** Additional adopted-tool schemas as the v0 stack grows. (`alloy hinged` was shelved with the FW12 tablet flow in the pivot.)
202 203 - **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.
203 204
M docs/CONTINUITY.md +16 -12
@@ -35,23 +35,27 @@
35 35
36 36 ## First-boot flow
37 37
38 - At first login, one screen with two rows:
38 + **Shipped as `alloy setup`.** At first login, one screen with two rows:
39 39
40 40 ```
41 - +---------------------------------------------------------+
42 - | Alloy first-boot |
43 - | |
44 - | Network identity [ Enroll Tailscale ] [ Skip ] |
45 - | |
46 - | File sync [ Enroll Syncthing ] [ Skip ] |
47 - | |
48 - | [ Done ] |
49 - +---------------------------------------------------------+
41 + +------------------------------------------------------------------------------+
42 + | first boot |
43 + | two enrollments make this machine rejoinable. both are optional. |
44 + | |
45 + | > network identity not enrolled reach your other machines from anywhere |
46 + | file sync not enrolled your files follow you onto this machine |
47 + | |
48 + | q when you are done. skipping is fine: alloy mesh and alloy sync enroll later.|
49 + +------------------------------------------------------------------------------+
50 50 ```
51 51
52 - Skip is fine and reversible: later, the same enrollments live under `alloy mesh` and `alloy sync`. This is not a wizard, and Alloy does not grow one. Two rows, two actions, done.
52 + Skip is fine and reversible, and the screen says so rather than leaving it to be discovered: skipping is leaving, not an answer to record. This is not a wizard, and Alloy does not grow one. Two rows, two actions, done.
53 53
54 - **Deferred to v1.** The first-boot flow itself is v1 scope. v0.5 users start the daemons manually via the console subcommands. Shipping the subcommands first, and the wizard after, is the correct ordering: the wizard is a thin shim over the enrollment actions the subcommands already expose.
54 + It authors nothing. Both rows act through the same `mesh` and `sync` backend seams the two views use, so there is no third enrollment path to keep in agreement with them. Each row reads its own backend and reports enrolled, not enrolled, or unknown; a backend that will not answer is still offered rather than going quiet, which is where this screen deliberately differs from `alloy mesh` (that one reads an unreadable status as enrolled, so a broken `tailscale status` cannot invite a signed-in user to sign in again; at first boot the likelier cause is a daemon that has not started, and the offer is the point).
55 +
56 + The mesh row inherits the terminal suspend, which is the one thing the shim could not simply delegate. `run0 tailscale up` escalates and then blocks on a browser login, so signing in tears the console down and rebuilds it, and the user is expected to come back for the *other* row. The shell refreshes a rebuilt view by calling its tick once, so the screen refreshes on the first tick after a suspend rather than on its five-tick poll counter; without that the row just enrolled would read "not enrolled" for several seconds after the sign-in that enrolled it.
57 +
58 + **Appearing once.** The session autostart (`etc/skel/.config/sway/config`) runs `alloy setup --if-first-boot`, and the console records that it asked under `first_boot` in the same SQLite store the theme preference lives in. The marker is written when the screen opens, not when it closes: an exit that never reaches the end, a closed lid or a killed terminal, would otherwise leave the machine asking again at every login, which is the behavior the screen exists to avoid. The cost of that choice is the reverse, a user who never looked at the screen it appeared on gets no second offer, and it is the cheaper mistake because both enrollments stay one command away. The key never replicates, deliberately: a synced copy would suppress the screen on the next machine the user sets up, which is exactly the machine that needs it. Typing `alloy setup` by hand always opens the screen; only the automatic invocation is gated.
55 59
56 60 ## Console subcommands
57 61
@@ -16,7 +16,9 @@
16 16 mod run;
17 17 mod schema;
18 18 mod settings;
19 + mod setup;
19 20 mod shell;
21 + mod store;
20 22 mod sync;
21 23 mod system;
22 24 mod theme;
@@ -72,6 +74,16 @@
72 74 #[command(subcommand)]
73 75 surface: Option<SyncSurface>,
74 76 },
77 + /// The first-boot offer: network identity and file sync
78 + Setup {
79 + /// Do nothing if this machine has been offered the screen already.
80 + ///
81 + /// The form the session autostart calls. Running `alloy setup` by hand
82 + /// always opens the screen, because someone who typed the verb is
83 + /// asking for it; only the automatic invocation is gated.
84 + #[arg(long)]
85 + if_first_boot: bool,
86 + },
75 87 }
76 88
77 89 /// Which tab of the sync view to open on.
@@ -147,6 +159,22 @@
147 159 let mut view = sync::SyncView::new(tab, &mut log);
148 160 shell::run(&theme, &mut view, &mut log)
149 161 }
162 + Command::Setup { if_first_boot } => {
163 + if if_first_boot && setup::seen() {
164 + return Ok(());
165 + }
166 + // Marked before the screen is drawn rather than after it closes.
167 + // docs/CONTINUITY.md commits to the screen appearing once, and an
168 + // exit that never reaches the end of this function — a closed lid,
169 + // a killed terminal — would otherwise leave the machine asking
170 + // again at every login. See the module docs for the trade.
171 + let marker_error = setup::mark_seen(&mut log);
172 + let mut view = setup::SetupView::new(&mut log);
173 + if let Some(error) = marker_error {
174 + view.warn(error);
175 + }
176 + shell::run(&theme, &mut view, &mut log)
177 + }
150 178 Command::Install => {
151 179 let mut view = install::InstallView::new(&mut log);
152 180 shell::run(&theme, &mut view, &mut log)
@@ -175,7 +175,7 @@
175 175 }
176 176
177 177 impl MeshStatus {
178 - fn is_running(&self) -> bool {
178 + pub(crate) fn is_running(&self) -> bool {
179 179 self.backend_state == "Running"
180 180 }
181 181 }
@@ -221,7 +221,7 @@
221 221 ///
222 222 /// Returns the trimmed value, or `None` for the vendor's control plane, which
223 223 /// is what an empty field means.
224 - fn validate_login_server(value: &str) -> Result<Option<String>, String> {
224 + pub(crate) fn validate_login_server(value: &str) -> Result<Option<String>, String> {
225 225 let value = value.trim();
226 226 if value.is_empty() {
227 227 return Ok(None);
@@ -10,10 +10,9 @@
10 10 use alloy_tui::Theme;
11 11 use anyhow::{Context, Result};
12 12 use makeover::{ThemeDefaults, ThemeDirs, ThemeMeta, ThemeSelection, Variant};
13 - use synckit_config::rusqlite::Connection;
14 - use synckit_config::{ConfigSpec, ConfigStore, Posture};
15 13
16 14 use crate::cli::Effect;
15 + use crate::store;
17 16
18 17 /// Default light theme (docs/TOKENS.md).
19 18 pub(crate) const DEFAULT_LIGHT: &str = "akari-dawn";
@@ -21,19 +20,6 @@
21 20 /// Default dark theme (docs/TOKENS.md).
22 21 pub(crate) const DEFAULT_DARK: &str = "akari-night";
23 22
24 - /// The console's config store: one table, one key.
25 - ///
26 - /// Declared for the family's shared config store even though the console never
27 - /// syncs — so posture is inert here and `theme` takes the safe default,
28 - /// [`Posture::Local`]. The key name is the convention's, not this crate's: every
29 - /// app in the family stores the chosen theme under `theme`.
30 - pub(crate) const CONFIG: ConfigSpec =
31 - ConfigSpec::new("console_config", &[(THEME_KEY, Posture::Local)]);
32 -
33 - /// The one key the console stores, named once so the read and the write cannot
34 - /// disagree about its spelling.
35 - const THEME_KEY: &str = "theme";
36 -
37 23 /// Theme search path: the user's own themes win, then whatever the image ships,
38 24 /// then makeover's bundled set for a run from a dev tree.
39 25 ///
@@ -49,7 +35,7 @@
49 35 ThemeDirs::new()
50 36 .bundled(makeover::bundled_themes_dir())
51 37 .system(Some(PathBuf::from("/usr/share/alloy/themes")))
52 - .custom(dirs_config_home().map(|config| config.join("alloy").join("themes")))
38 + .custom(store::config_home().map(|config| config.join("alloy").join("themes")))
53 39 .build()
54 40 }
55 41
@@ -79,18 +65,6 @@
79 65 if dark { Variant::Dark } else { Variant::Light }
80 66 }
81 67
82 - fn dirs_config_home() -> Option<PathBuf> {
83 - // XDG_CONFIG_HOME wins when set and absolute; the spec says a relative
84 - // value is invalid and must be ignored rather than resolved against cwd.
85 - if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
86 - let path = PathBuf::from(xdg);
87 - if path.is_absolute() {
88 - return Some(path);
89 - }
90 - }
91 - std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))
92 - }
93 -
94 68 /// Every theme the console can render, from the same search path it loads from.
95 69 ///
96 70 /// makeover's own scan, not a second one: the crate already knows what a theme
@@ -100,48 +74,13 @@
100 74 makeover::list_themes_from_dirs(&search_path())
101 75 }
102 76
103 - /// Where the console remembers what it is told.
104 - ///
105 - /// `$XDG_CONFIG_HOME/alloy/console.db`, a SQLite store the shared config crate
106 - /// owns. The console already owns that directory — custom themes live in
107 - /// `alloy/themes/`, user schemas in `alloy/schemas/` — so the store joins them
108 - /// rather than opening a new root. SQLite rather than the old `console.toml`
109 - /// because every app in the family now keeps settings in one store, so a
110 - /// preference can carry across devices where the app syncs; the console does not
111 - /// sync, but it reads and writes the same shape.
112 - ///
113 - /// Deliberately not a system-wide file: which theme a terminal renders in is
114 - /// the person's, not the machine's, and two accounts on one machine should not
115 - /// fight over it.
116 - pub(crate) fn store_path() -> Option<PathBuf> {
117 - dirs_config_home().map(|config| config.join("alloy").join("console.db"))
118 - }
119 -
120 77 /// What the user chose, which is not the same as what is being rendered.
121 78 ///
122 79 /// Nothing saved reads as [`ThemeSelection::Follow`], so a console that has
123 80 /// never been told anything tracks the terminal rather than pinning whatever it
124 81 /// guessed on first launch.
125 82 pub(crate) fn selection() -> ThemeSelection {
126 - ThemeSelection::parse(stored_theme().as_deref())
127 - }
128 -
129 - /// The stored `theme` value, or `None` when nothing is saved or the store will
130 - /// not read.
131 - ///
132 - /// Best-effort by design: a missing store is the normal first-launch state, and
133 - /// a store that will not open is not worth crashing the console over before the
134 - /// first frame — either reads as "nothing chosen", which follows the terminal.
135 - /// Reading deliberately does **not** create the store; it appears only when a
136 - /// choice is written, so a console never told anything leaves no file behind.
137 - fn stored_theme() -> Option<String> {
138 - let path = store_path()?;
139 - if !path.exists() {
140 - return None;
141 - }
142 - let conn = Connection::open(&path).ok()?;
143 - let store = ConfigStore::open(&conn, &CONFIG).ok()?;
144 - store.get(&conn, THEME_KEY).ok()?
83 + ThemeSelection::parse(store::get(store::THEME).as_deref())
145 84 }
146 85
147 86 /// The id the console is rendering in.
@@ -156,18 +95,11 @@
156 95
157 96 /// The write that remembers a choice.
158 97 ///
159 - /// Returned rather than performed, like every other effect in the console, so
160 - /// the log shows the setting being written and the caller decides when. The set
161 - /// is an upsert on one key, so writing again replaces the choice rather than
162 - /// accumulating rows.
98 + /// Stored as the selection, not as the id it resolves to right now: a standing
99 + /// "follow the terminal" is a different answer from a pin on whichever theme
100 + /// the terminal happened to call for when the user chose.
163 101 pub(crate) fn remember(selection: &ThemeSelection) -> Result<Effect> {
164 - let db = store_path().context("no config directory to remember a theme in")?;
165 - Ok(Effect::Config {
166 - db,
167 - spec: CONFIG,
168 - key: THEME_KEY.to_string(),
169 - value: selection.as_str().to_string(),
170 - })
102 + store::set(store::THEME, selection.as_str())
171 103 }
172 104
173 105 /// Load a theme by id, or the console's current one when `id` is `None`.
@@ -292,27 +224,12 @@
292 224 else {
293 225 panic!("a theme is a config set, not a command or file write");
294 226 };
295 - assert_eq!(key, THEME_KEY, "the family convention's key name");
227 + assert_eq!(key, store::THEME, "the family convention's key name");
296 228 assert_eq!(value, selection.as_str());
297 229 assert_eq!(spec.table(), "console_config");
298 230 }
299 231 }
300 232
301 - // The console's spec drives a real store the same as any other consumer: a
302 - // set under `theme` reads back, proving the key name the console writes is
303 - // the key name it later reads.
304 - #[test]
305 - fn the_theme_key_round_trips_through_the_store() {
306 - let conn = Connection::open_in_memory().unwrap();
307 - let store = ConfigStore::open(&conn, &CONFIG).unwrap();
308 - assert_eq!(store.get(&conn, THEME_KEY).unwrap(), None, "unset follows");
309 - store.set(&conn, THEME_KEY, "akari-night").unwrap();
310 - assert_eq!(
311 - store.get(&conn, THEME_KEY).unwrap().as_deref(),
312 - Some("akari-night"),
313 - );
314 - }
315 -
316 233 // The console ships against these two ids; a rename in makeover's themes
317 234 // that misses this crate should fail here rather than at first launch.
318 235 #[test]
@@ -99,6 +99,13 @@
99 99 # swayosd-server backs the volume/brightness/caps overlays.
100 100 exec swayosd-server
101 101
102 + # The first-boot offer: a mesh for the machines, a sync for the files
103 + # (docs/CONTINUITY.md). --if-first-boot is what makes this a one-time event:
104 + # the console records that it asked, and every later login runs this line and
105 + # exits without drawing anything. Skipping is fine and reversible; `alloy mesh`
106 + # and `alloy sync` carry the same enrollments afterwards.
107 + exec $term -e alloy setup --if-first-boot
108 +
102 109 # Idle: dim to lock at 5 minutes, displays off at 10, and always lock
103 110 # before suspend so a closed lid never resumes into a live session.
104 111 # -w makes swayidle wait for the lock to come up before sleeping.
@@ -1,0 +1,854 @@
1 + //! `alloy setup` — the first-boot screen.
2 + //!
3 + //! docs/CONTINUITY.md: an Alloy install should be *rejoinable*, not just
4 + //! installable. Two enrollments make that true — a mesh for the machines and a
5 + //! sync for the files — and this is the screen that offers both at first login,
6 + //! before the user has learned that `alloy mesh` and `alloy sync` exist.
7 + //!
8 + //! It authors nothing. Every action here is one the two views already expose,
9 + //! against the same [`mesh::Backend`] and [`sync::Backend`] seams, so there is
10 + //! no third code path to keep in agreement with them. What this adds is that
11 + //! both offers are visible at once, on the one occasion when neither has been
12 + //! taken yet.
13 + //!
14 + //! # Not a wizard
15 + //!
16 + //! CONTINUITY.md is explicit that Alloy does not grow one: two rows, two
17 + //! actions, done. So this is a list, not a [`Steps`](crate::wizard::Steps)
18 + //! sequence. Nothing here is ordered, nothing gates anything else, and skipping
19 + //! is leaving rather than an answer to record. The two rows are the same two
20 + //! rows whether the user takes both, one, or neither.
21 + //!
22 + //! # Surviving the suspend
23 + //!
24 + //! The two enrollments are not symmetric, and this screen is where that costs
25 + //! something. Syncthing's is `systemctl --user enable --now`, which runs under
26 + //! a live ratatui and returns in milliseconds. Signing into a mesh is `run0
27 + //! tailscale up`, which escalates and then blocks printing a browser URL, so it
28 + //! goes out through [`Flow::Suspend`] — the console tears down, the command
29 + //! runs on a clean terminal, and the console is rebuilt. See [`mesh`] for why
30 + //! that is the only honest way to run it.
31 + //!
32 + //! A single-row screen would not care. This one does: the user is expected to
33 + //! come back and take the *other* row, and the screen they come back to is a
34 + //! rebuilt one. The shell refreshes a view after a suspend by calling
35 + //! [`View::tick`], so the rebuilt screen is correct only if this view's tick
36 + //! answers that call rather than sitting on its poll counter — which is what
37 + //! [`SetupView::stale`] is for. Without it the mesh row would show "not
38 + //! enrolled" for several seconds after the sign-in that enrolled it.
39 + //!
40 + //! # Appearing once
41 + //!
42 + //! CONTINUITY.md settles that the screen does not come back: after it has been
43 + //! shown, `alloy mesh` and `alloy sync` are the enrollment paths. The marker is
44 + //! [`store::SETUP`], written when the screen opens rather than when it closes,
45 + //! and `alloy setup --if-first-boot` is the form the session autostart calls so
46 + //! that a machine which has already asked stays quiet.
47 + //!
48 + //! Written on open, deliberately. A marker written on close would be missed by
49 + //! every exit that is not the ordinary one — a closed lid, a killed terminal —
50 + //! and the failure mode of that is the OS asking a question it was told once
51 + //! already, at every login, which is the behaviour this screen exists to avoid.
52 + //! The cost of the choice is the reverse: a user who never looked at the screen
53 + //! it appeared on does not get a second offer. That is the cheaper mistake,
54 + //! because both enrollments remain one command away and the screen says so.
55 +
56 + use alloy_tui::{
57 + AlloyBlock, AlloyList, Cursor, Hint, Severity, TextField, Theme, hint, layout, text,
58 + };
59 + use ratatui::Frame;
60 + use ratatui::crossterm::event::{KeyCode, KeyEvent};
61 + use ratatui::layout::Rect;
62 + use ratatui::style::{Modifier, Style};
63 + use ratatui::text::{Line, Span};
64 +
65 + use crate::cli::CommandLog;
66 + use crate::shell::{Flow, View, block_title};
67 + use crate::store;
68 + use crate::{mesh, sync};
69 +
70 + /// Ticks between background refreshes.
71 + ///
72 + /// The same five as [`mesh`]'s, and for the same reason: neither enrollment
73 + /// changes on the scale of a keypress, so polling every second would spawn
74 + /// processes to learn nothing. Every refresh that matters here is prompted —
75 + /// an action, a suspend, or `r`.
76 + const POLL_TICKS: u64 = 5;
77 +
78 + /// The value written under [`store::SETUP`]. Only its presence is read; the
79 + /// word is for whoever opens the store with `sqlite3`.
80 + const SEEN: &str = "seen";
81 +
82 + /// The two rows, in the order CONTINUITY.md draws them.
83 + ///
84 + /// Network identity first because it is the one that can need a browser, and a
85 + /// user who is going to be sent to one is better sent early than after they
86 + /// have decided the screen is finished with them.
87 + const ROWS: [Row; 2] = [Row::Mesh, Row::Sync];
88 +
89 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
90 + enum Row {
91 + Mesh,
92 + Sync,
93 + }
94 +
95 + impl Row {
96 + /// The row's name, which names the capability rather than the tool.
97 + ///
98 + /// Same reasoning as `alloy mesh`'s title (see [`mesh`]): someone at first
99 + /// boot who has never heard of Tailscale or Syncthing still knows whether
100 + /// they want their machines reachable and their files to follow them. The
101 + /// backend names appear in the detail column and in the log, so nothing is
102 + /// concealed; they are just not the first word.
103 + const fn label(self) -> &'static str {
104 + match self {
105 + Row::Mesh => "network identity",
106 + Row::Sync => "file sync",
107 + }
108 + }
109 +
110 + /// What taking the row buys, for a reader who does not know the tool.
111 + const fn promise(self) -> &'static str {
112 + match self {
113 + Row::Mesh => "reach your other machines from anywhere",
114 + Row::Sync => "your files follow you onto this machine",
115 + }
116 + }
117 + }
118 +
119 + /// Whether a row's enrollment has already been taken.
120 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
121 + enum Enrolled {
122 + Yes,
123 + No,
124 + /// The backend would not answer.
125 + ///
126 + /// Offered rather than refused, which is where this screen parts company
127 + /// with [`mesh`]: that view treats an unreadable status as enrolled, so a
128 + /// broken `tailscale status` does not invite a signed-in user to sign in
129 + /// again. At first boot the likelier cause is a daemon that has not started
130 + /// yet, and a screen whose whole purpose is to offer must not go quiet the
131 + /// one time the offer is most likely to be wanted.
132 + Unknown,
133 + }
134 +
135 + impl Enrolled {
136 + const fn label(self) -> &'static str {
137 + match self {
138 + Enrolled::Yes => "enrolled",
139 + Enrolled::No => "not enrolled",
140 + Enrolled::Unknown => "unknown",
141 + }
142 + }
143 +
144 + const fn severity(self) -> Severity {
145 + match self {
146 + Enrolled::Yes => Severity::Healthy,
147 + // Not an error. Declining both rows is a supported answer, and a
148 + // red line saying so would read as something having gone wrong.
149 + Enrolled::No | Enrolled::Unknown => Severity::Warn,
150 + }
151 + }
152 +
153 + /// Whether pressing enroll on this row does anything.
154 + const fn actionable(self) -> bool {
155 + !matches!(self, Enrolled::Yes)
156 + }
157 + }
158 +
159 + /// The `alloy setup` screen.
160 + pub(crate) struct SetupView {
161 + mesh: Box<dyn mesh::Backend>,
162 + sync: Box<dyn sync::Backend>,
163 + mesh_state: Enrolled,
164 + sync_state: Enrolled,
165 + cursor: Cursor,
166 + /// The control-server field, while the mesh row's overlay is open.
167 + ///
168 + /// Deliberately not prefilled, which is the one place this differs from
169 + /// `alloy mesh`'s otherwise identical overlay. That one prefills from the
170 + /// control plane in use so a reconnection shows which mesh it is rejoining;
171 + /// a machine at first boot has never joined one, so there is nothing to
172 + /// show and an empty field is the honest state.
173 + server: Option<TextField>,
174 + error: Option<String>,
175 + ticks: u64,
176 + /// Refresh on the next tick regardless of the poll counter.
177 + ///
178 + /// Set before handing the terminal away. See the module docs: the shell's
179 + /// post-suspend refresh arrives as a [`View::tick`] call, and a tick that
180 + /// only acts every [`POLL_TICKS`] would swallow four out of five of them.
181 + stale: bool,
182 + }
183 +
184 + impl SetupView {
185 + pub(crate) fn new(log: &mut CommandLog) -> Self {
186 + Self::with_backends(mesh::detect(), sync::detect(), log)
187 + }
188 +
189 + fn with_backends(
190 + mesh: Box<dyn mesh::Backend>,
191 + sync: Box<dyn sync::Backend>,
192 + log: &mut CommandLog,
193 + ) -> Self {
194 + let mut cursor = Cursor::new();
195 + cursor.resize(ROWS.len());
196 + let mut view = Self {
197 + mesh,
198 + sync,
199 + mesh_state: Enrolled::Unknown,
200 + sync_state: Enrolled::Unknown,
201 + cursor,
202 + server: None,
203 + error: None,
204 + ticks: 0,
205 + stale: false,
206 + };
207 + view.refresh(log);
208 + view
209 + }
210 +
211 + /// Carry a message raised before the screen existed.
212 + ///
213 + /// One caller: a first-boot marker that would not write, which happens
214 + /// before the view is constructed and is worth saying on the status line
215 + /// rather than swallowing. The next keypress clears it, like every other
216 + /// message here.
217 + pub(crate) fn warn(&mut self, message: String) {
218 + self.error = Some(message);
219 + }
220 +
221 + /// Read both backends.
222 + ///
223 + /// As in [`mesh`] and `audio`, a successful read does not clear `error`:
224 + /// refreshes run on the background tick and would wipe an action's message
225 + /// before it could be read. Keypresses clear it instead.
226 + fn refresh(&mut self, log: &mut CommandLog) {
227 + self.mesh_state = match self.mesh.status(log) {
228 + Ok(status) if status.is_running() => Enrolled::Yes,
229 + Ok(_) => Enrolled::No,
230 + Err(err) => {
231 + self.error = Some(err.to_string());
232 + Enrolled::Unknown
233 + }
234 + };
235 + self.sync_state = match self.sync.reach(log) {
236 + Ok(sync::Reach::Running(_)) => Enrolled::Yes,
237 + Ok(sync::Reach::NotRunning) => Enrolled::No,
238 + Err(err) => {
239 + self.error = Some(err.to_string());
240 + Enrolled::Unknown
241 + }
242 + };
243 + }
244 +
245 + fn state_of(&self, row: Row) -> Enrolled {
246 + match row {
247 + Row::Mesh => self.mesh_state,
248 + Row::Sync => self.sync_state,
249 + }
250 + }
251 +
252 + fn selected(&self) -> Option<Row> {
253 + ROWS.get(self.cursor.selected()?).copied()
254 + }
255 +
256 + /// Take the selected row's enrollment.
257 + ///
258 + /// The split between the two is the whole reason this screen has a
259 + /// suspend: the mesh needs a control server answered first and then leaves
260 + /// the TUI, and the sync needs neither.
261 + fn enroll(&mut self, log: &mut CommandLog) -> Flow {
262 + let Some(row) = self.selected() else {
263 + return Flow::Continue;
264 + };
265 + if !self.state_of(row).actionable() {
266 + self.error = Some(format!("{} is already enrolled", row.label()));
267 + return Flow::Continue;
268 + }
269 + match row {
270 + Row::Mesh => {
271 + self.server = Some(TextField::new());
272 + Flow::Continue
273 + }
274 + Row::Sync => {
275 + match self.sync.enroll(log) {
276 + Ok(()) => log.quiet(|log| self.refresh(log)),
277 + Err(err) => self.error = Some(err.to_string()),
278 + }
279 + Flow::Continue
280 + }
281 + }
282 + }
283 +
284 + /// Hand the terminal to the mesh sign-in.
285 + fn submit_server(&mut self, log: &mut CommandLog) -> Flow {
286 + let Some(field) = &self.server else {
287 + return Flow::Continue;
288 + };
289 + let server = match mesh::validate_login_server(field.value()) {
290 + Ok(server) => server,
291 + Err(message) => {
292 + self.error = Some(message);
293 + return Flow::Continue;
294 + }
295 + };
296 + let invocation = self.mesh.enroll(server.as_deref());
297 + self.server = None;
298 + // Recorded before the handover, as `alloy mesh` and `alloy pkg` do: the
299 + // pane carries what the user is about to be dropped into rather than
300 + // filling in once they are back.
301 + log.record(invocation.display(), Severity::Info);
302 + // The screen the user comes back to is a rebuilt one, and they are
303 + // expected to come back for the other row.
304 + self.stale = true;
305 + Flow::Suspend(invocation.command())
306 + }
307 +
308 + /// Keys while the server overlay is open.
309 + ///
310 + /// Returns `Some` when the overlay consumed the key, so an `r` typed into a
311 + /// server name never reaches the refresh binding underneath.
312 + fn handle_server(&mut self, key: KeyEvent, log: &mut CommandLog) -> Option<Flow> {
313 + self.server.as_ref()?;
314 + match key.code {
315 + KeyCode::Esc => self.server = None,
316 + KeyCode::Enter => return Some(self.submit_server(log)),
317 + _ => {
318 + let field = self.server.as_mut()?;
319 + match key.code {
320 + KeyCode::Char(c) => field.insert(c),
321 + KeyCode::Backspace => field.backspace(),
322 + KeyCode::Delete => field.delete(),
323 + KeyCode::Left => field.left(),
324 + KeyCode::Right => field.right(),
325 + KeyCode::Home => field.home(),
326 + KeyCode::End => field.end(),
327 + _ => {}
328 + }
329 + }
330 + }
331 + Some(Flow::Continue)
332 + }
333 +
334 + fn row_line<'a>(&self, theme: &Theme, row: Row) -> Line<'a> {
335 + let state = self.state_of(row);
336 + Line::from(vec![
337 + text::bold(theme, format!("{:<20}", row.label())),
338 + Span::styled(
339 + format!("{:<14}", state.label()),
340 + state.severity().style(theme),
341 + ),
342 + text::muted(theme, row.promise().to_string()),
343 + ])
344 + }
345 +
346 + /// The one-field overlay the mesh row opens, the same shape `alloy mesh`
347 + /// draws.
348 + fn render_server(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
349 + let Some(field) = &self.server else {
350 + return;
351 + };
352 + let overlay = layout::centered(area, 60, 6);
353 + frame.render_widget(ratatui::widgets::Clear, overlay);
354 +
355 + let block = AlloyBlock::new(theme)
356 + .focused(true)
357 + .build()
358 + .title(block_title("sign in"));
359 + let inner = block.inner(overlay);
360 + frame.render_widget(block, overlay);
361 +
362 + let (before, under, after) = field.split();
363 + let spans = vec![
364 + text::bold(theme, format!("{:>10} ", "server")),
365 + text::primary(theme, before.to_string()),
366 + // Reversed rather than a block glyph: the caret sits on the
367 + // character it replaces, as it does in every other console field.
368 + Span::styled(
369 + under.unwrap_or(' ').to_string(),
370 + Style::default().add_modifier(Modifier::REVERSED),
371 + ),
372 + text::primary(theme, after.to_string()),
373 + ];
374 +
375 + let lines = vec![
376 + Line::from(spans),
377 + Line::from(""),
378 + Line::from(text::muted(
379 + theme,
380 + "empty joins tailscale.com; set a URL for a self-hosted server",
381 + )),
382 + ];
383 + frame.render_widget(ratatui::widgets::Paragraph::new(lines), inner);
384 + }
385 + }
386 +
387 + /// Whether this machine has been offered the first-boot screen already.
388 + pub(crate) fn seen() -> bool {
389 + store::get(store::SETUP).is_some()
390 + }
391 +
392 + /// Record that the screen has been shown.
393 + ///
394 + /// Best-effort, and its failure is reported rather than fatal: a marker that
395 + /// will not write costs the user this screen again next login, which is a worse
396 + /// outcome than not asking but a much better one than refusing to show the
397 + /// screen at all.
398 + pub(crate) fn mark_seen(log: &mut CommandLog) -> Option<String> {
399 + match store::set(store::SETUP, SEEN).and_then(|effect| effect.apply(log)) {
400 + Ok(()) => None,
401 + Err(err) => Some(err.to_string()),
402 + }
403 + }
404 +
405 + impl View for SetupView {
406 + fn title(&self) -> String {
407 + "first boot".to_string()
408 + }
409 +
410 + fn hints(&self) -> Vec<Hint> {
411 + if self.server.is_some() {
412 + return vec![hint("enter", "sign in"), hint("esc", "cancel")];
413 + }
414 + let mut hints = vec![hint("j/k", "select")];
415 + // The enroll hint is dropped rather than shown-and-refused on a row
416 + // that is already enrolled: a footer offering an action that answers
417 + // with an error is how a screen teaches the wrong keymap.
418 + if self
419 + .selected()
420 + .is_some_and(|row| self.state_of(row).actionable())
421 + {
422 + hints.push(hint("e", "enroll"));
423 + }
424 + hints.push(hint("r", "refresh"));
425 + hints
426 + }
427 +
428 + fn status(&self) -> Option<(Severity, String)> {
429 + self.error
430 + .as_ref()
431 + .map(|error| (Severity::Error, error.clone()))
432 + }
433 +
434 + fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
435 + let block = AlloyBlock::new(theme)
436 + .focused(true)
437 + .build()
438 + .title(block_title(&self.title()));
439 + let inner = block.inner(area);
440 + frame.render_widget(block, area);
441 +
442 + // One line of why, the rows, one line of what happens if you skip. The
443 + // closing line is not decoration: "skip" is a supported answer and a
444 + // user who does not know it is reversible will take the offer to be
445 + // rid of the screen.
446 + let lead = Line::from(text::muted(
447 + theme,
448 + "two enrollments make this machine rejoinable. both are optional.",
449 + ));
450 + let tail = Line::from(text::muted(
451 + theme,
452 + "q when you are done. skipping is fine: alloy mesh and alloy sync enroll later.",
453 + ));
454 +
455 + let rows: Vec<Line> = ROWS.iter().map(|row| self.row_line(theme, *row)).collect();
456 + // Two lines of lead (the sentence and a blank) and two of tail. Laid out
457 + // by hand rather than through a `Layout`: the pieces are fixed heights
458 + // and the list takes what is left, which is a subtraction, not a solver.
459 + let lead_height = 2u16.min(inner.height);
460 + let tail_height = 2u16.min(inner.height - lead_height);
461 + let list_height = inner.height - lead_height - tail_height;
462 +
463 + frame.render_widget(
464 + ratatui::widgets::Paragraph::new(lead),
465 + Rect {
466 + height: lead_height,
467 + ..inner
468 + },
469 + );
470 + frame.render_widget(
471 + AlloyList::new(theme, rows).selected(self.cursor.selected()),
472 + Rect {
473 + y: inner.y + lead_height,
474 + height: list_height,
475 + ..inner
476 + },
477 + );
478 + frame.render_widget(
479 + ratatui::widgets::Paragraph::new(tail),
480 + Rect {
481 + y: inner.y + lead_height + list_height + 1,
482 + height: tail_height.saturating_sub(1),
483 + ..inner
484 + },
485 + );
486 +
487 + // Last, and over the whole area rather than the block's inside, so it
488 + // floats above the border as the other screens' overlays do.
489 + self.render_server(frame, area, theme);
490 + }
491 +
492 + /// True while the server field is open, so a control server can live at
493 + /// `https://q.example.org` without `q` closing the console mid-word.
494 + fn text_entry(&self) -> bool {
495 + self.server.is_some()
496 + }
497 +
498 + fn cancel(&mut self) -> Flow {
499 + if self.server.is_some() {
500 + self.server = None;
Lines truncated
@@ -1,0 +1,139 @@
1 + //! Where the console remembers what it is told.
2 + //!
3 + //! `$XDG_CONFIG_HOME/alloy/console.db`, a SQLite store the shared config crate
4 + //! owns. The console already owns that directory — custom themes live in
5 + //! `alloy/themes/`, user schemas in `alloy/schemas/` — so the store joins them
6 + //! rather than opening a new root. SQLite rather than a `console.toml` because
7 + //! every app in the family now keeps settings in one store, so a preference can
8 + //! carry across devices where the app syncs; the console does not sync, but it
9 + //! reads and writes the same shape.
10 + //!
11 + //! Deliberately not a system-wide file. Which theme a terminal renders in is
12 + //! the person's, not the machine's, and two accounts on one machine should not
13 + //! fight over it.
14 + //!
15 + //! Lives in its own module rather than in [`theme`](crate::theme), which
16 + //! declared it while the theme was the only thing stored. [`setup`](crate::setup)
17 + //! is the second consumer, and a first-boot marker read out of a module named
18 + //! for palettes would be a worse place to look for it than this one.
19 +
20 + use std::path::PathBuf;
21 +
22 + use anyhow::{Context, Result};
23 + use synckit_config::rusqlite::Connection;
24 + use synckit_config::{ConfigSpec, ConfigStore, Posture};
25 +
26 + use crate::cli::Effect;
27 +
28 + /// The console's config store: one table, every key the console remembers.
29 + ///
30 + /// Declared for the family's shared config store even though the console never
31 + /// syncs, so posture is inert here and both keys take the safe default,
32 + /// [`Posture::Local`]. [`THEME`]'s name is the convention's, not this crate's:
33 + /// every app in the family stores the chosen theme under `theme`.
34 + pub(crate) const CONFIG: ConfigSpec = ConfigSpec::new(
35 + "console_config",
36 + &[(THEME, Posture::Local), (SETUP, Posture::Local)],
37 + );
38 +
39 + /// The chosen theme, or `follow` to track the terminal.
40 + pub(crate) const THEME: &str = "theme";
41 +
42 + /// Set once the first-boot screen has been shown.
43 + ///
44 + /// [`Posture::Local`] is load-bearing here in a way it is not for [`THEME`],
45 + /// which would be harmless to sync. This key must never replicate: it says
46 + /// something about *this* machine's first boot, and a synced copy would suppress
47 + /// the screen on the next machine the user sets up — which is exactly the
48 + /// machine that needs it.
49 + pub(crate) const SETUP: &str = "first_boot";
50 +
51 + /// The config directory the store lives under.
52 + pub(crate) fn config_home() -> Option<PathBuf> {
53 + // XDG_CONFIG_HOME wins when set and absolute; the spec says a relative
54 + // value is invalid and must be ignored rather than resolved against cwd.
55 + if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
56 + let path = PathBuf::from(xdg);
57 + if path.is_absolute() {
58 + return Some(path);
59 + }
60 + }
61 + std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))
62 + }
63 +
64 + /// The store file.
65 + pub(crate) fn path() -> Option<PathBuf> {
66 + config_home().map(|config| config.join("alloy").join("console.db"))
67 + }
68 +
69 + /// Read one key, or `None` when nothing is saved or the store will not read.
70 + ///
71 + /// Best-effort by design: a missing store is the normal first-launch state, and
72 + /// a store that will not open is not worth crashing the console over before the
73 + /// first frame. Reading deliberately does **not** create the store; it appears
74 + /// only when a choice is written, so a console never told anything leaves no
75 + /// file behind.
76 + pub(crate) fn get(key: &str) -> Option<String> {
77 + let path = path()?;
78 + if !path.exists() {
79 + return None;
80 + }
81 + let conn = Connection::open(&path).ok()?;
82 + let store = ConfigStore::open(&conn, &CONFIG).ok()?;
83 + store.get(&conn, key).ok()?
84 + }
85 +
86 + /// The write that remembers one key.
87 + ///
88 + /// Returned rather than performed, like every other effect in the console, so
89 + /// the log shows the setting being written and the caller decides when. The set
90 + /// is an upsert on one key, so writing again replaces the value rather than
91 + /// accumulating rows.
92 + pub(crate) fn set(key: &str, value: &str) -> Result<Effect> {
93 + let db = path().context("no config directory to remember settings in")?;
94 + Ok(Effect::Config {
95 + db,
96 + spec: CONFIG,
97 + key: key.to_string(),
98 + value: value.to_string(),
99 + })
100 + }
101 +
102 + #[cfg(test)]
103 + mod tests {
104 + use super::*;
105 +
106 + // Both keys drive one real store: a set reads back under the name the
107 + // console writes, which is what keeps a read and a write from disagreeing
108 + // about spelling.
109 + #[test]
110 + fn every_key_round_trips_through_the_store() {
111 + let conn = Connection::open_in_memory().unwrap();
112 + let store = ConfigStore::open(&conn, &CONFIG).unwrap();
113 + for (key, value) in [(THEME, "akari-night"), (SETUP, "done")] {
114 + assert_eq!(store.get(&conn, key).unwrap(), None, "{key} starts unset");
115 + store.set(&conn, key, value).unwrap();
116 + assert_eq!(store.get(&conn, key).unwrap().as_deref(), Some(value));
117 + }
118 + }
119 +
120 + // The first-boot marker is about one machine's first boot. Syncing it would
121 + // suppress the screen on the next machine the user sets up.
122 + #[test]
123 + fn the_first_boot_marker_never_replicates() {
124 + assert!(!CONFIG.is_synced(SETUP));
125 + }
126 +
127 + #[test]
128 + fn a_set_is_a_config_effect_on_the_consoles_store() {
129 + let Effect::Config {
130 + key, value, spec, ..
131 + } = set(THEME, "akari-night").unwrap()
132 + else {
133 + panic!("a setting is a config write, not a command or a file");
134 + };
135 + assert_eq!(key, THEME);
136 + assert_eq!(value, "akari-night");
137 + assert_eq!(spec.table(), "console_config");
138 + }
139 + }