diff --git a/src/update.rs b/src/update.rs index 27e180a1..bcc3c2b9 100644 --- a/src/update.rs +++ b/src/update.rs @@ -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, + notes: String, + #[serde(default)] + notes_url: String, + assets: BTreeMap, } -#[derive(Deserialize)] -struct GitHubAsset { - name: String, - browser_download_url: String, +impl UpdateManifest { + fn download_url_for(&self, os: &str, arch: &str) -> Option { + 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, 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" + ); } } diff --git a/website/latest.json b/website/latest.json index 8113b662..19498dee 100644 --- a/website/latest.json +++ b/website/latest.json @@ -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",