Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
28 changes: 28 additions & 0 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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", || {
Expand Down
32 changes: 31 additions & 1 deletion src/app/release.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<Vec<String>>) -> 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);
Expand Down
37 changes: 26 additions & 11 deletions src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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};
Expand Down
Loading