diff --git a/docs/usage.md b/docs/usage.md index 5bf4e47..226274f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -130,6 +130,6 @@ Only a named pane receives messages: name it with **Rename pane…** first. Befo ## Updating -**Check for a new release…** in the main menu asks GitHub for the newest release of nerd and shows it with its changelog: **install** downloads it, checks that nerd's release key signed its checksum and that the archive matches, and puts it in `~/.local/bin`, as install.sh does (a release without a signature is not offered); **skip this release** keeps the daily check quiet about it; **later** leaves it. A nerd installed with Homebrew is told to run `brew update && brew upgrade nerd`, and one running from anywhere else names its path and installs nothing. Install works only from nerd's own window on the Mac the server runs on, not from a window opened inside a pane or from another Mac (though a program running as you could ask for the install itself: it would get only the newest release, checked against nerd's release key: nothing it could not already do as you). The first window on a Mac asks once whether nerd may check by itself once a day; **Settings… → Check for new releases** changes the answer, which is kept per Mac in `update-check` next to config.json. A daily check only shows a card, and marks **Check for a new release…** in the menu until you install or skip that release. Every check writes a line to the log, and what it sends is in [What leaves nerd](privacy-and-security.md#what-leaves-nerd). +**Check for a new release…** in the main menu asks GitHub for the newest release of nerd and shows it with its changelog: **install** downloads it, checks that nerd's release key signed its checksum and that the archive matches, and puts it in `~/.local/bin`, as install.sh does (a release without a signature is not offered); **skip this release** keeps the daily check quiet about it; **later** leaves it. A nerd installed with Homebrew is told to run `brew update && brew upgrade nerd`; a release already installed where the server was started from (after `brew upgrade nerd`, say) is neither marked nor offered, and the window says **Update…** switches to it. One running from anywhere else names its path and installs nothing. Install works only from nerd's own window on the Mac the server runs on, not from a window opened inside a pane or from another Mac (though a program running as you could ask for the install itself: it would get only the newest release, checked against nerd's release key: nothing it could not already do as you). The first window on a Mac asks once whether nerd may check by itself once a day; **Settings… → Check for new releases** changes the answer, which is kept per Mac in `update-check` next to config.json. A daily check only shows a card, and marks **Check for a new release…** in the menu until you install or skip that release. Every check writes a line to the log, and what it sends is in [What leaves nerd](privacy-and-security.md#what-leaves-nerd). After you install a new build, a card says **Update available**, and the **≡** menu button and **Update…** in the menu are marked. **… more** on the card, or **Update…**, opens a window with the running build, the new one and what changed between them: **update now** hands the running server's panes to the new build, and their programs keep running; **skip this build** says nothing more about that build; **later** leaves it for now. diff --git a/src/app/mod.rs b/src/app/mod.rs index a811766..f199fac 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1675,6 +1675,34 @@ mod tests { }); } + #[test] + fn a_release_already_installed_at_the_server_s_path_is_left_to_update() { + use crate::release::Said; + with_release_home("release-on-disk", || { + let mut app = with_one_space(); + let marked = |app: &App, label: &str| matches!(app.global_menu((0, 0)), Overlay::Menu(m, _) if m.items.iter().any(|i| i.mark && i.label == label)); + app.release_job = Some((7, ReleaseAsk::Daily)); + release_ends(&mut app, Said::Found(found("v9.1.0"))); + assert!(marked(&app, "Check for a new release…"), "found before brew upgrade: news"); + // brew upgrade: the build at the server's path is that release now. + assert!(app.new_build("nerd 9.1.0 (abc, 2026-11-03 12:00)".into(), Vec::new())); + assert!(marked(&app, "Update…") && !marked(&app, "Check for a new release…"), "Update… holds it; Check no longer offers it"); + app.toast = None; + app.release_job = Some((8, ReleaseAsk::Daily)); + release_ends(&mut app, Said::Found(found("v9.1.0"))); + assert!(app.toast.is_none() && !marked(&app, "Check for a new release…"), "the daily check shows no card for it"); + app.overlay = app.check_release(); + release_ends(&mut app, Said::Found(crate::release::Found { newer: false, ..found("v9.1.0") })); + let Overlay::Confirm(c, Pending::Dismiss) = &app.overlay else { panic!("only told") }; + let body = c.body_lines(200).join(" "); + assert!(body.contains("nerd 9.1.0 (abc, 2026-11-03 12:00) is already installed here: Update… switches"), "{body}"); + app.new_build("nerd 9.0.5 (def)".into(), Vec::new()); + app.release_job = Some((9, ReleaseAsk::Daily)); + release_ends(&mut app, Said::Found(found("v9.1.0"))); + assert!(marked(&app, "Check for a new release…"), "an older build on disk leaves the release news"); + }); + } + #[test] fn the_first_window_asks_once_and_only_nerd_s_own_window_answers() { with_release_home("release-ask", || { diff --git a/src/app/release.rs b/src/app/release.rs index cca1b5e..1f35da6 100644 --- a/src/app/release.rs +++ b/src/app/release.rs @@ -100,7 +100,7 @@ impl App { } Said::Found(f) => { // Kept while it is news: the menu item and the menu button stay marked until it is installed or skipped. - let news = f.newer && crate::release::skipped().as_deref() != Some(f.tag.as_str()); + let news = f.newer && !self.release_on_disk(&f.tag) && crate::release::skipped().as_deref() != Some(f.tag.as_str()); self.release_found = news.then(|| f.clone()); let version = f.tag.trim_start_matches('v'); match ask { @@ -113,11 +113,41 @@ impl App { } } + /// A new build at the server's path said what it is: Update… offers it, and a release it holds is no longer news. + pub fn new_build(&mut self, info: String, changes: Vec>) -> bool { + self.update_available = true; + self.update_info = info; + self.update_changes = changes; + if self.release_found.as_ref().is_some_and(|f| self.release_on_disk(&f.tag)) { + self.release_found = None; + } + let skipped = std::fs::read_to_string(crate::paths::state_root().join("update-skipped")).unwrap_or_default(); + self.update_skipped = skipped.trim() == self.update_info; + if self.update_skipped { + return false; + } + // A standing notice would otherwise hide this card until a key. + self.notice = None; + self.toast_with_more("Update available".into(), More::Update); + true + } + + /// The build Update… switches to is this release or newer. + fn release_on_disk(&self, tag: &str) -> bool { + let v = |s: &str| crate::agents::semver(s).map(|s| s.0); + let installed = self.update_info.split_whitespace().nth(1).and_then(v); + self.update_available && installed.is_some_and(|i| tag.strip_prefix('v').and_then(v).is_some_and(|t| i >= t)) + } + /// The running build, the newest release and its changelog; install only when this nerd may replace itself. pub(super) fn release_window(&mut self, f: Found) -> Overlay { use crate::menu::Confirm; self.dirty = true; let (running, newest) = (crate::version_line(), format!("nerd {}", f.tag.trim_start_matches('v'))); + if self.release_on_disk(&f.tag) { + let body = format!("The newest release is {newest}, and {} is already installed here: Update… switches {running} to it.", self.update_info); + return Overlay::Confirm(Confirm::new(TITLE, body).info(), Pending::Dismiss); + } if !f.newer { let body = format!("Nothing newer: the newest release is {newest}, and {running} runs here."); return Overlay::Confirm(Confirm::new(TITLE, body).info(), Pending::Dismiss); diff --git a/src/daemon.rs b/src/daemon.rs index 4668850..a66fa5b 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -610,17 +610,7 @@ fn serve(started: &mut bool) -> std::io::Result<()> { } } if let Some((info, changes)) = probe.take(&exe, &bell, PROBE_LIMIT) { - app.update_available = true; - app.update_info = info; - app.update_changes = changes; - let skipped = std::fs::read_to_string(crate::paths::state_root().join("update-skipped")).unwrap_or_default(); - app.update_skipped = skipped.trim() == app.update_info; - if !app.update_skipped { - // A standing notice would otherwise hide this card until a key. - app.notice = None; - app.toast_with_more("Update available".into(), crate::app::More::Update); - redraw = true; - } + redraw |= app.new_build(info, changes); } redraw |= app.tick_states(); redraw |= app.autoscroll_selection(); @@ -1156,6 +1146,31 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn a_brew_upgrade_behind_the_link_is_a_new_build() { + let dir = std::env::temp_dir().join(format!("nerd-probe-brew-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let keg = |v: &str| script(&dir.join(format!("Cellar/nerd/{v}/bin")), &format!("echo 'nerd {v}'")); + keg("1.0.0"); + std::fs::create_dir_all(dir.join("bin")).unwrap(); + let link = dir.join("bin/nerd"); + std::os::unix::fs::symlink("../Cellar/nerd/1.0.0/bin/nerd", &link).unwrap(); + let before = super::file_id(&link); + // As brew upgrade does it: a new keg, the link moved, the old keg removed. + keg("1.1.0"); + std::fs::remove_file(&link).unwrap(); + std::os::unix::fs::symlink("../Cellar/nerd/1.1.0/bin/nerd", &link).unwrap(); + std::fs::remove_dir_all(dir.join("Cellar/nerd/1.0.0")).unwrap(); + let after = super::file_id(&link); + assert!(before.is_some() && after.is_some() && after != before, "{before:?} -> {after:?}"); + let (bell, _door) = crate::bell::new(); + let limit = std::time::Duration::from_secs(5); + let mut p = super::Probe::default(); + p.start(&link, &bell, limit); + assert_eq!(answer(&mut p, &link, limit, std::time::Duration::from_secs(8)).map(|a| a.0), Some("nerd 1.1.0".into())); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn a_trace_never_holds_what_was_typed_or_pasted() { use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};