max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+703 insertions,
-107 deletions
| @@ -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 |
| @@ -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 | + | } |