aboutsummaryrefslogtreecommitdiff
path: root/src/remote.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-02 21:48:02 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-02 21:48:02 +0300
commitb751d102f158968aab681cb663d27b4c32197f62 (patch)
tree85d03609314559df7705c2cb0ab4e37ab6d9715f /src/remote.rs
parent295b09ca25edbccac65401ee84fa420dd3888aad (diff)
feat: add preview update channel
Diffstat (limited to 'src/remote.rs')
-rw-r--r--src/remote.rs276
1 files changed, 220 insertions, 56 deletions
diff --git a/src/remote.rs b/src/remote.rs
index ebea55e0..cd53344b 100644
--- a/src/remote.rs
+++ b/src/remote.rs
@@ -19,9 +19,9 @@ const BRIDGE_ACCEPT_POLL: Duration = Duration::from_millis(50);
const BRIDGE_SOCKET_PERMISSION_MODE: u32 = 0o600;
const REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT: Duration = Duration::from_secs(5);
const REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100);
-const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const CURRENT_PROTOCOL: u32 = crate::protocol::PROTOCOL_VERSION;
-const UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json";
+const STABLE_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json";
+const PREVIEW_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/preview.json";
const REMOTE_BINARY_ENV_VAR: &str = "HERDR_REMOTE_BINARY";
pub(crate) const REATTACH_COMMAND_ENV_VAR: &str = "HERDR_REATTACH_COMMAND";
@@ -302,11 +302,36 @@ impl RemoteHerdr {
}
}
+#[derive(Debug, Clone, Deserialize)]
+#[serde(untagged)]
+enum RemoteAssetRef {
+ Url(String),
+ Object { url: String, sha256: Option<String> },
+}
+
+impl RemoteAssetRef {
+ fn url(&self) -> &str {
+ match self {
+ Self::Url(url) => url,
+ Self::Object { url, .. } => url,
+ }
+ }
+
+ fn sha256(&self) -> Option<&str> {
+ match self {
+ Self::Url(_) => None,
+ Self::Object { sha256, .. } => {
+ sha256.as_deref().filter(|value| !value.trim().is_empty())
+ }
+ }
+ }
+}
+
#[derive(Deserialize)]
struct RemoteUpdateManifest {
version: String,
protocol: Option<u32>,
- assets: BTreeMap<String, String>,
+ assets: BTreeMap<String, RemoteAssetRef>,
#[serde(default, deserialize_with = "deserialize_remote_manifest_releases")]
releases: BTreeMap<String, RemoteReleaseMetadata>,
}
@@ -315,7 +340,22 @@ struct RemoteUpdateManifest {
struct RemoteReleaseMetadata {
protocol: Option<u32>,
#[serde(default)]
- assets: BTreeMap<String, String>,
+ assets: BTreeMap<String, RemoteAssetRef>,
+}
+
+#[derive(Deserialize)]
+struct RemotePreviewManifest {
+ build_id: String,
+ protocol: u32,
+ assets: BTreeMap<String, RemoteAssetRef>,
+ #[serde(default)]
+ builds: BTreeMap<String, RemotePreviewBuildMetadata>,
+}
+
+#[derive(Deserialize)]
+struct RemotePreviewBuildMetadata {
+ protocol: u32,
+ assets: BTreeMap<String, RemoteAssetRef>,
}
fn deserialize_remote_manifest_releases<'de, D>(
@@ -359,7 +399,15 @@ impl RemoteUpdateManifest {
#[derive(Clone, Copy)]
struct RemoteManifestReleaseRef<'a> {
protocol: Option<u32>,
- assets: &'a BTreeMap<String, String>,
+ assets: &'a BTreeMap<String, RemoteAssetRef>,
+}
+
+fn current_version() -> String {
+ crate::build_info::version()
+}
+
+fn current_channel() -> &'static str {
+ crate::build_info::channel()
}
struct InstallSource {
@@ -367,6 +415,11 @@ struct InstallSource {
temporary_dir: Option<PathBuf>,
}
+struct RemoteReleaseAsset {
+ url: String,
+ sha256: Option<String>,
+}
+
struct PreparedRemoteHerdr {
remote_herdr: RemoteHerdr,
installed_or_replaced: bool,
@@ -448,8 +501,9 @@ fn prepare_remote_herdr(
if !remote_binary_matches(target, &remote_herdr)? {
return Err(io::Error::other(format!(
- "installed remote herdr at {}, but it did not report version {CURRENT_VERSION}",
- remote_herdr.shell_path
+ "installed remote herdr at {}, but it did not report version {}",
+ remote_herdr.shell_path,
+ current_version()
)));
}
warn_if_remote_bin_not_on_path(target)?;
@@ -519,7 +573,7 @@ fn remote_binary_matches(target: &str, remote_herdr: &RemoteHerdr) -> io::Result
let mut lines = stdout.lines();
let version = lines.next().unwrap_or_default().trim();
let status = lines.next().unwrap_or_default();
- Ok(version == format!("herdr {CURRENT_VERSION}")
+ Ok(version == format!("herdr {}", current_version())
&& parse_client_status_json(status)
.map(|status| status.protocol == CURRENT_PROTOCOL)
.unwrap_or(false))
@@ -585,7 +639,9 @@ fn install_source_description_for(
"the current local herdr binary".to_string()
} else {
format!(
- "the {CURRENT_VERSION} release asset for {}",
+ "the {} {} asset for {}",
+ current_version(),
+ current_channel(),
platform.asset_key()
)
}
@@ -691,7 +747,7 @@ fn remote_server_restart_reason(
if remote_binary_changed {
return Some(RemoteServerRestartReason::BinaryUpdated);
}
- if version != Some(CURRENT_VERSION) {
+ if version != Some(current_version().as_str()) {
return Some(RemoteServerRestartReason::VersionMismatch);
}
None
@@ -750,7 +806,8 @@ fn confirm_remote_install_with_running_server(
eprintln!("remote herdr server on {target} is currently running:");
eprintln!(" server: v{}", version_label(version.as_deref()));
eprintln!(
- "Herdr will install v{CURRENT_VERSION} and hand off live pane processes to the prepared server."
+ "Herdr will install {} and hand off live pane processes to the prepared server.",
+ current_version()
);
return Ok(false);
}
@@ -762,7 +819,10 @@ fn confirm_remote_install_with_running_server(
);
eprintln!("This stops active remote pane processes, including shells, dev servers, and tests.");
eprintln!();
- eprint!("Install v{CURRENT_VERSION} and stop the remote server now? [y/N] ");
+ eprint!(
+ "Install {} and stop the remote server now? [y/N] ",
+ current_version()
+ );
io::stderr().flush()?;
let mut answer = String::new();
@@ -847,15 +907,16 @@ fn confirm_remote_server_stop(
}
eprintln!(
- "remote herdr server on {target} is still running v{}; it will use v{CURRENT_VERSION} after it restarts.",
- version_label(version)
+ "remote herdr server on {target} is still running v{}; it will use {} after it restarts.",
+ version_label(version),
+ current_version()
);
return Ok(false);
}
eprintln!("remote herdr server on {target} is currently running:");
eprintln!(" server: v{}", version_label(version));
- eprintln!(" prepared binary: v{CURRENT_VERSION}");
+ eprintln!(" prepared binary: {}", current_version());
eprintln!();
match reason {
@@ -903,9 +964,11 @@ fn confirm_remote_server_stop(
fn live_handoff_remote_server(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> {
let command = format!(
- "{} server live-handoff --import-exe {} --expected-protocol {CURRENT_PROTOCOL} --expected-version {CURRENT_VERSION}",
+ "{} server live-handoff --import-exe {} --expected-protocol {} --expected-version {}",
remote_herdr.shell_path,
- remote_herdr.shell_path
+ remote_herdr.shell_path,
+ CURRENT_PROTOCOL,
+ current_version()
);
let output = ssh_sh_output(target, &command)?;
if !output.status.success() {
@@ -976,7 +1039,36 @@ fn remote_shell_resolves_managed_install(stdout: &str) -> bool {
}
fn download_release_asset(platform: &RemotePlatform) -> io::Result<InstallSource> {
- let manifest_output = Command::new("curl")
+ let asset_key = platform.asset_key();
+ let asset = remote_release_asset(&asset_key)?;
+
+ let dir = private_download_dir(&asset_key)?;
+ let path = dir.join("herdr.tmp");
+ let status = Command::new("curl")
+ .args(["-sfL", "--max-time", "120", "-o"])
+ .arg(&path)
+ .arg(&asset.url)
+ .status()
+ .map_err(|err| io::Error::new(err.kind(), format!("download failed: {err}")))?;
+ if !status.success() {
+ let _ = fs::remove_dir_all(&dir);
+ return Err(io::Error::other("download failed"));
+ }
+ if let Some(expected) = &asset.sha256 {
+ if let Err(err) = crate::checksum::verify_sha256(&path, expected) {
+ let _ = fs::remove_dir_all(&dir);
+ return Err(io::Error::new(
+ err.kind(),
+ format!("downloaded remote asset checksum verification failed: {err}"),
+ ));
+ }
+ }
+
+ Ok(InstallSource::temporary(path, dir))
+}
+
+fn fetch_remote_manifest(url: &str) -> io::Result<Vec<u8>> {
+ let output = Command::new("curl")
.args([
"-sfL",
"--retry",
@@ -985,54 +1077,87 @@ fn download_release_asset(platform: &RemotePlatform) -> io::Result<InstallSource
"10",
"--max-time",
"20",
- UPDATE_MANIFEST_URL,
+ url,
])
.output()
.map_err(|err| io::Error::new(err.kind(), format!("curl failed: {err}")))?;
- if !manifest_output.status.success() {
- return Err(command_failed(
- "failed to fetch update manifest",
- &manifest_output,
- ));
+ if !output.status.success() {
+ return Err(command_failed("failed to fetch update manifest", &output));
}
+ Ok(output.stdout)
+}
- let manifest: RemoteUpdateManifest = serde_json::from_slice(&manifest_output.stdout)
- .map_err(|err| io::Error::other(format!("failed to parse update manifest JSON: {err}")))?;
+fn remote_asset_info(asset: &RemoteAssetRef) -> RemoteReleaseAsset {
+ RemoteReleaseAsset {
+ url: asset.url().to_string(),
+ sha256: asset.sha256().map(str::to_string),
+ }
+}
- let asset_key = platform.asset_key();
- let release = manifest.release_for_version(CURRENT_VERSION).ok_or_else(|| {
+fn preview_assets_for_build<'a>(
+ manifest: &'a RemotePreviewManifest,
+ build_id: &str,
+) -> io::Result<(u32, &'a BTreeMap<String, RemoteAssetRef>)> {
+ if manifest.build_id == build_id {
+ return Ok((manifest.protocol, &manifest.assets));
+ }
+ let build = manifest.builds.get(build_id).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()
+ "preview manifest no longer includes build {build_id}; run `herdr update` locally or set {REMOTE_BINARY_ENV_VAR}=target/release/herdr"
))
})?;
- if let Some(protocol) = release.protocol {
+ Ok((build.protocol, &build.assets))
+}
+
+fn remote_release_asset(asset_key: &str) -> io::Result<RemoteReleaseAsset> {
+ if crate::build_info::is_preview() {
+ let build_id = crate::build_info::build_id().ok_or_else(|| {
+ io::Error::other("preview client has no build id; set HERDR_REMOTE_BINARY or install Herdr on the remote manually")
+ })?;
+ let manifest_bytes = fetch_remote_manifest(PREVIEW_UPDATE_MANIFEST_URL)?;
+ let manifest: RemotePreviewManifest =
+ serde_json::from_slice(&manifest_bytes).map_err(|err| {
+ io::Error::other(format!("failed to parse preview manifest JSON: {err}"))
+ })?;
+ let (protocol, assets) = preview_assets_for_build(&manifest, build_id)?;
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"
+ "preview manifest has build {build_id} 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"
)));
}
+ return assets.get(asset_key).map(remote_asset_info).ok_or_else(|| {
+ io::Error::other(format!(
+ "no {asset_key} binary in the preview manifest for build {build_id}"
+ ))
+ });
}
- let url = release.assets.get(&asset_key).ok_or_else(|| {
+
+ let current_version = current_version();
+ let manifest_bytes = fetch_remote_manifest(STABLE_UPDATE_MANIFEST_URL)?;
+ let manifest: RemoteUpdateManifest = serde_json::from_slice(&manifest_bytes)
+ .map_err(|err| io::Error::other(format!("failed to parse update manifest JSON: {err}")))?;
+ let release = manifest.release_for_version(&current_version).ok_or_else(|| {
io::Error::other(format!(
- "no {asset_key} binary in the release manifest for herdr {CURRENT_VERSION}"
+ "release manifest does not include herdr {current_version}; build herdr for {} or install it there manually",
+ asset_key
))
})?;
-
- let dir = private_download_dir(&asset_key)?;
- let path = dir.join("herdr.tmp");
- let status = Command::new("curl")
- .args(["-sfL", "--max-time", "120", "-o"])
- .arg(&path)
- .arg(url)
- .status()
- .map_err(|err| io::Error::new(err.kind(), format!("download failed: {err}")))?;
- if !status.success() {
- let _ = fs::remove_dir_all(&dir);
- return Err(io::Error::other("download failed"));
+ 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"
+ )));
+ }
}
-
- Ok(InstallSource::temporary(path, dir))
+ release
+ .assets
+ .get(asset_key)
+ .map(remote_asset_info)
+ .ok_or_else(|| {
+ io::Error::other(format!(
+ "no {asset_key} binary in the release manifest for herdr {current_version}"
+ ))
+ })
}
fn private_download_dir(asset_key: &str) -> io::Result<PathBuf> {
@@ -1063,13 +1188,15 @@ fn confirm_remote_install(
) -> io::Result<()> {
if !io::stdin().is_terminal() {
return Err(io::Error::other(format!(
- "matching remote herdr {CURRENT_VERSION} is not installed at {}; run from an interactive terminal to approve installation",
+ "matching remote herdr {} is not installed at {}; run from an interactive terminal to approve installation",
+ current_version(),
remote_herdr.shell_path
)));
}
eprintln!(
- "matching herdr {CURRENT_VERSION} is not installed on {target} for {}.",
+ "matching herdr {} is not installed on {target} for {}.",
+ current_version(),
remote_herdr.platform.asset_key()
);
eprint!(
@@ -2029,7 +2156,7 @@ mod tests {
manifest
.release_for_version("1.2.3")
.and_then(|release| release.assets.get("linux-x86_64"))
- .map(String::as_str),
+ .map(RemoteAssetRef::url),
Some("https://example.com/latest")
);
}
@@ -2058,7 +2185,7 @@ mod tests {
manifest
.release_for_version("1.2.3")
.and_then(|release| release.assets.get("linux-x86_64"))
- .map(String::as_str),
+ .map(RemoteAssetRef::url),
Some("https://example.com/archive")
);
}
@@ -2123,9 +2250,44 @@ mod tests {
}
#[test]
+ fn remote_preview_manifest_falls_back_to_archived_exact_build_assets() {
+ let manifest: RemotePreviewManifest = serde_json::from_str(
+ r#"{
+ "build_id": "2026-06-06-new",
+ "protocol": 12,
+ "assets": {
+ "linux-x86_64": {
+ "url": "https://example.com/new",
+ "sha256": "new"
+ }
+ },
+ "builds": {
+ "2026-06-02-old": {
+ "protocol": 11,
+ "assets": {
+ "linux-x86_64": {
+ "url": "https://example.com/old",
+ "sha256": "old"
+ }
+ }
+ }
+ }
+ }"#,
+ )
+ .unwrap();
+
+ let (protocol, assets) =
+ preview_assets_for_build(&manifest, "2026-06-02-old").expect("archived build");
+ let asset = assets.get("linux-x86_64").expect("asset");
+ assert_eq!(protocol, 11);
+ assert_eq!(asset.url(), "https://example.com/old");
+ assert_eq!(asset.sha256(), Some("old"));
+ }
+
+ #[test]
fn remote_server_restart_reason_requires_stop_for_protocol_mismatch() {
assert_eq!(
- remote_server_restart_reason(Some(CURRENT_VERSION), Some(0), false),
+ remote_server_restart_reason(Some(&current_version()), Some(0), false),
Some(RemoteServerRestartReason::ProtocolMismatch)
);
}
@@ -2133,7 +2295,7 @@ mod tests {
#[test]
fn remote_server_restart_reason_offers_restart_after_binary_update() {
assert_eq!(
- remote_server_restart_reason(Some(CURRENT_VERSION), Some(CURRENT_PROTOCOL), true),
+ remote_server_restart_reason(Some(&current_version()), Some(CURRENT_PROTOCOL), true),
Some(RemoteServerRestartReason::BinaryUpdated)
);
}
@@ -2153,7 +2315,7 @@ mod tests {
#[test]
fn remote_server_restart_reason_allows_current_server() {
assert_eq!(
- remote_server_restart_reason(Some(CURRENT_VERSION), Some(CURRENT_PROTOCOL), false),
+ remote_server_restart_reason(Some(&current_version()), Some(CURRENT_PROTOCOL), false),
None
);
}
@@ -2187,7 +2349,9 @@ mod tests {
assert_eq!(
install_source_description_for(&platform, None, false),
format!(
- "the {CURRENT_VERSION} release asset for {}",
+ "the {} {} asset for {}",
+ current_version(),
+ current_channel(),
platform.asset_key()
)
);