feat: add homebrew update handling

refs #127
This commit is contained in:
Ogulcan Celik 2026-05-17 00:17:34 +03:00
parent 1342a76e60
commit 8d4cc05be0
11 changed files with 279 additions and 17 deletions

View File

@ -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"]);

View File

@ -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 {

View File

@ -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();
}

View File

@ -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,

View File

@ -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> },

View File

@ -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);
}
}

View File

@ -1187,6 +1187,7 @@ mod tests {
tx.try_send(AppEvent::UpdateReady {
version: "9.9.9".into(),
install_command: "herdr update".into(),
})
.unwrap();

View File

@ -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);

View File

@ -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");

View File

@ -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,
);

View File

@ -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
// ---------------------------------------------------------------------------
@ -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(&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
// ---------------------------------------------------------------------------
@ -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);