Skip to main content

max / alloy

mesh: sign in from the console, and say what that costs A machine not in a mesh got an empty peer list under a warning line, which is not an answer to "how do I start". It gets the offer instead now, the same shape `alloy sync` uses, and `e` opens a one-field overlay for the control server. Empty joins tailscale.com; a URL joins a self-hosted Headscale, which is the first-class field CONTINUITY.md asks for rather than a footnote. ENROLLMENT LEAVES THE TUI, AND THAT IS THE DECISION. `run0 tailscale up` escalates and then blocks printing a URL to open in a browser. Both halves are hostile to a full-screen view: polkit wants a real terminal, and an auth link drawn into a pane cannot be selected or clicked. So it goes out through Flow::Suspend, the door `distrobox enter` already uses. The console tears down, polkit prompts on a clean screen, the link lands where the terminal's own selection works, and the console comes back and refreshes into the peer list. Tier 2 of the ladder in `alloy-privilege`, and the interruption is the honest price of a once-per-machine act. Rejected: scraping AuthURL out of `status --json` and drawing it. Reads better in a screenshot and worse in use. The URL is still unselectable, the polkit prompt still has nowhere to go, and a spawned `tailscale up` nobody waits on has to be reaped by hand. run0 rather than sudo or bare, per the run0-first stance. No --pipe: the pty run0 gives its child by default is what is wanted, since the child is about to print something for a human to read. `e` means two things across the two screens. Safe only because they are never both on, and there is a test that says so. The server field is prefilled from the control plane in use, which decides CONTINUITY.md's open question in favour of automatic: ControlURL lives in the client's prefs, which is where the title already reads it from, so re-entry is never required. Prefilled anyway, so someone reconnecting sees which mesh they are rejoining. The checkbox stays open: that rests on reading prefs on a machine already up, not on watching a down/up cycle preserve them. Check it on the image. Validation is shallow, as sync's is, but the bare hostname earns a check with the fix in it. Being wrong costs more here than in a normal field, because the price of a rejected value is the whole console tearing down and rebuilding around the error. Still open on this screen: MagicDNS lookup and share/unshare.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-25 20:47 UTC
Signed with PGP, not checked
Commit: 818c439b38107e3bbfd8ddb90b975bfb6f588bbc
Parent: 19cf84c
3 files changed, +376 insertions, -31 deletions
M docs/CONSOLE.md +1 -1
@@ -197,7 +197,7 @@
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 198 - **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 199 - **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; `alloy sync` now carries its half of that, so the shim has one real enrollment to call and one still to write.
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 201 - **v1.x.** Additional adopted-tool schemas as the v0 stack grows. (`alloy hinged` was shelved with the FW12 tablet flow in the pivot.)
202 202 - **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 203
@@ -57,7 +57,11 @@
57 57
58 58 `alloy mesh` and `alloy sync` live under Alloy Console. Full spec in [CONSOLE.md](CONSOLE.md); scope summary here so this document stands alone:
59 59
60 - - **`alloy mesh`** (was `alloy tail`; the old verb remains an alias). A ratatui front over Tailscale, named for what it is rather than who makes it, since Headscale users drive the same client. Replaces `tailscale status` as the daily-use surface. **Shipped:** peer list with online status and last-seen, this machine first, exit-node selection and clearing, and the control plane named in the title when it is self-hosted. **Still to come:** MagicDNS lookup, share/unshare, and the enrollment flow.
60 + - **`alloy mesh`** (was `alloy tail`; the old verb remains an alias). A ratatui front over Tailscale, named for what it is rather than who makes it, since Headscale users drive the same client. Replaces `tailscale status` as the daily-use surface. **Shipped:** peer list with online status and last-seen, this machine first, exit-node selection and clearing, the control plane named in the title when it is self-hosted, and enrollment. **Still to come:** MagicDNS lookup and share/unshare.
61 +
62 + A machine that has not joined a mesh gets an offer in place of the peer list, as `alloy sync` does, and `e` opens a one-field overlay for the control server before signing in. Empty joins tailscale.com; a URL joins a self-hosted Headscale, which is the first-class field this document asks for. The field is prefilled when the machine is already pointed at a self-hosted server, so a reconnection shows which mesh it is rejoining.
63 +
64 + Enrollment leaves the TUI, which is the one place this screen differs from `alloy sync`. `run0 tailscale up` needs root and then blocks printing a URL to open in a browser, so it goes out through the same suspend that hands `distrobox enter` a real terminal: the console tears down, polkit prompts on a clean screen, the link lands where the terminal's own selection works, and the console comes back into the peer list. Syncthing's enrollment needs none of that, which is why it runs in place.
61 65 - **`alloy sync`**: a ratatui front over Syncthing, two tabs over one shell. Does not try to replicate the web UI's full feature surface, only the operations users perform; the web UI remains available for edge cases. **Shipped:** three tabs. Folders with path, share mode and paused state; devices with connection state and this machine first; and pending, the devices knocking. Pause and resume on either configured list, add and remove for both, accept for a pending device, and enrollment. Adding opens a small overlay of text fields (`a`); removing confirms first (`d`), and both confirms say what is *not* lost, because "remove folder" must not read as "delete my documents". The pending tab carries its count in its own label and in the status line on every tab, since an invitation nobody notices is the same as one that never arrived. **Still to come:** editing an existing folder's share list.
62 66
63 67 **Accepting is possible; declining is not.** The pending tab is how a pairing finishes: the other machine adds this one, dials it, and waits. Accepting is an add with the id and name already known, and Syncthing drops the entry once the device is configured. There is no matching decline, because the REST API can drop a pending entry and `syncthing cli` does not expose that. So an unaccepted device stays listed. That asymmetry ships as-is and the view says so when `d` is pressed on that tab; a dismiss button that silently did nothing would be worse than not having one. Revisit if `syncthing cli` grows the verb.
@@ -103,5 +107,5 @@
103 107
104 108 - [ ] MagicDNS interaction with Fedora's `systemd-resolved`: verify no config conflict on a fresh Silverblue install with Tailscale enrolled.
105 109 - [x] **Syncthing's `discosrv` and `relaysrv`: defaults for v1.** Public infrastructure is fine for the enroll-and-it-works model. Expose configuration in `alloy sync` only if requests appear from users self-hosting the discovery / relay side.
106 - - [ ] Headscale login-server field in `alloy mesh` enrollment: preserve across `tailscale down` / `tailscale up` cycles automatically, or require re-entry? Automatic is the correct default; verify Tailscale client behavior supports it cleanly.
110 + - [ ] Headscale login-server field in `alloy mesh` enrollment: preserve across `tailscale down` / `tailscale up` cycles automatically, or require re-entry? Automatic is the correct default. Decided in favor of automatic and built that way: `ControlURL` lives in the client's own prefs, which is where the console already reads the control plane from for the title, so the field is prefilled rather than demanded. What is still unverified is the cycle itself. The claim rests on reading prefs on a machine that is already up, not on watching a `down`/`up` preserve them. Check on the image.
107 111 - [x] **First-boot screen appears once.** If the user skips both Tailscale and Syncthing, `alloy mesh` and `alloy sync` are the enrollment paths after that; the first-boot flow does not re-appear.
@@ -32,14 +32,45 @@
32 32 //! what a user would search for, and it is the word the logged command uses.
33 33 //! Inventing a friendlier synonym would have the console teach a term nothing
34 34 //! else in the ecosystem uses.
35 + //!
36 + //! # Enrollment leaves the TUI, deliberately
37 + //!
38 + //! A machine that has not joined a mesh gets the offer screen instead of the
39 + //! peer list, the same way [`sync`](crate::sync) does, because an empty list
40 + //! under a warning line is not an answer to "how do I start".
41 + //!
42 + //! What it offers is different in kind, though. Syncthing enrollment is
43 + //! `systemctl --user enable --now`, which needs no privilege, returns in
44 + //! milliseconds, and can be run under a live ratatui. Signing into a mesh is
45 + //! `run0 tailscale up`, which needs root and then **blocks printing a URL the
46 + //! user has to open in a browser**. Both halves of that are hostile to a
47 + //! full-screen TUI: the polkit prompt wants a real terminal, and an
48 + //! authentication URL rendered into a pane the user cannot select text out of
49 + //! is a URL they cannot use.
50 + //!
51 + //! So enrollment goes out through [`Flow::Suspend`], the same door `distrobox
52 + //! enter` uses. The console tears down, `run0` prompts on a clean terminal,
53 + //! `tailscale up` prints its link where the terminal's own selection and
54 + //! hyperlink handling work, and the console comes back and refreshes into the
55 + //! peer list. This is tier 2 of the ladder in the wiki note `alloy-privilege`,
56 + //! and the interruption is the honest cost of an act performed once per
57 + //! machine.
58 + //!
59 + //! The alternative was scraping the `AuthURL` field out of `status --json` and
60 + //! drawing it. That reads better in a screenshot and worse in use: the URL
61 + //! still is not selectable, the polkit prompt still has nowhere to go, and a
62 + //! spawned `tailscale up` nobody waits on has to be reaped by hand.
35 63
36 64 use std::collections::HashMap;
37 65
38 - use alloy_tui::{AlloyBlock, AlloyList, Cursor, Hint, Severity, Theme, hint, text};
66 + use alloy_tui::{
67 + AlloyBlock, AlloyList, Cursor, Hint, Severity, TextField, Theme, hint, layout, text,
68 + };
39 69 use anyhow::{Context, Result};
40 70 use ratatui::Frame;
41 71 use ratatui::crossterm::event::{KeyCode, KeyEvent};
42 72 use ratatui::layout::Rect;
73 + use ratatui::style::{Modifier, Style};
43 74 use ratatui::text::{Line, Span};
44 75 use serde::Deserialize;
45 76
@@ -161,6 +192,15 @@
161 192 ControlPlane::Unknown
162 193 }
163 194
195 + /// The command that signs this machine into the mesh.
196 + ///
197 + /// Handed back rather than run, because the caller suspends the TUI to run
198 + /// it. See the module docs for why enrollment cannot happen under a live
199 + /// screen: it escalates, and then it blocks on a browser.
200 + ///
201 + /// `login_server` is the control server to join, absent for the vendor's.
202 + fn enroll(&self, login_server: Option<&str>) -> Invocation;
203 +
164 204 /// Route traffic through `peer`.
165 205 fn set_exit_node(&self, peer: &Peer, log: &mut CommandLog) -> Result<()>;
166 206
@@ -168,6 +208,30 @@
168 208 fn clear_exit_node(&self, log: &mut CommandLog) -> Result<()>;
169 209 }
170 210
211 + /// Check a login server before spending a suspend on it.
212 + ///
213 + /// Shallow, on the same principle as [`sync`](crate::sync)'s draft validation:
214 + /// whether the host resolves, whether it is really a control server, whether
215 + /// its certificate is good are all questions Tailscale answers, and answers
216 + /// better. What this catches is the bare hostname, because `--login-server`
217 + /// wants a full URL and a user typing the name of their Headscale box is the
218 + /// obvious way to get that wrong. Getting it wrong is more expensive here than
219 + /// in a normal field: the price of a rejected value is the whole console
220 + /// tearing down and rebuilding around the error.
221 + ///
222 + /// Returns the trimmed value, or `None` for the vendor's control plane, which
223 + /// is what an empty field means.
224 + fn validate_login_server(value: &str) -> Result<Option<String>, String> {
225 + let value = value.trim();
226 + if value.is_empty() {
227 + return Ok(None);
228 + }
229 + if !(value.starts_with("https://") || value.starts_with("http://")) {
230 + return Err(format!("a control server is a URL: try https://{value}"));
231 + }
232 + Ok(Some(value.to_string()))
233 + }
234 +
171 235 /// Pick a backend: `tailscale` when it answers, the mock otherwise.
172 236 ///
173 237 /// Tailscale is the only real implementation today, and covers Headscale too
@@ -221,6 +285,23 @@
221 285 classify_control_url(prefs.control_url.as_deref().unwrap_or_default())
222 286 }
223 287
288 + /// `run0`, not `sudo` and not bare: `tailscale up` drives a system daemon
289 + /// and is refused without root. run0 is what Alloy names when a user needs
290 + /// root (wiki `alloy-privilege`, "run0-first, not sudo"), and it goes
291 + /// through the same polkit authority every writing view already does, so a
292 + /// fingerprint factor configured once covers this too.
293 + ///
294 + /// No `--pipe`. That flag is for the build scripts, which capture output;
295 + /// here the pty run0 gives its child by default is exactly what is wanted,
296 + /// since the child is about to print a link for a human to read.
297 + fn enroll(&self, login_server: Option<&str>) -> Invocation {
298 + let invocation = Invocation::new("run0").args(["tailscale", "up"]);
299 + match login_server {
300 + Some(server) => invocation.arg(format!("--login-server={server}")),
301 + None => invocation,
302 + }
303 + }
304 +
224 305 fn set_exit_node(&self, peer: &Peer, log: &mut CommandLog) -> Result<()> {
225 306 // Addressed by IP rather than hostname: hostnames collide (three
226 307 // devices on this tailnet answer to "localhost") and MagicDNS may be
@@ -293,6 +374,14 @@
293 374 })
294 375 }
295 376
377 + /// The mock reports `Running`, so the offer screen this belongs to is
378 + /// unreachable under it and nothing calls this in normal use. It still has
379 + /// to return something the shell could hand to a suspend, so it returns the
380 + /// command that does nothing and succeeds.
381 + fn enroll(&self, _login_server: Option<&str>) -> Invocation {
382 + Invocation::new("true")
383 + }
384 +
296 385 // The mock is a display fixture, not a simulator.
297 386 fn set_exit_node(&self, _peer: &Peer, log: &mut CommandLog) -> Result<()> {
298 387 log.record("# mock backend: exit node unchanged", Severity::Warn);
@@ -468,6 +557,12 @@
468 557 cursor: Cursor,
469 558 error: Option<String>,
470 559 ticks: u64,
560 + /// The control-server field, while the enrollment overlay is open.
561 + ///
562 + /// One field, so there is no focus ring: the overlay exists to make the
563 + /// Headscale choice visible at the moment it is made, and the vendor's
564 + /// plane is the empty answer.
565 + server: Option<TextField>,
471 566 }
472 567
473 568 impl MeshView {
@@ -483,6 +578,7 @@
483 578 cursor: Cursor::new(),
484 579 error: None,
485 580 ticks: 0,
581 + server: None,
486 582 };
487 583 view.refresh(log);
488 584 view
@@ -542,6 +638,92 @@
542 638 }
543 639 }
544 640
641 + // ---- enrollment ----
642 +
643 + /// Whether this machine is in a mesh at all.
644 + ///
645 + /// A status that failed to read is treated as enrolled, so a broken
646 + /// `tailscale status` shows its error rather than inviting a user who is
647 + /// already on the mesh to sign in again.
648 + fn is_enrolled(&self) -> bool {
649 + self.status.as_ref().is_none_or(MeshStatus::is_running)
650 + }
651 +
652 + /// Open the enrollment overlay, prefilled with the control server in use.
653 + ///
654 + /// Prefilling settles the open question in docs/CONTINUITY.md about whether
655 + /// a Headscale login server survives a `down`/`up` cycle. It does: Tailscale
656 + /// keeps `ControlURL` in its prefs, which is where [`ControlPlane`] is read
657 + /// from in the first place, so re-entry is never *required*. It is prefilled
658 + /// anyway, because a self-hosted user reconnecting should be able to see
659 + /// which server they are about to rejoin rather than trust that it was
660 + /// remembered.
661 + fn open_enrollment(&mut self) {
662 + let mut field = TextField::new();
663 + if let ControlPlane::SelfHosted(host) = &self.control_plane {
664 + // Round-tripped back into a URL. `ControlPlane` keeps only the host,
665 + // since that is all a title needs, and `--login-server` needs the
666 + // scheme back. https, because a control server that answered over
667 + // plain http would not have been reachable to be read here.
668 + field.set(format!("https://{host}"));
669 + }
670 + self.server = Some(field);
671 + }
672 +
673 + fn close_enrollment(&mut self) {
674 + self.server = None;
675 + }
676 +
677 + /// Hand the terminal to `run0 tailscale up`.
678 + ///
679 + /// The overlay closes first. There is nothing to come back to: the console
680 + /// is about to tear down, and on the way back the poll will have found
681 + /// either a mesh or the same offer.
682 + fn submit_enrollment(&mut self, log: &mut CommandLog) -> Flow {
683 + let Some(field) = &self.server else {
684 + return Flow::Continue;
685 + };
686 + let server = match validate_login_server(field.value()) {
687 + Ok(server) => server,
688 + Err(message) => {
689 + self.error = Some(message);
690 + return Flow::Continue;
691 + }
692 + };
693 + let invocation = self.backend.enroll(server.as_deref());
694 + self.close_enrollment();
695 + // Recorded before the handover, as in `alloy pkg`: the pane carries what
696 + // the user is about to be dropped into rather than filling in after.
697 + log.record(invocation.display(), Severity::Info);
698 + Flow::Suspend(invocation.command())
699 + }
700 +
701 + /// Keys while the enrollment overlay is open.
702 + ///
703 + /// Returns `Some` when the overlay consumed the key, so an `r` typed into a
704 + /// server name never reaches the refresh binding underneath.
705 + fn handle_enrollment(&mut self, key: KeyEvent, log: &mut CommandLog) -> Option<Flow> {
706 + self.server.as_ref()?;
707 + match key.code {
708 + KeyCode::Esc => self.close_enrollment(),
709 + KeyCode::Enter => return Some(self.submit_enrollment(log)),
710 + _ => {
711 + let field = self.server.as_mut()?;
712 + match key.code {
713 + KeyCode::Char(c) => field.insert(c),
714 + KeyCode::Backspace => field.backspace(),
715 + KeyCode::Delete => field.delete(),
716 + KeyCode::Left => field.left(),
717 + KeyCode::Right => field.right(),
718 + KeyCode::Home => field.home(),
719 + KeyCode::End => field.end(),
720 + _ => {}
721 + }
722 + }
723 + }
724 + Some(Flow::Continue)
725 + }
726 +
545 727 fn row<'a>(theme: &Theme, peer: &'a Peer) -> Line<'a> {
546 728 Line::from(vec![
547 729 text::bold(theme, format!("{:<18}", truncate(&peer.hostname, 17))),
@@ -554,6 +736,81 @@
554 736 text::muted(theme, peer.state_label()),
555 737 ])
556 738 }
739 +
740 + /// The offer shown when this machine is not in a mesh.
741 + ///
742 + /// Names the command it is about to run, as `alloy sync`'s offer does. Here
743 + /// that is not only a teaching move: the command escalates, so a user is
744 + /// owed sight of what they are authorizing before the polkit prompt asks.
745 + fn render_offer(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
746 + let state = self
747 + .status
748 + .as_ref()
749 + .map_or("", |status| status.backend_state.as_str());
750 + // "NeedsLogin" is the never-signed-in state and every other non-running
751 + // one means signed in but down. The distinction is worth a sentence:
752 + // the second is a reconnection, and telling someone to sign in when
753 + // they already have reads as the console having lost their account.
754 + let headline = if state == "NeedsLogin" {
755 + "this machine is not signed in to a mesh"
756 + } else {
757 + "the mesh is not connected"
758 + };
759 + let lines = vec![
760 + Line::from(text::muted(theme, headline)),
761 + Line::from(""),
762 + Line::from(text::secondary(
763 + theme,
764 + format!("press e to run: {}", self.backend.enroll(None).display()),
765 + )),
766 + ];
767 + frame.render_widget(ratatui::widgets::Paragraph::new(lines), area);
768 + }
769 +
770 + /// The one-field enrollment overlay.
771 + ///
772 + /// Same shape as `alloy sync`'s add overlay, one row shorter.
773 + fn render_enrollment(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
774 + let Some(field) = &self.server else {
775 + return;
776 + };
777 + // Two rows of border, one of padding either side, one field, and one
778 + // line saying what leaving it empty means.
779 + let overlay = layout::centered(area, 60, 6);
780 + frame.render_widget(ratatui::widgets::Clear, overlay);
781 +
782 + let block = AlloyBlock::new(theme)
783 + .focused(true)
784 + .build()
785 + .title(block_title("sign in"));
786 + let inner = block.inner(overlay);
787 + frame.render_widget(block, overlay);
788 +
789 + let (before, under, after) = field.split();
790 + let mut spans = vec![
791 + text::bold(theme, format!("{:>10} ", "server")),
792 + text::primary(theme, before.to_string()),
793 + ];
794 + // Reversed rather than a block glyph, matching the installer's fields
795 + // and `alloy sync`'s: the caret sits on the character it replaces.
796 + spans.push(Span::styled(
797 + under.unwrap_or(' ').to_string(),
798 + Style::default().add_modifier(Modifier::REVERSED),
799 + ));
800 + spans.push(text::primary(theme, after.to_string()));
801 +
802 + let lines = vec![
803 + Line::from(spans),
804 + Line::from(""),
805 + // The empty answer is the common one, and an empty field with no
806 + // caption reads as a value the user forgot rather than as a choice.
807 + Line::from(text::muted(
808 + theme,
809 + "empty joins tailscale.com; set a URL for a self-hosted server",
810 + )),
811 + ];
812 + frame.render_widget(ratatui::widgets::Paragraph::new(lines), inner);
813 + }
557 814 }
558 815
559 816 impl View for MeshView {
@@ -572,6 +829,15 @@
572 829 }
573 830
574 831 fn hints(&self) -> Vec<Hint> {
832 + if self.server.is_some() {
833 + return vec![hint("enter", "sign in"), hint("esc", "cancel")];
834 + }
835 + // `e` means two things across the two screens, which is safe only
836 + // because they are never both on: an un-enrolled machine has no peer
837 + // list to pick an exit node from, and an enrolled one has no offer.
838 + if !self.is_enrolled() {
839 + return vec![hint("e", "sign in"), hint("r", "refresh")];
840 + }
575 841 vec![
576 842 hint("j/k", "select"),
577 843 hint("e", "exit node"),
@@ -602,22 +868,63 @@
602 868 let inner = block.inner(area);
603 869 frame.render_widget(block, area);
604 870
605 - let peers = self.peers();
606 - if peers.is_empty() {
607 - frame.render_widget(Line::from(text::muted(theme, "no peers")), inner);
608 - return;
871 + if self.is_enrolled() {
872 + let peers = self.peers();
873 + if peers.is_empty() {
874 + frame.render_widget(Line::from(text::muted(theme, "no peers")), inner);
875 + } else {
876 + let rows: Vec<Line> = peers.iter().map(|peer| Self::row(theme, peer)).collect();
877 + frame.render_widget(
878 + AlloyList::new(theme, rows).selected(self.cursor.selected()),
879 + inner,
880 + );
881 + }
882 + } else {
883 + self.render_offer(frame, inner, theme);
609 884 }
610 885
611 - let rows: Vec<Line> = peers.iter().map(|peer| Self::row(theme, peer)).collect();
612 - frame.render_widget(
613 - AlloyList::new(theme, rows).selected(self.cursor.selected()),
614 - inner,
615 - );
886 + // Last, and over the whole area rather than the block's inside, so it
887 + // floats above the border as `alloy sync`'s does.
888 + self.render_enrollment(frame, area, theme);
889 + }
890 +
891 + /// True while the server field is open, so the shell stops claiming the
892 + /// reserved keys and a control server can live at `https://q.example.org`.
893 + fn text_entry(&self) -> bool {
894 + self.server.is_some()
895 + }
896 +
897 + /// Esc backs out of the overlay first, and out of the view only when there
898 + /// is no overlay to close.
899 + fn cancel(&mut self) -> Flow {
900 + if self.server.is_some() {
901 + self.close_enrollment();
902 + return Flow::Continue;
903 + }
904 + Flow::Exit
616 905 }
617 906
618 907 fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow {
619 908 self.error = None;
620 909
910 + // The overlay eats every key it is given, so an `x` typed into a server
911 + // name never reaches the clear-exit binding underneath.
912 + if let Some(flow) = self.handle_enrollment(key, log) {
913 + return flow;
914 + }
915 +
916 + // On the offer screen `e` signs in; on the peer list it picks an exit
917 + // node. Split here rather than inside the actions, so the binding a key
918 + // has is decided in one place.
919 + if !self.is_enrolled() {
920 + match key.code {
921 + KeyCode::Char('e') => self.open_enrollment(),
922 + KeyCode::Char('r') => self.refresh(log),
923 + _ => {}
924 + }
925 + return Flow::Continue;
926 + }
927 +
621 928 match key.code {
622 929 KeyCode::Char('j') | KeyCode::Down => self.cursor.next(),
623 930 KeyCode::Char('k') | KeyCode::Up => self.cursor.prev(),
@@ -631,6 +938,12 @@
631 938
632 939 fn tick(&mut self, log: &mut CommandLog) {
633 940 self.ticks += 1;
941 + // No background refresh under the overlay: it would redraw the offer
942 + // behind a field someone is typing into, to learn nothing that has
943 + // changed, since what changes it is the command they have not run yet.
944 + if self.server.is_some() {
945 + return;
946 + }
634 947 if self.ticks % POLL_TICKS == 0 {
635 948 log.quiet(|log| self.refresh(log));
636 949 }
@@ -848,19 +1161,27 @@
848 1161 // ---- view behavior ----
849 1162
850 1163 fn mock_view() -> (MeshView, CommandLog) {
851 - let mut log = CommandLog::new();
852 - let mut view = MeshView {
853 - backend: Box::new(Mock),
854 - status: None,
855 - control_plane: ControlPlane::Unknown,
856 - cursor: Cursor::new(),
857 - error: None,
858 - ticks: 0,
859 - };
1164 + let (mut view, mut log) = bare_view();
860 1165 view.refresh(&mut log);
861 1166 (view, log)
862 1167 }
863 1168
1169 + /// A view that has not read a status yet.
1170 + fn bare_view() -> (MeshView, CommandLog) {
1171 + (
1172 + MeshView {
1173 + backend: Box::new(Mock),
1174 + status: None,
1175 + control_plane: ControlPlane::Unknown,
1176 + cursor: Cursor::new(),
1177 + error: None,
1178 + ticks: 0,
1179 + server: None,
1180 + },
1181 + CommandLog::new(),
1182 + )
1183 + }
1184 +
864 1185 #[test]
865 1186 fn routing_through_this_machine_is_refused() {
866 1187 let (mut view, mut log) = mock_view();
@@ -914,19 +1235,186 @@
914 1235
915 1236 #[test]
916 1237 fn acting_with_no_selection_is_inert() {
917 - let mut log = CommandLog::new();
918 - let mut view = MeshView {
919 - backend: Box::new(Mock),
920 - status: None,
921 - control_plane: ControlPlane::Unknown,
922 - cursor: Cursor::new(),
923 - error: None,
924 - ticks: 0,
925 - };
1238 + let (mut view, mut log) = bare_view();
926 1239 view.set_exit_node(&mut log);
927 1240 assert!(view.error.is_none(), "no selection is not an error");
928 1241 }
929 1242
1243 + // ---- enrollment ----
1244 +
1245 + fn press(view: &mut MeshView, c: char, log: &mut CommandLog) -> Flow {
1246 + view.handle(KeyEvent::from(KeyCode::Char(c)), log)
1247 + }
1248 +
1249 + fn key(view: &mut MeshView, code: KeyCode, log: &mut CommandLog) -> Flow {
1250 + view.handle(KeyEvent::from(code), log)
1251 + }
1252 +
1253 + /// A view sitting on a tailnet it has never signed into.
1254 + fn logged_out_view() -> (MeshView, CommandLog) {
1255 + let (mut view, log) = bare_view();
1256 + view.status = Some(parse_status(r#"{"BackendState":"NeedsLogin","Peer":{}}"#).unwrap());
1257 + (view, log)
1258 + }
1259 +
1260 + #[test]
1261 + fn an_unset_server_means_the_vendor_plane() {
1262 + assert_eq!(validate_login_server(""), Ok(None));
1263 + assert_eq!(validate_login_server(" "), Ok(None));
1264 + }
1265 +
1266 + #[test]
1267 + fn a_server_url_is_trimmed_and_kept() {
1268 + assert_eq!(
1269 + validate_login_server(" https://hs.example.org "),
1270 + Ok(Some("https://hs.example.org".into()))
Lines truncated