fix: use website update manifest

This commit is contained in:
Ogulcan Celik 2026-04-07 13:45:36 +03:00
parent fd89da606e
commit 78fc0721a3
2 changed files with 83 additions and 56 deletions

View File

@ -1,16 +1,17 @@
//! Self-update mechanism.
//!
//! Checks GitHub releases for newer versions and downloads the binary.
//! Checks the hosted herdr.dev update manifest for newer versions and downloads the binary.
//! Uses `curl` as a subprocess for HTTP — no additional Rust HTTP dependencies.
//! JSON parsing uses serde_json (already in deps for persistence).
use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::process::Command;
use serde::Deserialize;
const GITHUB_REPO: &str = "ogulcancelik/herdr";
const UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json";
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const FAKE_UPDATE_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_VERSION";
@ -58,21 +59,37 @@ impl std::fmt::Display for Version {
}
// ---------------------------------------------------------------------------
// GitHub release API types
// Update manifest
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
struct GitHubRelease {
tag_name: String,
struct UpdateManifest {
version: String,
#[serde(default)]
body: String,
assets: Vec<GitHubAsset>,
notes: String,
#[serde(default)]
notes_url: String,
assets: BTreeMap<String, String>,
}
#[derive(Deserialize)]
struct GitHubAsset {
name: String,
browser_download_url: String,
impl UpdateManifest {
fn download_url_for(&self, os: &str, arch: &str) -> Option<String> {
self.assets.get(&format!("{os}-{arch}")).cloned()
}
fn notes_body(&self) -> String {
let notes = self.notes.trim();
if !notes.is_empty() {
return notes.to_string();
}
let notes_url = self.notes_url.trim();
if notes_url.is_empty() {
return String::new();
}
format!("### Release notes\n- {notes_url}")
}
}
// ---------------------------------------------------------------------------
@ -86,54 +103,47 @@ struct ReleaseInfo {
notes_body: String,
}
/// Check GitHub for the latest release. Returns release info if newer.
/// Check the hosted update manifest for the latest release. Returns release info if newer.
fn check_latest() -> Result<Option<ReleaseInfo>, String> {
let current = Version::current();
let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases/latest");
let output = Command::new("curl")
.args([
"-sfL",
"--max-time",
"--retry",
"3",
"--connect-timeout",
"10",
"-H",
"Accept: application/vnd.github+json",
"-H",
"User-Agent: herdr-updater",
&url,
"--max-time",
"20",
UPDATE_MANIFEST_URL,
])
.output()
.map_err(|e| format!("curl failed: {e}"))?;
if !output.status.success() {
return Err("failed to fetch release info".into());
return Err("failed to fetch update manifest".into());
}
let release: GitHubRelease = serde_json::from_slice(&output.stdout)
.map_err(|e| format!("failed to parse release JSON: {e}"))?;
let manifest: UpdateManifest = serde_json::from_slice(&output.stdout)
.map_err(|e| format!("failed to parse update manifest JSON: {e}"))?;
let latest = Version::parse(&release.tag_name)
.ok_or_else(|| format!("invalid version tag: {}", release.tag_name))?;
let latest = Version::parse(&manifest.version)
.ok_or_else(|| format!("invalid version in update manifest: {}", manifest.version))?;
if latest <= current {
return Ok(None); // up to date
}
// Find the asset for our platform
let (os, arch) = platform_target();
let asset_name = format!("herdr-{os}-{arch}");
let download_url = release
.assets
.iter()
.find(|a| a.name == asset_name)
.map(|a| a.browser_download_url.clone())
.ok_or_else(|| format!("no binary for {os}-{arch} in release {}", release.tag_name))?;
let download_url = manifest
.download_url_for(os, arch)
.ok_or_else(|| format!("no binary for {os}-{arch} in update manifest"))?;
Ok(Some(ReleaseInfo {
version: latest,
download_url,
notes_body: release.body.trim().to_string(),
notes_body: manifest.notes_body(),
}))
}
@ -390,32 +400,48 @@ mod tests {
}
#[test]
fn github_release_deserializes() {
fn update_manifest_deserializes() {
let json = r#"{
"tag_name": "v0.2.0",
"assets": [
{"name": "herdr-linux-x86_64", "browser_download_url": "https://example.com/herdr-linux-x86_64"},
{"name": "herdr-macos-aarch64", "browser_download_url": "https://example.com/herdr-macos-aarch64"}
]
"version": "0.2.0",
"notes_url": "https://example.com/releases/v0.2.0",
"assets": {
"linux-x86_64": "https://example.com/herdr-linux-x86_64",
"macos-aarch64": "https://example.com/herdr-macos-aarch64"
}
}"#;
let release: GitHubRelease = serde_json::from_str(json).unwrap();
assert_eq!(release.tag_name, "v0.2.0");
assert_eq!(release.assets.len(), 2);
assert_eq!(release.assets[0].name, "herdr-linux-x86_64");
let manifest: UpdateManifest = serde_json::from_str(json).unwrap();
assert_eq!(manifest.version, "0.2.0");
assert_eq!(manifest.assets.len(), 2);
assert_eq!(
manifest.download_url_for("linux", "x86_64").as_deref(),
Some("https://example.com/herdr-linux-x86_64")
);
}
#[test]
fn github_release_ignores_extra_fields() {
let json = r#"{
"tag_name": "v0.1.0",
"name": "Release v0.1.0",
"body": "Some notes",
"draft": false,
"prerelease": false,
"assets": []
}"#;
let release: GitHubRelease = serde_json::from_str(json).unwrap();
assert_eq!(release.tag_name, "v0.1.0");
assert!(release.assets.is_empty());
fn update_manifest_prefers_embedded_notes() {
let manifest = UpdateManifest {
version: "0.2.0".into(),
notes: "### Changed\n- One".into(),
notes_url: "https://example.com/releases/v0.2.0".into(),
assets: BTreeMap::new(),
};
assert_eq!(manifest.notes_body(), "### Changed\n- One");
}
#[test]
fn update_manifest_falls_back_to_notes_url() {
let manifest = UpdateManifest {
version: "0.2.0".into(),
notes: String::new(),
notes_url: "https://example.com/releases/v0.2.0".into(),
assets: BTreeMap::new(),
};
assert_eq!(
manifest.notes_body(),
"### Release notes\n- https://example.com/releases/v0.2.0"
);
}
}

View File

@ -1,5 +1,6 @@
{
"version": "0.4.2",
"notes": "### Added\n- The expanded sidebar agent panel can now switch between the current workspace and all workspaces, so you can scan and jump to agents across the whole session.\n- The collapsed sidebar now shows compact per-pane agent indicators, so you can keep an eye on agent activity without reopening the full sidebar.\n\n### Changed\n- The sidebar now handles larger workspace sets more cleanly: the workspace section has headers, its own scrolling, better-aligned drag/drop slots, and manual width changes persist across restarts. Double-clicking the divider resets it to the configured default width.\n- Pane scrollback is now configured with `advanced.scrollback_limit_bytes`, matching Ghostty's byte-based scrollback limit. Set it to `0` to disable pane scrollback entirely. The old `advanced.scrollback_lines` key is still accepted as an alias, but it now uses the same byte-based value.\n- Linux release binaries now ship with libghostty SIMD enabled again without reintroducing the musl startup issue, restoring the optimized Linux build path.\n\n### Fixed\n- Typing in pane terminals on macOS is responsive again after the Ghostty migration, by keeping a persistent per-pane Ghostty key encoder instead of rebuilding it on every keypress.\n- The collapsed sidebar expand toggle works again.\n- Creating a new tab now waits until you confirm the dialog, so cancelling the new-tab flow no longer leaves behind an unwanted tab.\n- Copying selected pane text now uses Ghostty's native selection extraction, which preserves wrapped text and wide characters more accurately.\n- Session restore is more tolerant of older and current snapshot formats, including pre-tab session files.",
"notes_url": "https://github.com/ogulcancelik/herdr/releases/tag/v0.4.2",
"assets": {
"linux-x86_64": "https://github.com/ogulcancelik/herdr/releases/download/v0.4.2/herdr-linux-x86_64",