From 8d4cc05be0c696db2aabca7e9995e59019a01469 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sun, 17 May 2026 00:17:34 +0300 Subject: [PATCH] feat: add homebrew update handling refs #127 --- src/app/actions.rs | 31 ++++++- src/app/api.rs | 12 ++- src/app/mod.rs | 3 + src/app/state.rs | 2 + src/events.rs | 7 +- src/main.rs | 6 +- src/pane.rs | 1 + src/server/headless.rs | 10 ++- src/ui.rs | 2 +- src/ui/release_notes.rs | 27 ++++-- src/update.rs | 195 ++++++++++++++++++++++++++++++++++++++++ 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()); @@ -1512,6 +1518,27 @@ mod tests { assert_eq!(toast.context, "detach, then run `herdr update`"); } + #[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"]); 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, // Notifications pub update_available: Option, + pub update_install_command: String, pub latest_release_notes_available: bool, pub update_dismissed: bool, pub config_diagnostic: Option, @@ -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, seq: Option, }, - /// 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 }, 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, ¬es.version, &app.palette); + render_release_notes_preview_panel( + frame, + instructions_area, + ¬es.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> { +pub(super) fn release_notes_preview_lines<'a>( + _version: &str, + install_command: &'a str, + p: &Palette, +) -> Vec> { 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, } +#[derive(Deserialize)] +struct HomebrewFormula { + versions: HomebrewFormulaVersions, +} + +#[derive(Deserialize)] +struct HomebrewFormulaVersions { + stable: String, +} + impl UpdateManifest { fn download_url_for(&self, os: &str, arch: &str) -> Option { self.assets.get(&format!("{os}-{arch}")).cloned() @@ -165,6 +178,46 @@ fn check_latest() -> Result, String> { })) } +fn parse_homebrew_formula_stable_version(input: &[u8]) -> Result { + 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, 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 // --------------------------------------------------------------------------- @@ -532,12 +585,68 @@ fn wait_for_server_shutdown(timeout: Duration) -> Result<(), String> { wait_for_server_shutdown_at(&crate::api::socket_path(), timeout) } +// --------------------------------------------------------------------------- +// 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(¤t_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 { + 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 { + 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) { } 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) { // 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) { + 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(), ¬es_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 // --------------------------------------------------------------------------- @@ -746,6 +899,48 @@ mod tests { assert_eq!(Version::parse(""), None); } + #[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);