Skip to main content

max / alloy

Say what an enrolled Syncthing has not done yet, and offer its web UI Three changes to the same failure. fw12 on 2026-09-07: daemon up, four devices paired, none of fw13's eight folders shared to it, and every surface reporting success. It read as finished for hours. WHAT THE SCREEN NOW SAYS. A running daemon with no folders is a state worth naming, and the next step is not on this machine: a folder is shared FROM the one that has it TO this one, and what that machine needs is this one's device id. So the id is the message rather than a suggestion to go and find it. A device waiting to connect still outranks it, and a single shared folder clears it rather than nagging. WHAT ENROLMENT NOW CLEANS UP. Syncthing invents a `default` folder at ~/Sync on first run, shared with nobody. Left alone it is worse than clutter: one folder that is never behind, so a machine holding nothing reports what a machine holding everything reports. Removed on enrol, guarded on both path and share count so a folder somebody meant to call `default` survives, and best-effort because enrolment succeeded when the daemon came up. WHAT THE w KEY IS FOR. docs/CONTINUITY.md has the web UI reachable and not recommended, with `alloy sync` covering "only the operations users perform; the web UI remains available for edge cases". Ignore patterns and versioning are those edge cases, and reaching them meant knowing the address by heart. Not a suspend, unlike `alloy mesh`: a browser owns a window rather than the terminal, so the console stays up. That needed Invocation::launch, because alloy-open execs the browser it finds and would otherwise hold the console frozen until the user quit Firefox.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01WFBzMprSmNCfvdj2cGZyka
Author: Max Johnson <me@maxj.phd> · 2026-09-07 22:14 UTC
Signed with PGP, not checked
Commit: fdddfbba6d0cd9648d826987cbbe6b429f2a9308
Parent: 71ada7a
3 files changed, +242 insertions, -6 deletions
@@ -442,6 +442,40 @@
442 442 self.capture().is_ok()
443 443 }
444 444
445 + /// Start it and do not wait, for a child that outlives the keypress.
446 + ///
447 + /// `alloy-open` execs the browser it finds, so it lives exactly as long as
448 + /// the browser does. [`run`](Self::run) would hold the console frozen until
449 + /// the user quit Firefox, which is not what pressing a key to open a web UI
450 + /// means.
451 + ///
452 + /// Stdio goes to /dev/null in all three directions. A browser writes to its
453 + /// terminal freely, and this console is a ratatui frame: its chatter would
454 + /// land on top of the drawing. The cost is that a message on stderr is lost,
455 + /// which for `alloy-open` is the "no browser installed" advice it exists to
456 + /// give -- but that path also calls `notify-send`, so the message survives
457 + /// where a person can see it, and the caller says where it went.
458 + pub(crate) fn launch(&self, log: &mut CommandLog) -> Result<()> {
459 + let mut command = child_command(&self.program);
460 + command
461 + .args(&self.args)
462 + .stdin(Stdio::null())
463 + .stdout(Stdio::null())
464 + .stderr(Stdio::null());
465 + let spawned = command.spawn();
466 + log.record(
467 + self.display(),
468 + if spawned.is_ok() {
469 + Severity::Healthy
470 + } else {
471 + Severity::Error
472 + },
473 + );
474 + spawned
475 + .map(drop)
476 + .with_context(|| format!("failed to launch `{}`", self.display()))
477 + }
478 +
445 479 /// Run without logging, keeping the output.
446 480 ///
447 481 /// For console bookkeeping that needs a result rather than a yes/no:
@@ -134,6 +134,36 @@
134 134
135 135 pub(super) struct Syncthing;
136 136
137 + impl Syncthing {
138 + /// Remove Syncthing's auto-created `default` folder, if it is still that.
139 + ///
140 + /// Guarded rather than unconditional, because `default` is a legal id for a
141 + /// folder somebody meant. Two things have to hold: the path is the `~/Sync`
142 + /// that Syncthing picks itself, and it is shared with nobody but this
143 + /// machine. A folder that has been pointed somewhere else, or shared with a
144 + /// peer, is one a person made and this leaves it alone.
145 + ///
146 + /// Best effort by design. Enrolment succeeded the moment the daemon came
147 + /// up, and failing that because a tidy-up did not take would be reporting
148 + /// the wrong thing. The command is still logged, so a failure is visible
149 + /// rather than swallowed.
150 + fn drop_placeholder(&self, log: &mut CommandLog) {
151 + let Ok(Reach::Running(state)) = self.reach(log) else {
152 + return;
153 + };
154 + let placeholder = state.folders.iter().find(|folder| {
155 + folder.id == "default"
156 + && folder.shared_with <= 1
157 + && folder.path.trim_end_matches('/').ends_with("/Sync")
158 + });
159 + if placeholder.is_some() {
160 + let _ = Invocation::new("syncthing")
161 + .args(["cli", "config", "folders", "default", "delete"])
162 + .run(log);
163 + }
164 + }
165 + }
166 +
137 167 impl Backend for Syncthing {
138 168 fn name(&self) -> &'static str {
139 169 "syncthing"
@@ -175,11 +205,21 @@
175 205 /// `--now` so enrolling starts the daemon as well as arranging for it to
176 206 /// start next time. A user who pressed enroll and then had to log out
177 207 /// before anything happened would reasonably read that as broken.
208 + ///
209 + /// Then the placeholder goes. Syncthing invents a folder called `default`
210 + /// at `~/Sync` on its first run, shared with nobody, and nothing in this
211 + /// fleet wants it. Left alone it is worse than clutter: it is one folder
212 + /// that is never behind, so a machine where no real folder has arrived
213 + /// reports the same "none behind" as one holding the lot. That is exactly
214 + /// how fw12 read as finished on 2026-09-07 while none of fw13's eight had
215 + /// been shared to it, and it is the second install in a row where somebody
216 + /// deleted it by hand.
178 217 fn enroll(&self, log: &mut CommandLog) -> Result<()> {
179 218 Invocation::new("systemctl")
180 219 .args(["--user", "enable", "--now", UNIT])
181 - .run(log)
182 - .map(drop)
220 + .run(log)?;
221 + self.drop_placeholder(log);
222 + Ok(())
183 223 }
184 224
185 225 fn set_folder_paused(&self, folder: &Folder, paused: bool, log: &mut CommandLog) -> Result<()> {
@@ -21,7 +21,7 @@
21 21 Backend, DeviceDraft, FolderDraft, UNIT, detect, validate_device, validate_folder,
22 22 };
23 23 use super::model::{Device, Folder, PendingDevice, Reach, SyncState, day};
24 - use crate::cli::CommandLog;
24 + use crate::cli::{CommandLog, Invocation};
25 25 use crate::shell::{Confirm, Flow, View, block_title, truncate};
26 26
27 27 /// Ticks between background refreshes.
@@ -311,6 +311,37 @@
311 311 self.finish(result, log);
312 312 }
313 313
314 + /// Hand the web UI to a browser, for the edge cases this screen does not do.
315 + ///
316 + /// docs/CONTINUITY.md puts the web UI as reachable and not recommended:
317 + /// this screen "does not try to replicate the web UI's full feature
318 + /// surface, only the operations users perform; the web UI remains available
319 + /// for edge cases". Ignore patterns, versioning and per-folder advanced
320 + /// settings are those edge cases, and until today reaching them meant
321 + /// knowing the address by heart.
322 + ///
323 + /// Not a suspend. `alloy mesh` tears the console down for `tailscale up`
324 + /// because that command owns the terminal and blocks; a browser owns a
325 + /// window instead, so the console stays up and the list is still there when
326 + /// the user looks back. See [`Invocation::launch`](crate::cli::Invocation::launch).
327 + ///
328 + /// Address hardcoded rather than read from the config. It is Syncthing's
329 + /// documented default and this module's own header already names it; a
330 + /// machine whose GUI has been moved is one where this key is the least of
331 + /// what has changed.
332 + fn open_web_ui(&mut self, log: &mut CommandLog) {
333 + if self.reach == Reach::NotRunning {
334 + self.error = Some("syncthing is not running; enrol first with `e`".into());
335 + return;
336 + }
337 + let result = Invocation::new("alloy-open")
338 + .arg("http://127.0.0.1:8384")
339 + .launch(log);
340 + if let Err(err) = result {
341 + self.error = Some(err.to_string());
342 + }
343 + }
344 +
314 345 fn finish(&mut self, result: Result<()>, log: &mut CommandLog) {
315 346 match result {
316 347 Ok(()) => log.quiet(|log| self.refresh(log)),
@@ -611,6 +642,7 @@
611 642 hint("a", "add"),
612 643 hint("d", "remove"),
613 644 hint("p", "pause/resume"),
645 + hint("w", "web ui"),
614 646 hint("r", "refresh"),
615 647 ]
616 648 }
@@ -625,10 +657,38 @@
625 657 // Said on every tab, not only the one that lists them: an invitation
626 658 // nobody notices is the same as one that never arrived.
627 659 match self.pending_list().len() {
628 - 0 => None,
629 - 1 => Some((Severity::Info, "1 device waiting to connect".into())),
630 - n => Some((Severity::Info, format!("{n} devices waiting to connect"))),
660 + 0 => {}
661 + 1 => return Some((Severity::Info, "1 device waiting to connect".into())),
662 + n => return Some((Severity::Info, format!("{n} devices waiting to connect"))),
631 663 }
664 + // A running daemon with nothing shared is the state this screen used to
665 + // call enrolled and leave at that.
666 + //
667 + // MEASURED on fw12 2026-09-07, after a reinstall: the daemon was up,
668 + // four devices were paired, and not one of fw13's eight folders had
669 + // arrived. Every surface said the right thing separately and none of
670 + // them said the next step, so the machine read as done for hours.
671 + //
672 + // The next step is not on this machine, which is why saying it matters.
673 + // A folder is shared FROM the machine that has it TO this one, and what
674 + // that machine needs is this one's device id. So the id is the message
675 + // rather than a suggestion to go and look for it.
676 + if let Reach::Running(state) = &self.reach
677 + && state.folders.is_empty()
678 + {
679 + let mine = state.devices.iter().find(|device| device.is_self);
680 + return Some((
681 + Severity::Warn,
682 + match mine {
683 + Some(device) => format!(
684 + "no folders yet: share them to this machine ({}) from the one that has them",
685 + device.short_id()
686 + ),
687 + None => "no folders yet: share them from the machine that has them".into(),
688 + },
689 + ));
690 + }
691 + None
632 692 }
633 693
634 694 fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
@@ -763,6 +823,7 @@
763 823 KeyCode::Char('d') => return self.remove_selected(),
764 824 KeyCode::Char('p') => self.toggle_paused(log),
765 825 KeyCode::Char('e') => self.enroll(log),
826 + KeyCode::Char('w') => self.open_web_ui(log),
766 827 KeyCode::Char('r') => self.refresh(log),
767 828 _ => {}
768 829 }
@@ -873,4 +934,105 @@
873 934 }
874 935 }
875 936 }
937 +
938 + // -----------------------------------------------------------------------
939 + // What a running daemon with nothing shared says
940 + // -----------------------------------------------------------------------
941 +
942 + fn view_with(reach: Reach) -> SyncView {
943 + SyncView {
944 + backend: Box::new(super::super::backend::Mock),
945 + reach,
946 + tab: Tab::Folders,
947 + folders: Cursor::new(),
948 + devices: Cursor::new(),
949 + pending: Cursor::new(),
950 + draft: None,
951 + draft_focus: FocusRing::new(0),
952 + pending_action: None,
953 + error: None,
954 + ticks: 0,
955 + }
956 + }
957 +
958 + fn a_device(id: &str, is_self: bool) -> Device {
959 + Device {
960 + id: id.into(),
961 + name: "somebody".into(),
962 + paused: false,
963 + connected: false,
964 + is_self,
965 + }
966 + }
967 +
968 + /// The state fw12 sat in for hours on 2026-09-07: daemon up, devices
969 + /// paired, not one folder shared. It has to name the next step, and the
970 + /// next step needs this machine's id because the sharing happens elsewhere.
971 + #[test]
972 + fn a_running_daemon_with_no_folders_says_so_and_gives_this_machine_s_id() {
973 + let view = view_with(Reach::Running(SyncState {
974 + folders: Vec::new(),
975 + devices: vec![
976 + a_device("AAAAAAA-BBBBBBB", true),
977 + a_device("CCCCCCC-DDDDDDD", false),
978 + ],
979 + pending: Vec::new(),
980 + }));
981 + let (severity, message) = view.status().expect("a bare daemon is worth saying");
982 + assert_eq!(severity, Severity::Warn);
983 + assert!(message.contains("no folders"), "{message}");
984 + assert!(
985 + message.contains("AAAAAAA"),
986 + "it carries this machine's id: {message}"
987 + );
988 + assert!(
989 + !message.contains("CCCCCCC"),
990 + "and not the other machine's: {message}"
991 + );
992 + }
993 +
994 + /// A device waiting to connect is the more urgent thing and still wins.
995 + #[test]
996 + fn a_pending_device_outranks_the_no_folders_notice() {
997 + let view = view_with(Reach::Running(SyncState {
998 + folders: Vec::new(),
999 + devices: vec![a_device("AAAAAAA-BBBBBBB", true)],
1000 + pending: vec![PendingDevice {
1001 + id: "EEEEEEE-FFFFFFF".into(),
1002 + name: "knocking".into(),
1003 + address: "10.0.0.2:22000".into(),
1004 + time: "2026-09-07T21:00:00Z".into(),
1005 + }],
1006 + }));
1007 + let (_, message) = view.status().expect("a knock is worth saying");
1008 + assert!(message.contains("waiting to connect"), "{message}");
1009 + }
1010 +
1011 + /// Once a folder is there the notice goes away rather than nagging.
1012 + #[test]
1013 + fn a_shared_folder_clears_the_notice() {
1014 + let view = view_with(Reach::Running(SyncState {
1015 + folders: vec![Folder {
1016 + id: "mailbox".into(),
1017 + label: "mailbox".into(),
1018 + path: "/var/home/max/mailbox".into(),
1019 + kind: "sendreceive".into(),
1020 + paused: false,
1021 + shared_with: 2,
1022 + }],
1023 + devices: vec![a_device("AAAAAAA-BBBBBBB", true)],
1024 + pending: Vec::new(),
1025 + }));
1026 + assert!(view.status().is_none());
1027 + }
1028 +
1029 + /// The web UI key is refused before there is a daemon to show.
1030 + #[test]
1031 + fn the_web_ui_key_is_refused_until_the_daemon_is_up() {
1032 + let mut view = view_with(Reach::NotRunning);
1033 + let mut log = CommandLog::new();
1034 + view.open_web_ui(&mut log);
1035 + let error = view.error.as_deref().expect("it says why");
1036 + assert!(error.contains("not running"), "{error}");
1037 + }
876 1038 }