From a988222c5ab500cb13a080e1aedfdd7c5ed28985 Mon Sep 17 00:00:00 2001 From: Evgeniy Kovrov <7254010+nyxene@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:20:55 +0100 Subject: [PATCH] fix: Check for a new release leaves a release already installed to Update After brew upgrade, a release found earlier kept Check for a new release marked beside Update, and its window said "Nothing newer ... runs here". A release the build at the server's path already holds is no longer news: the mark goes when that build is found, the daily check shows no card for it, and the window says it is installed and that Update switches to it. The server already watches the path it was started by (current_exe is not canonicalized on macOS); a test of the Homebrew layout keeps it so. Closes #172 Co-Authored-By: Claude --- docs/usage.md | 2 +- src/app/mod.rs | 28 ++++++++++++++++++++++++++++ src/app/release.rs | 32 +++++++++++++++++++++++++++++++- src/daemon.rs | 37 ++++++++++++++++++++++++++----------- 4 files changed, 86 insertions(+), 13 deletions(-) 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};