aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/app/actions.rs31
-rw-r--r--src/app/api.rs12
-rw-r--r--src/app/mod.rs3
-rw-r--r--src/app/state.rs2
-rw-r--r--src/events.rs7
-rw-r--r--src/main.rs6
-rw-r--r--src/pane.rs1
-rw-r--r--src/server/headless.rs10
-rw-r--r--src/ui.rs2
-rw-r--r--src/ui/release_notes.rs27
-rw-r--r--src/update.rs195
11 files changed, 279 insertions, 17 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 870044a8..be8e1018 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -652,8 +652,12 @@ impl AppState {
self.handle_pane_died(pane_id);
Vec::new()
}
- AppEvent::UpdateReady { version } => {
+ AppEvent::UpdateReady {
+ version,
+ install_command,
+ } => {
self.update_available = Some(version.clone());
+ self.update_install_command = install_command.clone();
self.latest_release_notes_available = true;
self.update_dismissed = true;
if matches!(
@@ -663,7 +667,7 @@ impl AppState {
self.toast = Some(ToastNotification {
kind: ToastKind::UpdateInstalled,
title: format!("v{version} available"),
- context: "detach, then run `herdr update`".to_string(),
+ context: format!("detach, then run `{install_command}`"),
target: None,
});
}
@@ -953,6 +957,7 @@ mod tests {
let updates = state.handle_app_event(crate::events::AppEvent::UpdateReady {
version: "0.5.0".into(),
+ install_command: "herdr update".into(),
});
assert!(updates.is_empty());
@@ -1500,6 +1505,7 @@ mod tests {
let updates = state.handle_app_event(AppEvent::UpdateReady {
version: "0.5.0".into(),
+ install_command: "herdr update".into(),
});
assert!(updates.is_empty());
@@ -1513,6 +1519,27 @@ mod tests {
}
#[test]
+ fn update_ready_uses_event_install_command_in_toast() {
+ let mut state = AppState::test_new();
+ state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
+
+ state.handle_app_event(AppEvent::UpdateReady {
+ version: "0.5.0".into(),
+ install_command: "brew update && brew upgrade herdr".into(),
+ });
+
+ assert_eq!(
+ state.update_install_command,
+ "brew update && brew upgrade herdr"
+ );
+ let toast = state.toast.as_ref().expect("update toast");
+ assert_eq!(
+ toast.context,
+ "detach, then run `brew update && brew upgrade herdr`"
+ );
+ }
+
+ #[test]
fn toggle_fullscreen_works() {
let mut state = app_with_workspaces(&["test"]);
state.workspaces[0].test_split(Direction::Horizontal);
diff --git a/src/app/api.rs b/src/app/api.rs
index 50a56624..5f959365 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -58,8 +58,12 @@ impl App {
None
};
- let update_ready_version = if let AppEvent::UpdateReady { version } = &ev {
- Some(version.clone())
+ let update_ready = if let AppEvent::UpdateReady {
+ version,
+ install_command,
+ } = &ev
+ {
+ Some((version.clone(), install_command.clone()))
} else {
None
};
@@ -93,10 +97,10 @@ impl App {
_ => unreachable!("toast delivery was checked above"),
};
- if let Some(version) = update_ready_version {
+ if let Some((version, install_command)) = update_ready {
let _ = notify(
&format!("v{version} available"),
- Some("detach, then run `herdr update`"),
+ Some(&format!("detach, then run `{install_command}`")),
);
} else {
for update in &pane_updates {
diff --git a/src/app/mod.rs b/src/app/mod.rs
index bc732f57..d9f666ed 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -291,6 +291,7 @@ impl App {
.filter(|notes| notes.preview)
.map(|notes| notes.version.clone());
let latest_release_notes_available = latest_release_notes.is_some();
+ let update_install_command = crate::update::update_install_command().to_string();
let mode = if config.should_show_onboarding() {
state::Mode::Onboarding
@@ -355,6 +356,7 @@ impl App {
selection: None,
context_menu: None,
update_available,
+ update_install_command,
latest_release_notes_available,
update_dismissed: false,
config_diagnostic,
@@ -1883,6 +1885,7 @@ mod tests {
app.event_tx
.try_send(AppEvent::UpdateReady {
version: format!("9.9.{i}"),
+ install_command: "herdr update".into(),
})
.unwrap();
}
diff --git a/src/app/state.rs b/src/app/state.rs
index 237e25a0..45473435 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -859,6 +859,7 @@ pub struct AppState {
pub context_menu: Option<ContextMenuState>,
// Notifications
pub update_available: Option<String>,
+ pub update_install_command: String,
pub latest_release_notes_available: bool,
pub update_dismissed: bool,
pub config_diagnostic: Option<String>,
@@ -1049,6 +1050,7 @@ impl AppState {
selection: None,
context_menu: None,
update_available: None,
+ update_install_command: "herdr update".into(),
latest_release_notes_available: false,
update_dismissed: false,
config_diagnostic: None,
diff --git a/src/events.rs b/src/events.rs
index 6016fe1b..2e0143e3 100644
--- a/src/events.rs
+++ b/src/events.rs
@@ -42,8 +42,11 @@ pub enum AppEvent {
known_agent: Option<Agent>,
seq: Option<u64>,
},
- /// A new version is available and ready to install explicitly.
- UpdateReady { version: String },
+ /// A new version is available through the active installation manager.
+ UpdateReady {
+ version: String,
+ install_command: String,
+ },
/// A pane child emitted a valid OSC 52 clipboard write. The main loop
/// re-emits it through herdr's own clipboard writer.
ClipboardWrite { content: Vec<u8> },
diff --git a/src/main.rs b/src/main.rs
index 33461e4d..c852c33b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -258,7 +258,11 @@ fn main() -> io::Result<()> {
match update::self_update() {
Ok(_) => return Ok(()),
Err(e) => {
- eprintln!("update failed: {e}");
+ if e.starts_with("self-update is disabled") {
+ eprintln!("{e}");
+ } else {
+ eprintln!("update failed: {e}");
+ }
std::process::exit(1);
}
}
diff --git a/src/pane.rs b/src/pane.rs
index 522365df..272229fa 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -1187,6 +1187,7 @@ mod tests {
tx.try_send(AppEvent::UpdateReady {
version: "9.9.9".into(),
+ install_command: "herdr update".into(),
})
.unwrap();
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 779bb522..a1e7669b 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -955,9 +955,13 @@ impl HeadlessServer {
true
}
- AppEvent::UpdateReady { version } => {
+ AppEvent::UpdateReady {
+ version,
+ install_command,
+ } => {
let toast_before = self.app.state.toast.clone();
let version = version.clone();
+ let install_command = install_command.clone();
self.app.handle_internal_event(ev);
@@ -971,7 +975,7 @@ impl HeadlessServer {
.map(|toast| format!("{}: {}", toast.title, toast.context))
} else {
Some(format!(
- "v{version} available: detach, then run `herdr update`"
+ "v{version} available: detach, then run `{install_command}`"
))
}
} else {
@@ -2970,6 +2974,7 @@ mod tests {
let changed = server.handle_internal_event_with_forwarding(AppEvent::UpdateReady {
version: "9.9.9".to_string(),
+ install_command: "herdr update".into(),
});
assert!(changed);
@@ -3004,6 +3009,7 @@ mod tests {
let changed = server.handle_internal_event_with_forwarding(AppEvent::UpdateReady {
version: "9.9.9".to_string(),
+ install_command: "herdr update".into(),
});
assert!(changed);
diff --git a/src/ui.rs b/src/ui.rs
index fc888396..819cc0eb 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -819,7 +819,7 @@ mod tests {
#[test]
fn release_notes_preview_lines_show_update_steps() {
let palette = Palette::catppuccin();
- let lines = release_notes_preview_lines("0.5.0", &palette);
+ let lines = release_notes_preview_lines("0.5.0", "herdr update", &palette);
assert_eq!(lines.len(), 2);
assert_eq!(line_text(&lines[0]), "● update ready");
diff --git a/src/ui/release_notes.rs b/src/ui/release_notes.rs
index 346b06d5..91990695 100644
--- a/src/ui/release_notes.rs
+++ b/src/ui/release_notes.rs
@@ -100,7 +100,13 @@ pub(super) fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, ar
.unwrap_or(sections.notes_body);
if let Some(instructions_area) = sections.instructions {
- render_release_notes_preview_panel(frame, instructions_area, &notes.version, &app.palette);
+ render_release_notes_preview_panel(
+ frame,
+ instructions_area,
+ &notes.version,
+ &app.update_install_command,
+ &app.palette,
+ );
}
let body = Paragraph::new(
@@ -278,7 +284,11 @@ pub(crate) fn release_notes_sections(area: Rect, preview: bool) -> ReleaseNotesS
}
}
-pub(super) fn release_notes_preview_lines<'a>(_version: &str, p: &Palette) -> Vec<Line<'a>> {
+pub(super) fn release_notes_preview_lines<'a>(
+ _version: &str,
+ install_command: &'a str,
+ p: &Palette,
+) -> Vec<Line<'a>> {
let title_style = Style::default().fg(p.text).add_modifier(Modifier::BOLD);
let text_style = Style::default().fg(p.text);
let code_style = Style::default()
@@ -296,13 +306,19 @@ pub(super) fn release_notes_preview_lines<'a>(_version: &str, p: &Palette) -> Ve
]),
Line::from(vec![
Span::styled("detach from this session, then run ", text_style),
- Span::styled("herdr update", code_style),
+ Span::styled(install_command, code_style),
Span::styled(" in your shell", text_style),
]),
]
}
-fn render_release_notes_preview_panel(frame: &mut Frame, area: Rect, _version: &str, p: &Palette) {
+fn render_release_notes_preview_panel(
+ frame: &mut Frame,
+ area: Rect,
+ _version: &str,
+ install_command: &str,
+ p: &Palette,
+) {
let rows = Layout::vertical([
Constraint::Length(2),
Constraint::Length(1),
@@ -318,7 +334,8 @@ fn render_release_notes_preview_panel(frame: &mut Frame, area: Rect, _version: &
rows[0].height,
);
frame.render_widget(
- Paragraph::new(release_notes_preview_lines(_version, p)).wrap(Wrap { trim: false }),
+ Paragraph::new(release_notes_preview_lines(_version, install_command, p))
+ .wrap(Wrap { trim: false }),
text_area,
);
diff --git a/src/update.rs b/src/update.rs
index fdb3ec5e..da35137c 100644
--- a/src/update.rs
+++ b/src/update.rs
@@ -18,6 +18,9 @@ use std::time::{Duration, Instant};
use serde::Deserialize;
const UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json";
+const HOMEBREW_FORMULA_API_URL: &str = "https://formulae.brew.sh/api/formula/herdr.json";
+const HERDR_UPDATE_COMMAND: &str = "herdr update";
+const HOMEBREW_UPDATE_COMMAND: &str = "brew update && brew upgrade herdr";
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const FAKE_UPDATE_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_VERSION";
const FAKE_UPDATE_NOTES_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_NOTES_VERSION";
@@ -92,6 +95,16 @@ struct UpdateManifest {
assets: BTreeMap<String, String>,
}
+#[derive(Deserialize)]
+struct HomebrewFormula {
+ versions: HomebrewFormulaVersions,
+}
+
+#[derive(Deserialize)]
+struct HomebrewFormulaVersions {
+ stable: String,
+}
+
impl UpdateManifest {
fn download_url_for(&self, os: &str, arch: &str) -> Option<String> {
self.assets.get(&format!("{os}-{arch}")).cloned()
@@ -165,6 +178,46 @@ fn check_latest() -> Result<Option<ReleaseInfo>, String> {
}))
}
+fn parse_homebrew_formula_stable_version(input: &[u8]) -> Result<Version, String> {
+ let formula: HomebrewFormula = serde_json::from_slice(input)
+ .map_err(|e| format!("failed to parse Homebrew formula JSON: {e}"))?;
+ Version::parse(&formula.versions.stable).ok_or_else(|| {
+ format!(
+ "invalid stable version in Homebrew formula JSON: {}",
+ formula.versions.stable
+ )
+ })
+}
+
+fn check_homebrew_latest() -> Result<Option<Version>, String> {
+ let current = Version::current();
+
+ let output = Command::new("curl")
+ .args([
+ "-sfL",
+ "--retry",
+ "2",
+ "--connect-timeout",
+ "5",
+ "--max-time",
+ "10",
+ HOMEBREW_FORMULA_API_URL,
+ ])
+ .output()
+ .map_err(|e| format!("curl failed: {e}"))?;
+
+ if !output.status.success() {
+ return Ok(None);
+ }
+
+ let latest = parse_homebrew_formula_stable_version(&output.stdout)?;
+ if latest <= current {
+ return Ok(None);
+ }
+
+ Ok(Some(latest))
+}
+
// ---------------------------------------------------------------------------
// Download + install
// ---------------------------------------------------------------------------
@@ -533,11 +586,67 @@ fn wait_for_server_shutdown(timeout: Duration) -> Result<(), String> {
}
// ---------------------------------------------------------------------------
+// Installation manager detection
+// ---------------------------------------------------------------------------
+
+pub(crate) fn update_install_command() -> &'static str {
+ if is_homebrew_managed_install() {
+ HOMEBREW_UPDATE_COMMAND
+ } else {
+ HERDR_UPDATE_COMMAND
+ }
+}
+
+fn is_homebrew_managed_install() -> bool {
+ let Ok(current_exe) = env::current_exe() else {
+ return false;
+ };
+
+ if is_homebrew_managed_exe_path(&current_exe) {
+ return true;
+ }
+
+ current_exe
+ .canonicalize()
+ .is_ok_and(|path| is_homebrew_managed_exe_path(&path))
+}
+
+fn is_homebrew_managed_exe_path(path: &Path) -> bool {
+ homebrew_cellar_keg_root(path).is_some()
+}
+
+fn homebrew_cellar_keg_root(path: &Path) -> Option<PathBuf> {
+ if path.file_name()? != "herdr" {
+ return None;
+ }
+ let bin_dir = path.parent()?;
+ if bin_dir.file_name()? != "bin" {
+ return None;
+ }
+ let version_dir = bin_dir.parent()?;
+ let formula_dir = version_dir.parent()?;
+ if formula_dir.file_name()? != "herdr" {
+ return None;
+ }
+ let cellar_dir = formula_dir.parent()?;
+ if cellar_dir.file_name()? != "Cellar" {
+ return None;
+ }
+ Some(version_dir.to_path_buf())
+}
+
+// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Manual self-update command (`herdr update`).
pub fn self_update() -> Result<Version, String> {
+ if is_homebrew_managed_install() {
+ return Err(format!(
+ "self-update is disabled for Homebrew installs; run `{HOMEBREW_UPDATE_COMMAND}`"
+ ));
+ }
+
if running_inside_herdr() {
return Err("run `herdr update` outside herdr after detaching from the session".into());
}
@@ -609,11 +718,17 @@ pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
}
let _ = events.blocking_send(crate::events::AppEvent::UpdateReady {
version: version.to_string(),
+ install_command: update_install_command().to_string(),
});
}
return;
}
+ if is_homebrew_managed_install() {
+ auto_update_homebrew(events);
+ return;
+ }
+
let release = match check_latest() {
Ok(Some(r)) => r,
Ok(None) => return,
@@ -644,9 +759,47 @@ pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
// Notify the TUI — blocking_send is safe from a std::thread
let _ = events.blocking_send(crate::events::AppEvent::UpdateReady {
version: release.version.to_string(),
+ install_command: HERDR_UPDATE_COMMAND.to_string(),
});
}
+fn auto_update_homebrew(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
+ let version = match check_homebrew_latest() {
+ Ok(Some(version)) => version,
+ Ok(None) => return,
+ Err(err) => {
+ crate::logging::update_check_failed(&err);
+ return;
+ }
+ };
+
+ crate::logging::update_available(&version.to_string());
+ let notes_body = homebrew_release_notes_body(&version);
+ if let Err(e) = crate::release_notes::save_pending(&version.to_string(), &notes_body) {
+ tracing::warn!("failed to save pending release notes: {e}");
+ }
+
+ tracing::info!(
+ "auto-update check: v{} available through Homebrew, waiting for explicit install",
+ version
+ );
+
+ let _ = events.blocking_send(crate::events::AppEvent::UpdateReady {
+ version: version.to_string(),
+ install_command: HOMEBREW_UPDATE_COMMAND.to_string(),
+ });
+}
+
+fn homebrew_release_notes_body(version: &Version) -> String {
+ if let Ok(Some(release)) = check_latest() {
+ if release.version == *version {
+ return release.notes_body;
+ }
+ }
+
+ format!("### Changed\n- v{version} is available through Homebrew.")
+}
+
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -747,6 +900,48 @@ mod tests {
}
#[test]
+ fn homebrew_cellar_path_is_detected() {
+ let path = Path::new("/opt/homebrew/Cellar/herdr/0.5.9/bin/herdr");
+
+ assert!(is_homebrew_managed_exe_path(path));
+ assert_eq!(
+ homebrew_cellar_keg_root(path).unwrap(),
+ PathBuf::from("/opt/homebrew/Cellar/herdr/0.5.9")
+ );
+ }
+
+ #[test]
+ fn homebrew_linux_cellar_path_is_detected() {
+ let path = Path::new("/home/linuxbrew/.linuxbrew/Cellar/herdr/0.5.9/bin/herdr");
+
+ assert!(is_homebrew_managed_exe_path(path));
+ }
+
+ #[test]
+ fn homebrew_opt_path_requires_canonicalized_cellar_target() {
+ let path = Path::new("/opt/homebrew/opt/herdr/bin/herdr");
+
+ assert!(!is_homebrew_managed_exe_path(path));
+ }
+
+ #[test]
+ fn non_homebrew_path_is_not_detected() {
+ let path = Path::new("/usr/local/bin/herdr");
+
+ assert!(!is_homebrew_managed_exe_path(path));
+ }
+
+ #[test]
+ fn parse_homebrew_formula_stable_version_reads_versions_stable() {
+ let version = parse_homebrew_formula_stable_version(
+ br#"{"versions":{"stable":"0.5.10","head":"HEAD","bottle":true}}"#,
+ )
+ .unwrap();
+
+ assert_eq!(version, Version::parse("0.5.10").unwrap());
+ }
+
+ #[test]
fn fake_release_notes_default_to_real_large_changelog_section() {
std::env::remove_var(FAKE_UPDATE_NOTES_VERSION_ENV);