diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-02 21:48:02 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-02 21:48:02 +0300 |
| commit | b751d102f158968aab681cb663d27b4c32197f62 (patch) | |
| tree | 85d03609314559df7705c2cb0ab4e37ab6d9715f /src/update.rs | |
| parent | 295b09ca25edbccac65401ee84fa420dd3888aad (diff) | |
feat: add preview update channel
Diffstat (limited to 'src/update.rs')
| -rw-r--r-- | src/update.rs | 419 |
1 files changed, 371 insertions, 48 deletions
diff --git a/src/update.rs b/src/update.rs index d05365d1..58095c08 100644 --- a/src/update.rs +++ b/src/update.rs @@ -17,12 +17,12 @@ use std::time::{Duration, Instant}; use serde::{Deserialize, Deserializer}; -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 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 NIX_UPDATE_COMMAND: &str = "update through Nix"; -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"; const DEFAULT_FAKE_UPDATE_NOTES_VERSION: &str = "0.3.0"; @@ -73,7 +73,7 @@ impl Version { } pub fn current() -> Self { - Self::parse(CURRENT_VERSION).expect("invalid CARGO_PKG_VERSION") + Self::parse(crate::build_info::BASE_VERSION).expect("invalid CARGO_PKG_VERSION") } } @@ -87,13 +87,75 @@ impl std::fmt::Display for Version { // Update manifest // --------------------------------------------------------------------------- +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum UpdateChannel { + Stable, + Preview, +} + +impl UpdateChannel { + fn configured() -> Self { + match crate::config::Config::load().config.update.channel { + crate::config::UpdateChannelConfig::Stable => Self::Stable, + crate::config::UpdateChannelConfig::Preview => Self::Preview, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Stable => "stable", + Self::Preview => "preview", + } + } +} + +#[derive(Debug, Clone)] +struct AssetRef { + url: String, + sha256: Option<String>, +} + +impl<'de> Deserialize<'de> for AssetRef { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + match value { + serde_json::Value::String(url) if !url.trim().is_empty() => Ok(Self { + url: url.trim().to_string(), + sha256: None, + }), + serde_json::Value::Object(mut object) => { + let url = object + .remove("url") + .and_then(|value| value.as_str().map(str::to_string)) + .ok_or_else(|| serde::de::Error::custom("asset object is missing url"))?; + let sha256 = object + .remove("sha256") + .and_then(|value| value.as_str().map(str::to_string)); + if url.trim().is_empty() { + return Err(serde::de::Error::custom("asset url must not be empty")); + } + Ok(Self { + url: url.trim().to_string(), + sha256: sha256.filter(|value| !value.trim().is_empty()), + }) + } + _ => Err(serde::de::Error::custom( + "asset must be a URL string or object with url", + )), + } + } +} + #[derive(Deserialize)] struct UpdateManifest { version: String, /// Thin-client protocol spoken by this release, when advertised by the manifest. protocol: Option<u32>, notes: String, - assets: BTreeMap<String, String>, + assets: BTreeMap<String, AssetRef>, announcement: Option<serde_json::Value>, #[serde(default, deserialize_with = "deserialize_manifest_releases")] releases: BTreeMap<String, serde_json::Value>, @@ -119,6 +181,29 @@ struct ManifestReleaseMetadata { } #[derive(Deserialize)] +struct PreviewManifest { + channel: String, + base_version: String, + build_id: String, + commit: String, + built_at: String, + protocol: u32, + notes: String, + assets: BTreeMap<String, AssetRef>, + #[serde(default)] + builds: BTreeMap<String, PreviewBuildMetadata>, +} + +#[derive(Deserialize)] +struct PreviewBuildMetadata { + base_version: String, + commit: String, + built_at: String, + protocol: u32, + assets: BTreeMap<String, AssetRef>, +} + +#[derive(Deserialize)] struct HomebrewFormula { versions: HomebrewFormulaVersions, } @@ -129,8 +214,11 @@ struct HomebrewFormulaVersions { } impl UpdateManifest { + #[cfg(test)] fn download_url_for(&self, os: &str, arch: &str) -> Option<String> { - self.assets.get(&format!("{os}-{arch}")).cloned() + self.assets + .get(&format!("{os}-{arch}")) + .map(|asset| asset.url.clone()) } fn metadata_for_version(&self, version: &Version) -> Option<ManifestReleaseMetadata> { @@ -164,12 +252,34 @@ impl ManifestReleaseMetadata { #[derive(Debug, Clone)] struct ReleaseInfo { version: Version, + identity: String, + channel: UpdateChannel, + build_id: Option<String>, + commit: Option<String>, target_protocol: Option<u32>, download_url: String, + sha256: Option<String>, notes_body: String, } +impl ReleaseInfo { + fn label(&self) -> &str { + &self.identity + } +} + fn fetch_update_manifest() -> Result<UpdateManifest, String> { + fetch_json_manifest(STABLE_UPDATE_MANIFEST_URL) +} + +fn fetch_preview_manifest() -> Result<PreviewManifest, String> { + fetch_json_manifest(PREVIEW_UPDATE_MANIFEST_URL) +} + +fn fetch_json_manifest<T>(url: &str) -> Result<T, String> +where + T: serde::de::DeserializeOwned, +{ let output = Command::new("curl") .args([ "-sfL", @@ -179,7 +289,7 @@ fn fetch_update_manifest() -> Result<UpdateManifest, String> { "10", "--max-time", "20", - UPDATE_MANIFEST_URL, + url, ]) .output() .map_err(|e| format!("curl failed: {e}"))?; @@ -219,7 +329,7 @@ fn release_info_from_manifest(manifest: &UpdateManifest) -> Result<Option<Releas let latest = Version::parse(&manifest.version) .ok_or_else(|| format!("invalid version in update manifest: {}", manifest.version))?; - if latest <= current { + if !stable_channel_should_install(&latest, ¤t, crate::build_info::is_preview()) { return Ok(None); // up to date } @@ -232,20 +342,117 @@ fn release_info_from_manifest(manifest: &UpdateManifest) -> Result<Option<Releas } let (os, arch) = platform_target(); - let download_url = manifest - .download_url_for(os, arch) - .ok_or_else(|| format!("no binary for {os}-{arch} in update manifest"))?; + let asset_key = format!("{os}-{arch}"); + let asset = manifest + .assets + .get(&asset_key) + .ok_or_else(|| format!("no binary for {asset_key} in update manifest"))?; + let download_url = asset.url.clone(); Ok(Some(ReleaseInfo { + identity: latest.to_string(), version: latest, + channel: UpdateChannel::Stable, + build_id: None, + commit: None, target_protocol: manifest.protocol, download_url, + sha256: asset.sha256.clone(), + notes_body, + })) +} + +fn stable_channel_should_install( + latest: &Version, + current: &Version, + installed_is_preview: bool, +) -> bool { + installed_is_preview || latest > current +} + +fn preview_display_version(base_version: &str, build_id: &str) -> String { + format!( + "{}-preview.{}", + base_version.trim_start_matches('v'), + build_id + ) +} + +fn release_info_from_preview_manifest( + manifest: &PreviewManifest, +) -> Result<Option<ReleaseInfo>, String> { + if manifest.channel != "preview" { + return Err(format!( + "invalid preview manifest channel: {}", + manifest.channel + )); + } + let build_id = manifest.build_id.trim(); + if build_id.is_empty() { + return Err("preview manifest build_id is empty".into()); + } + if crate::build_info::is_preview() + && crate::build_info::build_id().is_some_and(|current| current == build_id) + { + return Ok(None); + } + + let version = Version::parse(&manifest.base_version).ok_or_else(|| { + format!( + "invalid base_version in preview manifest: {}", + manifest.base_version + ) + })?; + let notes_body = manifest.notes.trim().to_string(); + if notes_body.is_empty() { + return Err("preview manifest notes are empty".into()); + } + let (os, arch) = platform_target(); + let asset_key = format!("{os}-{arch}"); + if let Some(archived) = manifest.builds.get(build_id) { + if archived.base_version != manifest.base_version + || archived.commit != manifest.commit + || archived.built_at != manifest.built_at + || archived.protocol != manifest.protocol + { + tracing::warn!( + build_id, + "preview manifest archived build metadata differs from top-level metadata" + ); + } + } + let asset = manifest + .assets + .get(&asset_key) + .or_else(|| { + manifest + .builds + .get(build_id) + .and_then(|build| build.assets.get(&asset_key)) + }) + .ok_or_else(|| format!("no binary for {asset_key} in preview manifest"))?; + let download_url = asset.url.clone(); + + Ok(Some(ReleaseInfo { + identity: preview_display_version(&manifest.base_version, build_id), + version, + channel: UpdateChannel::Preview, + build_id: Some(build_id.to_string()), + commit: Some(manifest.commit.clone()), + target_protocol: Some(manifest.protocol), + download_url, + sha256: asset.sha256.clone(), notes_body, })) } /// Check the hosted update manifest for the latest release. Returns release info if newer. fn check_latest() -> Result<Option<ReleaseInfo>, String> { + let channel = UpdateChannel::configured(); + if channel == UpdateChannel::Preview { + return release_info_from_preview_manifest(&fetch_preview_manifest()?); + } + let manifest = fetch_update_manifest()?; let release = release_info_from_manifest(&manifest)?; if let Some(release) = &release { @@ -357,6 +564,16 @@ fn download_update(release: &ReleaseInfo) -> Result<DownloadedUpdate, String> { return Err("download failed".into()); } + if let Some(expected) = &release.sha256 { + if let Err(e) = crate::checksum::verify_sha256(&tmp_path, expected) { + let _ = fs::remove_file(&tmp_path); + return Err(format!( + "downloaded update checksum verification failed: {e}" + )); + } + tracing::info!(sha256 = %expected, "downloaded update checksum verified"); + } + // Make executable #[cfg(unix)] { @@ -703,8 +920,8 @@ fn prompt_to_stop_old_servers_before_update( } eprintln!( - "Running sessions that must stop to use v{}:", - release.version + "Running sessions that must stop to use {}:", + release.label() ); for plan in plans { eprintln!( @@ -838,8 +1055,9 @@ fn prompt_to_complete_plain_update( loop { eprint!( - "Stop running {} and install v{} now? [y/N] ", - noun, release.version + "Stop running {} and install {} now? [y/N] ", + noun, + release.label() ); io::stderr() .flush() @@ -899,7 +1117,7 @@ fn print_running_session_update_summary( ); } } - eprintln!(" update: v{}", release.version); + eprintln!(" update: {}", release.label()); eprintln!(); } @@ -927,7 +1145,7 @@ fn runtime_matches_release(status: &crate::api::RuntimeStatus, release: &Release let protocol_matches = release .target_protocol .is_none_or(|protocol| status.protocol == Some(protocol)); - let version_matches = status.version.as_deref() == Some(&release.version.to_string()); + let version_matches = status.version.as_deref() == Some(release.label()); protocol_matches && version_matches } @@ -956,10 +1174,10 @@ fn prompt_to_stop_old_server_after_failed_handoff( plan.label() ); eprintln!(" server: v{}", version_label(status.version.as_deref())); - eprintln!(" installed: v{}", release.version); + eprintln!(" installed: {}", release.label()); eprintln!( - "you can keep using the old server, or stop it now so the next `herdr` start uses v{}.", - release.version + "you can keep using the old server, or stop it now so the next `herdr` start uses {}.", + release.label() ); eprintln!("stopping the old server will exit its pane processes."); @@ -1024,15 +1242,15 @@ fn recover_failed_live_handoff_for_update( FailedHandoffServerState::NoServerResponding => { if let Some(command) = plan.attach_command() { eprintln!( - "no herdr server is responding for session {}. the binary was updated; run `{command}` to start v{}.", + "no herdr server is responding for session {}. the binary was updated; run `{command}` to start {}.", plan.label(), - release.version + release.label() ); } else { eprintln!( - "no herdr server is responding at {}. the binary was updated; restart with the same socket override to use v{}.", + "no herdr server is responding at {}. the binary was updated; restart with the same socket override to use {}.", plan.socket_path().display(), - release.version + release.label() ); } Ok(RunningServerUpdateOutcome::FailedHandoffNoServer) @@ -1154,7 +1372,7 @@ fn live_handoff_server_via_api_for_release_at( let params = ServerLiveHandoffParams { import_exe: Some(updated_exe.display().to_string()), expected_protocol: release.target_protocol, - expected_version: Some(release.version.to_string()), + expected_version: Some(release.label().to_string()), }; send_server_update_method_at( @@ -1236,7 +1454,7 @@ fn wait_for_server_handoff_at( socket_path, timeout, release.target_protocol, - Some(&release.version.to_string()), + Some(release.label()), ) } @@ -1341,12 +1559,14 @@ fn print_running_session_update_outcomes( eprintln!("Stopping exits active pane processes."); match &outcome.attach_command { Some(command) => eprintln!( - "Run `{}`, then run `{command}` when ready to use v{}.", - outcome.stop_command, release.version + "Run `{}`, then run `{command}` when ready to use {}.", + outcome.stop_command, + release.label() ), None => eprintln!( - "Run `{}`, then restart Herdr with the same socket override when ready to use v{}.", - outcome.stop_command, release.version + "Run `{}`, then restart Herdr with the same socket override when ready to use {}.", + outcome.stop_command, + release.label() ), } } @@ -1499,13 +1719,24 @@ fn homebrew_cellar_keg_root(path: &Path) -> Option<PathBuf> { /// Manual self-update command (`herdr update`). pub fn self_update(options: SelfUpdateOptions) -> Result<Version, String> { + let channel = UpdateChannel::configured(); if is_homebrew_managed_install() { + if channel == UpdateChannel::Preview { + return Err( + "self-update is disabled for Homebrew installs; preview is only available for direct Herdr installs".into(), + ); + } return Err(format!( "self-update is disabled for Homebrew installs; run `{HOMEBREW_UPDATE_COMMAND}`" )); } if is_nix_managed_install() { + if channel == UpdateChannel::Preview { + return Err( + "self-update is disabled for Nix installs; preview is only available for direct Herdr installs".into(), + ); + } return Err( "self-update is disabled for Nix installs; update with `nix profile upgrade` or update the flake input that provides Herdr".into(), ); @@ -1515,14 +1746,14 @@ pub fn self_update(options: SelfUpdateOptions) -> Result<Version, String> { return Err("run `herdr update` outside herdr after detaching from the session".into()); } - eprintln!("checking for updates..."); + eprintln!("checking {} channel for updates...", channel.as_str()); let current = Version::current(); let release = match check_latest()? { Some(r) => r, None => { - eprintln!("already up to date (v{current})"); + eprintln!("already up to date ({})", crate::build_info::version()); return Ok(current); } }; @@ -1531,15 +1762,16 @@ pub fn self_update(options: SelfUpdateOptions) -> Result<Version, String> { let server_update_decisions = confirm_running_server_update_action(running_server_plans, &release, options)?; - eprintln!("downloading v{}...", release.version); - if let Err(e) = - crate::release_notes::save_pending(&release.version.to_string(), &release.notes_body) - { + if let Some(commit) = &release.commit { + tracing::info!(commit = %commit, build_id = ?release.build_id, "selected preview update build"); + } + eprintln!("downloading {}...", release.label()); + if let Err(e) = crate::release_notes::save_pending(release.label(), &release.notes_body) { tracing::warn!("failed to save pending release notes: {e}"); } let downloaded_update = download_update(&release)?; let updated_exe = downloaded_update.current_exe.clone(); - eprintln!("downloaded v{}", release.version); + eprintln!("downloaded {}", release.label()); if !options.live_handoff && !prompt_to_complete_plain_update(&server_update_decisions, &release)? { @@ -1548,7 +1780,7 @@ pub fn self_update(options: SelfUpdateOptions) -> Result<Version, String> { return Ok(current); } install_downloaded_update(downloaded_update)?; - eprintln!("installed v{}", release.version); + eprintln!("installed {}", release.label()); let server_update_decisions = if options.live_handoff { server_update_decisions } else { @@ -1598,12 +1830,23 @@ pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) { return; } + let configured_channel = UpdateChannel::configured(); if is_homebrew_managed_install() { + if configured_channel == UpdateChannel::Preview { + crate::logging::update_check_failed( + "preview channel is not available for Homebrew installs", + ); + return; + } auto_update_homebrew(events); return; } let nix_managed_install = is_nix_managed_install(); + if nix_managed_install && configured_channel == UpdateChannel::Preview { + crate::logging::update_check_failed("preview channel is not available for Nix installs"); + return; + } let release = match check_latest() { Ok(Some(r)) => r, @@ -1614,22 +1857,20 @@ pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) { } }; - crate::logging::update_available(&release.version.to_string()); + crate::logging::update_available(release.label()); tracing::info!( - "new version v{} available at {}", - release.version, + "new {} build available at {}", + release.channel.as_str(), release.download_url ); - if let Err(e) = - crate::release_notes::save_pending(&release.version.to_string(), &release.notes_body) - { + if let Err(e) = crate::release_notes::save_pending(release.label(), &release.notes_body) { tracing::warn!("failed to save pending release notes: {e}"); } tracing::info!( - "auto-update check: v{} available, waiting for explicit install", - release.version + "auto-update check: {} available, waiting for explicit install", + release.label() ); // Notify the TUI — blocking_send is safe from a std::thread @@ -1640,7 +1881,7 @@ pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) { }; let _ = events.blocking_send(crate::events::AppEvent::UpdateReady { - version: release.version.to_string(), + version: release.label().to_string(), install_command: install_command.to_string(), }); } @@ -1792,8 +2033,13 @@ mod tests { fn fake_release(version: &str, target_protocol: Option<u32>) -> ReleaseInfo { ReleaseInfo { version: Version::parse(version).unwrap(), + identity: version.to_string(), + channel: UpdateChannel::Stable, + build_id: None, + commit: None, target_protocol, download_url: "https://example.com/herdr".to_string(), + sha256: None, notes_body: "### Changed\n- One".to_string(), } } @@ -2071,8 +2317,13 @@ mod tests { }; let compatible_release = ReleaseInfo { version: Version::parse("0.5.6").unwrap(), + identity: "0.5.6".to_string(), + channel: UpdateChannel::Stable, + build_id: None, + commit: None, target_protocol: Some(2), download_url: "https://example.com/herdr".to_string(), + sha256: None, notes_body: "### Changed\n- One".to_string(), }; let incompatible_release = ReleaseInfo { @@ -2305,8 +2556,13 @@ mod tests { }; let release = ReleaseInfo { version: Version::parse("0.5.6").unwrap(), + identity: "0.5.6".to_string(), + channel: UpdateChannel::Stable, + build_id: None, + commit: None, target_protocol: Some(3), download_url: "https://example.com/herdr".to_string(), + sha256: None, notes_body: "### Changed\n- One".to_string(), }; let plan = RunningServerUpdatePlan { @@ -2435,8 +2691,13 @@ mod tests { }); let release = ReleaseInfo { version: Version::parse("9.8.7").unwrap(), + identity: "9.8.7".to_string(), + channel: UpdateChannel::Stable, + build_id: None, + commit: None, target_protocol: Some(77), download_url: "https://example.com/herdr".to_string(), + sha256: None, notes_body: "### Changed\n- One".to_string(), }; @@ -2722,6 +2983,67 @@ mod tests { } #[test] + fn stable_channel_installs_stable_asset_when_current_binary_is_preview() { + let older_stable = Version::parse("0.6.5").unwrap(); + let preview_base = Version::parse("0.6.6").unwrap(); + assert!(stable_channel_should_install( + &older_stable, + &preview_base, + true + )); + assert!(!stable_channel_should_install( + &older_stable, + &preview_base, + false + )); + } + + #[test] + fn preview_manifest_reports_update_when_build_id_differs() { + let manifest: PreviewManifest = serde_json::from_str( + r####"{ + "channel": "preview", + "base_version": "9.9.9", + "build_id": "2026-06-02-abcdef123456", + "commit": "abcdef1234567890", + "built_at": "2026-06-02T03:00:00Z", + "protocol": 77, + "notes": "### Fixed\n- One", + "assets": { + "linux-x86_64": { + "url": "https://example.com/herdr-linux-x86_64", + "sha256": "deadbeef" + } + }, + "builds": { + "2026-06-02-abcdef123456": { + "base_version": "9.9.9", + "commit": "abcdef1234567890", + "built_at": "2026-06-02T03:00:00Z", + "protocol": 77, + "assets": { + "linux_x86_64": { + "url": "https://example.com/herdr-linux_x86_64", + "sha256": "deadbeef" + } + } + } + } + }"####, + ) + .unwrap(); + + let release = release_info_from_preview_manifest(&manifest) + .unwrap() + .expect("preview update"); + + assert_eq!(release.channel, UpdateChannel::Preview); + assert_eq!(release.identity, "9.9.9-preview.2026-06-02-abcdef123456"); + assert_eq!(release.target_protocol, Some(77)); + assert_eq!(release.sha256.as_deref(), Some("deadbeef")); + } + + #[test] fn checked_in_website_manifest_matches_update_schema() { let manifest: UpdateManifest = serde_json::from_str(include_str!("../website/latest.json")) .expect("website/latest.json should match updater schema"); @@ -2744,10 +3066,11 @@ mod tests { "macos-x86_64", "macos-aarch64", ] { - let url = manifest + let url = &manifest .assets .get(target) - .unwrap_or_else(|| panic!("missing asset URL for {target}")); + .unwrap_or_else(|| panic!("missing asset URL for {target}")) + .url; assert!( url.contains(&format!("/releases/download/v{}/", manifest.version)), "unexpected release URL for {target}: {url}" |
