parent
1342a76e60
commit
8d4cc05be0
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1187,6 +1187,7 @@ mod tests {
|
|||
|
||||
tx.try_send(AppEvent::UpdateReady {
|
||||
version: "9.9.9".into(),
|
||||
install_command: "herdr update".into(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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<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,
|
||||
);
|
||||
|
||||
|
|
|
|||
195
src/update.rs
195
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -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<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(), ¬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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue