aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-26 22:51:28 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-26 22:51:28 +0300
commiteac2314e6ccb4ba91b7272523e68110035167279 (patch)
treecfa813cec6164a75366dfeeaa88d97ab9fa6e0fa /src
parent427c2606d8f62558e3949d2f44173175e96bed49 (diff)
fix: handle package-manager remote updates
Diffstat (limited to 'src')
-rw-r--r--src/remote.rs245
-rw-r--r--src/update.rs175
2 files changed, 385 insertions, 35 deletions
diff --git a/src/remote.rs b/src/remote.rs
index 9a4cc774..3cf1e43b 100644
--- a/src/remote.rs
+++ b/src/remote.rs
@@ -300,9 +300,63 @@ impl RemoteHerdr {
#[derive(Deserialize)]
struct RemoteUpdateManifest {
version: String,
+ protocol: Option<u32>,
+ assets: BTreeMap<String, String>,
+ #[serde(default, deserialize_with = "deserialize_remote_manifest_releases")]
+ releases: BTreeMap<String, RemoteReleaseMetadata>,
+}
+
+#[derive(Deserialize)]
+struct RemoteReleaseMetadata {
+ protocol: Option<u32>,
+ #[serde(default)]
assets: BTreeMap<String, String>,
}
+fn deserialize_remote_manifest_releases<'de, D>(
+ deserializer: D,
+) -> Result<BTreeMap<String, RemoteReleaseMetadata>, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ let value = Option::<serde_json::Value>::deserialize(deserializer)?;
+ Ok(match value {
+ Some(serde_json::Value::Object(object)) => object
+ .into_iter()
+ .filter_map(|(version, release)| {
+ serde_json::from_value::<RemoteReleaseMetadata>(release)
+ .ok()
+ .map(|metadata| (version, metadata))
+ })
+ .collect(),
+ _ => BTreeMap::new(),
+ })
+}
+
+impl RemoteUpdateManifest {
+ fn release_for_version(&self, version: &str) -> Option<RemoteManifestReleaseRef<'_>> {
+ if self.version.trim_start_matches('v') == version {
+ return Some(RemoteManifestReleaseRef {
+ protocol: self.protocol,
+ assets: &self.assets,
+ });
+ }
+
+ self.releases.get(version).and_then(|release| {
+ (!release.assets.is_empty()).then_some(RemoteManifestReleaseRef {
+ protocol: release.protocol,
+ assets: &release.assets,
+ })
+ })
+ }
+}
+
+#[derive(Clone, Copy)]
+struct RemoteManifestReleaseRef<'a> {
+ protocol: Option<u32>,
+ assets: &'a BTreeMap<String, String>,
+}
+
struct InstallSource {
path: PathBuf,
temporary_dir: Option<PathBuf>,
@@ -525,11 +579,23 @@ fn remote_binary_override_path() -> io::Result<Option<PathBuf>> {
}
fn install_source_description(platform: &RemotePlatform, override_binary: Option<&Path>) -> String {
+ install_source_description_for(
+ platform,
+ override_binary,
+ local_binary_can_seed_remote(platform),
+ )
+}
+
+fn install_source_description_for(
+ platform: &RemotePlatform,
+ override_binary: Option<&Path>,
+ local_binary_can_seed_remote: bool,
+) -> String {
if let Some(path) = override_binary {
return format!("{REMOTE_BINARY_ENV_VAR} ({})", path.display());
}
- if *platform == RemotePlatform::local() {
+ if local_binary_can_seed_remote {
"the current local herdr binary".to_string()
} else {
format!(
@@ -549,12 +615,24 @@ fn resolve_install_source(
if *platform == RemotePlatform::local() {
let path = std::env::current_exe()?;
- return Ok(InstallSource::persistent(path));
+ if !crate::update::is_package_manager_managed_exe_path(&path) {
+ return Ok(InstallSource::persistent(path));
+ }
}
download_release_asset(platform)
}
+fn local_binary_can_seed_remote(platform: &RemotePlatform) -> bool {
+ if *platform != RemotePlatform::local() {
+ return false;
+ }
+
+ std::env::current_exe()
+ .map(|path| !crate::update::is_package_manager_managed_exe_path(&path))
+ .unwrap_or(false)
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
enum RemoteServerStatus {
Running {
@@ -985,16 +1063,22 @@ fn download_release_asset(platform: &RemotePlatform) -> io::Result<InstallSource
let manifest: RemoteUpdateManifest = serde_json::from_slice(&manifest_output.stdout)
.map_err(|err| io::Error::other(format!("failed to parse update manifest JSON: {err}")))?;
- if manifest.version.trim_start_matches('v') != CURRENT_VERSION {
- return Err(io::Error::other(format!(
- "remote host is {}, but this local herdr is {CURRENT_VERSION} and the latest release manifest is {}; build herdr for the remote platform or install it there manually",
- platform.asset_key(),
- manifest.version
- )));
- }
let asset_key = platform.asset_key();
- let url = manifest.assets.get(&asset_key).ok_or_else(|| {
+ let release = manifest.release_for_version(CURRENT_VERSION).ok_or_else(|| {
+ io::Error::other(format!(
+ "release manifest does not include herdr {CURRENT_VERSION}; build herdr for {} or install it there manually",
+ platform.asset_key()
+ ))
+ })?;
+ if let Some(protocol) = release.protocol {
+ if protocol != CURRENT_PROTOCOL {
+ return Err(io::Error::other(format!(
+ "release manifest has herdr {CURRENT_VERSION} protocol {protocol}, but this client needs protocol {CURRENT_PROTOCOL}; set {REMOTE_BINARY_ENV_VAR}=target/release/herdr or install a matching herdr on the remote host manually"
+ )));
+ }
+ }
+ let url = release.assets.get(&asset_key).ok_or_else(|| {
io::Error::other(format!(
"no {asset_key} binary in the release manifest for herdr {CURRENT_VERSION}"
))
@@ -1805,6 +1889,122 @@ mod tests {
}
#[test]
+ fn remote_update_manifest_uses_root_assets_for_latest_version() {
+ let manifest: RemoteUpdateManifest = serde_json::from_str(
+ r#"{
+ "version": "1.2.3",
+ "assets": {
+ "linux-x86_64": "https://example.com/latest"
+ },
+ "releases": {
+ "1.2.3": {
+ "assets": {
+ "linux-x86_64": "https://example.com/archive"
+ }
+ }
+ }
+ }"#,
+ )
+ .unwrap();
+
+ assert_eq!(
+ manifest
+ .release_for_version("1.2.3")
+ .and_then(|release| release.assets.get("linux-x86_64"))
+ .map(String::as_str),
+ Some("https://example.com/latest")
+ );
+ }
+
+ #[test]
+ fn remote_update_manifest_reads_archived_release_assets() {
+ let manifest: RemoteUpdateManifest = serde_json::from_str(
+ r#"{
+ "version": "1.2.4",
+ "assets": {
+ "linux-x86_64": "https://example.com/latest"
+ },
+ "releases": {
+ "1.2.3": {
+ "notes": "ignored",
+ "assets": {
+ "linux-x86_64": "https://example.com/archive"
+ }
+ }
+ }
+ }"#,
+ )
+ .unwrap();
+
+ assert_eq!(
+ manifest
+ .release_for_version("1.2.3")
+ .and_then(|release| release.assets.get("linux-x86_64"))
+ .map(String::as_str),
+ Some("https://example.com/archive")
+ );
+ }
+
+ #[test]
+ fn remote_update_manifest_uses_archived_release_protocol() {
+ let manifest: RemoteUpdateManifest = serde_json::from_str(
+ r#"{
+ "version": "1.2.4",
+ "protocol": 42,
+ "assets": {
+ "linux-x86_64": "https://example.com/latest"
+ },
+ "releases": {
+ "1.2.3": {
+ "notes": "ignored",
+ "protocol": 41,
+ "assets": {
+ "linux-x86_64": "https://example.com/archive"
+ }
+ }
+ }
+ }"#,
+ )
+ .unwrap();
+
+ assert_eq!(
+ manifest
+ .release_for_version("1.2.3")
+ .and_then(|release| release.protocol),
+ Some(41)
+ );
+ }
+
+ #[test]
+ fn remote_update_manifest_does_not_inherit_latest_protocol_for_archived_assets() {
+ let manifest: RemoteUpdateManifest = serde_json::from_str(
+ r#"{
+ "version": "1.2.4",
+ "protocol": 42,
+ "assets": {
+ "linux-x86_64": "https://example.com/latest"
+ },
+ "releases": {
+ "1.2.3": {
+ "notes": "ignored",
+ "assets": {
+ "linux-x86_64": "https://example.com/archive"
+ }
+ }
+ }
+ }"#,
+ )
+ .unwrap();
+
+ assert_eq!(
+ manifest
+ .release_for_version("1.2.3")
+ .and_then(|release| release.protocol),
+ None
+ );
+ }
+
+ #[test]
fn remote_server_restart_reason_requires_stop_for_protocol_mismatch() {
assert_eq!(
remote_server_restart_reason(Some(CURRENT_VERSION), Some(0), false),
@@ -1847,12 +2047,35 @@ mod tests {
arch: "aarch64",
};
assert_eq!(
- install_source_description(&platform, Some(Path::new("/tmp/herdr-aarch64"))),
+ install_source_description_for(&platform, Some(Path::new("/tmp/herdr-aarch64")), false),
"HERDR_REMOTE_BINARY (/tmp/herdr-aarch64)"
);
}
#[test]
+ fn install_source_description_uses_local_binary_when_allowed() {
+ let platform = RemotePlatform::local();
+
+ assert_eq!(
+ install_source_description_for(&platform, None, true),
+ "the current local herdr binary"
+ );
+ }
+
+ #[test]
+ fn install_source_description_uses_release_asset_when_local_binary_cannot_seed_remote() {
+ let platform = RemotePlatform::local();
+
+ assert_eq!(
+ install_source_description_for(&platform, None, false),
+ format!(
+ "the {CURRENT_VERSION} release asset for {}",
+ platform.asset_key()
+ )
+ );
+ }
+
+ #[test]
fn resolve_install_source_uses_override_binary_without_temporary_cleanup() {
let platform = RemotePlatform {
os: "linux",
diff --git a/src/update.rs b/src/update.rs
index afbbc662..81cfb848 100644
--- a/src/update.rs
+++ b/src/update.rs
@@ -276,6 +276,18 @@ fn parse_homebrew_formula_stable_version(input: &[u8]) -> Result<Version, String
})
}
+fn homebrew_update_from_formula_json(
+ input: &[u8],
+ current: &Version,
+) -> Result<Option<Version>, String> {
+ let latest = parse_homebrew_formula_stable_version(input)?;
+ if &latest <= current {
+ return Ok(None);
+ }
+
+ Ok(Some(latest))
+}
+
fn check_homebrew_latest() -> Result<Option<Version>, String> {
let current = Version::current();
@@ -294,15 +306,10 @@ fn check_homebrew_latest() -> Result<Option<Version>, String> {
.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);
+ return Err("failed to fetch Homebrew formula JSON".into());
}
- Ok(Some(latest))
+ homebrew_update_from_formula_json(&output.stdout, &current)
}
// ---------------------------------------------------------------------------
@@ -1425,13 +1432,7 @@ fn is_homebrew_managed_install() -> bool {
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))
+ is_homebrew_managed_exe_path_following_links(&current_exe)
}
fn is_nix_managed_install() -> bool {
@@ -1439,12 +1440,29 @@ fn is_nix_managed_install() -> bool {
return false;
};
- if is_nix_store_exe_path(&current_exe) {
+ is_nix_store_exe_path_following_links(&current_exe)
+}
+
+pub(crate) fn is_package_manager_managed_exe_path(path: &Path) -> bool {
+ is_homebrew_managed_exe_path_following_links(path)
+ || is_nix_store_exe_path_following_links(path)
+}
+
+fn is_homebrew_managed_exe_path_following_links(path: &Path) -> bool {
+ if is_homebrew_managed_exe_path(path) {
+ return true;
+ }
+
+ path.canonicalize()
+ .is_ok_and(|path| is_homebrew_managed_exe_path(&path))
+}
+
+fn is_nix_store_exe_path_following_links(path: &Path) -> bool {
+ if is_nix_store_exe_path(path) {
return true;
}
- current_exe
- .canonicalize()
+ path.canonicalize()
.is_ok_and(|path| is_nix_store_exe_path(&path))
}
@@ -1903,13 +1921,19 @@ fn auto_update_homebrew(events: tokio::sync::mpsc::Sender<crate::events::AppEven
}
fn homebrew_release_notes_body(version: &Version) -> String {
- if let Ok(manifest) = fetch_update_manifest() {
- if let Some(metadata) = manifest.metadata_for_version(version) {
- let notes_body = metadata.notes_body();
- if !notes_body.is_empty() {
- handle_manifest_announcement(&version.to_string(), metadata.announcement.as_ref());
- return notes_body;
- }
+ let manifest = fetch_update_manifest().ok();
+ homebrew_release_notes_body_from_manifest(version, manifest.as_ref())
+}
+
+fn homebrew_release_notes_body_from_manifest(
+ version: &Version,
+ manifest: Option<&UpdateManifest>,
+) -> String {
+ if let Some(metadata) = manifest.and_then(|manifest| manifest.metadata_for_version(version)) {
+ let notes_body = metadata.notes_body();
+ if !notes_body.is_empty() {
+ handle_manifest_announcement(&version.to_string(), metadata.announcement.as_ref());
+ return notes_body;
}
}
@@ -2102,10 +2126,34 @@ mod tests {
}
#[test]
+ fn package_manager_path_detection_follows_homebrew_symlink() {
+ #[cfg(unix)]
+ {
+ let root = std::env::temp_dir().join(format!(
+ "herdr-homebrew-symlink-test-{}",
+ std::process::id()
+ ));
+ let cellar_bin = root.join("Cellar/herdr/0.6.2/bin");
+ let opt_bin = root.join("opt/herdr/bin");
+ fs::create_dir_all(&cellar_bin).unwrap();
+ fs::create_dir_all(&opt_bin).unwrap();
+ let cellar_binary = cellar_bin.join("herdr");
+ let opt_binary = opt_bin.join("herdr");
+ fs::write(&cellar_binary, b"").unwrap();
+ std::os::unix::fs::symlink(&cellar_binary, &opt_binary).unwrap();
+
+ assert!(is_package_manager_managed_exe_path(&opt_binary));
+
+ let _ = fs::remove_dir_all(root);
+ }
+ }
+
+ #[test]
fn nix_store_path_is_detected() {
let path = Path::new("/nix/store/abc123-herdr-0.6.1/bin/herdr");
assert!(is_nix_store_exe_path(path));
+ assert!(is_package_manager_managed_exe_path(path));
}
#[test]
@@ -2126,6 +2174,59 @@ mod tests {
}
#[test]
+ fn homebrew_formula_update_uses_formula_stable_not_manifest_latest() {
+ let current = Version::parse("0.6.1").unwrap();
+ let update = homebrew_update_from_formula_json(
+ br#"{"versions":{"stable":"0.6.2","head":"HEAD","bottle":true}}"#,
+ &current,
+ )
+ .unwrap();
+
+ assert_eq!(update, Some(Version::parse("0.6.2").unwrap()));
+ }
+
+ #[test]
+ fn homebrew_formula_update_ignores_versions_that_are_not_newer() {
+ let current = Version::parse("0.6.2").unwrap();
+ let update = homebrew_update_from_formula_json(
+ br#"{"versions":{"stable":"0.6.2","head":"HEAD","bottle":true}}"#,
+ &current,
+ )
+ .unwrap();
+
+ assert_eq!(update, None);
+ }
+
+ #[test]
+ fn homebrew_release_notes_use_package_manager_guidance() {
+ let body =
+ homebrew_release_notes_body_from_manifest(&Version::parse("0.6.3").unwrap(), None);
+
+ assert_eq!(body, "### Changed\n- v0.6.3 is available through Homebrew.");
+ }
+
+ #[test]
+ fn homebrew_release_notes_can_use_manifest_metadata() {
+ let manifest: UpdateManifest = serde_json::from_str(
+ r####"{
+ "version": "0.6.3",
+ "protocol": 10,
+ "notes": "### Fixed\n- Brew notes",
+ "assets": {
+ "linux-x86_64": "https://example.com/herdr-linux-x86_64"
+ }
+ }"####,
+ )
+ .unwrap();
+ let body = homebrew_release_notes_body_from_manifest(
+ &Version::parse("0.6.3").unwrap(),
+ Some(&manifest),
+ );
+
+ assert_eq!(body, "### Fixed\n- Brew notes");
+ }
+
+ #[test]
fn fake_release_notes_default_to_real_large_changelog_section() {
let _guard = env_lock().lock().unwrap();
std::env::remove_var(FAKE_UPDATE_NOTES_VERSION_ENV);
@@ -2954,5 +3055,31 @@ mod tests {
"unexpected asset name for {target}: {url}"
);
}
+
+ for (version, release) in &manifest.releases {
+ let assets = release
+ .get("assets")
+ .and_then(serde_json::Value::as_object)
+ .unwrap_or_else(|| panic!("missing assets for release {version}"));
+ for target in [
+ "linux-x86_64",
+ "linux-aarch64",
+ "macos-x86_64",
+ "macos-aarch64",
+ ] {
+ let url = assets
+ .get(target)
+ .and_then(serde_json::Value::as_str)
+ .unwrap_or_else(|| panic!("missing asset URL for {version} {target}"));
+ assert!(
+ url.contains(&format!("/releases/download/v{version}/")),
+ "unexpected release URL for {version} {target}: {url}"
+ );
+ assert!(
+ url.ends_with(&format!("herdr-{target}")),
+ "unexpected asset name for {version} {target}: {url}"
+ );
+ }
+ }
}
}