mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
1016 lines
33 KiB
Rust
1016 lines
33 KiB
Rust
//! Self-update mechanism.
|
|
//!
|
|
//! Checks the hosted herdr.dev update manifest for newer versions.
|
|
//! Manual `herdr update` downloads and installs the binary.
|
|
//! Background checks only surface availability and release notes.
|
|
//! 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::io::{self, BufRead, BufReader, IsTerminal, Write};
|
|
use std::os::unix::net::UnixStream;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use serde::Deserialize;
|
|
|
|
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";
|
|
const FAKE_UPDATE_NOTES_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_NOTES_VERSION";
|
|
const DEFAULT_FAKE_UPDATE_NOTES_VERSION: &str = "0.3.0";
|
|
const SERVER_STOP_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5);
|
|
const SERVER_SHUTDOWN_CONFIRM_TIMEOUT: Duration = Duration::from_secs(5);
|
|
const SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
|
|
|
fn fake_release_notes_body(version: &str) -> String {
|
|
let notes_version = env::var(FAKE_UPDATE_NOTES_VERSION_ENV)
|
|
.ok()
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or_else(|| DEFAULT_FAKE_UPDATE_NOTES_VERSION.to_string());
|
|
|
|
if let Some(notes) = crate::release_notes::load_preview_from_local_changelog(¬es_version) {
|
|
return notes.body;
|
|
}
|
|
|
|
format!(
|
|
"### Changed\n- Test update v{version} for local UI validation.\n\n### Notes\n- This is stub release-notes content generated by {FAKE_UPDATE_VERSION_ENV}."
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Version
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Parsed semver version for comparison.
|
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
|
pub struct Version {
|
|
pub major: u32,
|
|
pub minor: u32,
|
|
pub patch: u32,
|
|
}
|
|
|
|
impl Version {
|
|
pub fn parse(s: &str) -> Option<Self> {
|
|
let s = s.strip_prefix('v').unwrap_or(s);
|
|
let parts: Vec<&str> = s.split('.').collect();
|
|
if parts.len() != 3 {
|
|
return None;
|
|
}
|
|
Some(Self {
|
|
major: parts[0].parse().ok()?,
|
|
minor: parts[1].parse().ok()?,
|
|
patch: parts[2].parse().ok()?,
|
|
})
|
|
}
|
|
|
|
pub fn current() -> Self {
|
|
Self::parse(CURRENT_VERSION).expect("invalid CARGO_PKG_VERSION")
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for Version {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Update manifest
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[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>,
|
|
}
|
|
|
|
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 {
|
|
self.notes.trim().to_string()
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Release info
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Information about an available update.
|
|
#[derive(Debug, Clone)]
|
|
struct ReleaseInfo {
|
|
version: Version,
|
|
target_protocol: Option<u32>,
|
|
download_url: String,
|
|
notes_body: String,
|
|
}
|
|
|
|
/// 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 output = Command::new("curl")
|
|
.args([
|
|
"-sfL",
|
|
"--retry",
|
|
"3",
|
|
"--connect-timeout",
|
|
"10",
|
|
"--max-time",
|
|
"20",
|
|
UPDATE_MANIFEST_URL,
|
|
])
|
|
.output()
|
|
.map_err(|e| format!("curl failed: {e}"))?;
|
|
|
|
if !output.status.success() {
|
|
return Err("failed to fetch update manifest".into());
|
|
}
|
|
|
|
let manifest: UpdateManifest = serde_json::from_slice(&output.stdout)
|
|
.map_err(|e| format!("failed to parse update manifest JSON: {e}"))?;
|
|
|
|
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
|
|
}
|
|
|
|
let notes_body = manifest.notes_body();
|
|
if notes_body.is_empty() {
|
|
return Err("update manifest notes are empty".into());
|
|
}
|
|
|
|
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"))?;
|
|
|
|
Ok(Some(ReleaseInfo {
|
|
version: latest,
|
|
target_protocol: manifest.protocol,
|
|
download_url,
|
|
notes_body,
|
|
}))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Download + install
|
|
// ---------------------------------------------------------------------------
|
|
|
|
struct DownloadedUpdate {
|
|
current_exe: PathBuf,
|
|
tmp_path: Option<PathBuf>,
|
|
}
|
|
|
|
impl Drop for DownloadedUpdate {
|
|
fn drop(&mut self) {
|
|
if let Some(tmp_path) = self.tmp_path.take() {
|
|
let _ = fs::remove_file(tmp_path);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Download a release to a prepared executable temp file without touching the running server.
|
|
fn download_update(release: &ReleaseInfo) -> Result<DownloadedUpdate, String> {
|
|
let current_exe = env::current_exe().map_err(|e| format!("can't find current binary: {e}"))?;
|
|
|
|
let parent = current_exe.parent().ok_or("can't find binary directory")?;
|
|
|
|
// Check write permissions early
|
|
let test_path = parent.join(".herdr-write-test");
|
|
if let Err(e) = fs::write(&test_path, b"") {
|
|
let _ = fs::remove_file(&test_path);
|
|
return Err(format!(
|
|
"install directory not writable: {} ({}). Try running with appropriate permissions.",
|
|
parent.display(),
|
|
e
|
|
));
|
|
}
|
|
let _ = fs::remove_file(&test_path);
|
|
|
|
// Unique temp file (avoids races with concurrent instances)
|
|
let tmp_path = parent.join(format!(".herdr-update-{}.tmp", std::process::id()));
|
|
|
|
// Download the exact asset URL (pinned to the release we checked)
|
|
let status = Command::new("curl")
|
|
.args(["-sfL", "--max-time", "120", "-o"])
|
|
.arg(&tmp_path)
|
|
.arg(&release.download_url)
|
|
.status()
|
|
.map_err(|e| format!("download failed: {e}"))?;
|
|
|
|
if !status.success() {
|
|
let _ = fs::remove_file(&tmp_path);
|
|
return Err("download failed".into());
|
|
}
|
|
|
|
// Make executable
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
if let Err(e) = fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o755)) {
|
|
let _ = fs::remove_file(&tmp_path);
|
|
return Err(format!("chmod failed: {e}"));
|
|
}
|
|
}
|
|
|
|
Ok(DownloadedUpdate {
|
|
current_exe,
|
|
tmp_path: Some(tmp_path),
|
|
})
|
|
}
|
|
|
|
fn install_downloaded_update(mut update: DownloadedUpdate) -> Result<(), String> {
|
|
let tmp_path = update
|
|
.tmp_path
|
|
.take()
|
|
.ok_or("downloaded update temp file is missing")?;
|
|
|
|
// Atomic replace — rename over the current binary.
|
|
// On Linux, the running process keeps its fd to the old inode.
|
|
// Next launch picks up the new file.
|
|
if let Err(e) = fs::rename(&tmp_path, &update.current_exe) {
|
|
let _ = fs::remove_file(&tmp_path);
|
|
return Err(format!("failed to replace binary: {e}"));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Upgrade flow helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn running_inside_herdr_env(herdr_env: Option<&str>) -> bool {
|
|
herdr_env == Some(crate::HERDR_ENV_VALUE)
|
|
}
|
|
|
|
fn running_inside_herdr() -> bool {
|
|
running_inside_herdr_env(env::var(crate::HERDR_ENV_VAR).ok().as_deref())
|
|
}
|
|
|
|
fn api_server_is_running_at(socket_path: &Path) -> bool {
|
|
if !socket_path.exists() {
|
|
return false;
|
|
}
|
|
|
|
UnixStream::connect(socket_path).is_ok()
|
|
}
|
|
|
|
fn api_server_is_running() -> bool {
|
|
api_server_is_running_at(&crate::api::socket_path())
|
|
}
|
|
|
|
fn client_protocol_server_is_running_at(socket_path: &Path) -> bool {
|
|
if !socket_path.exists() {
|
|
return false;
|
|
}
|
|
|
|
UnixStream::connect(socket_path).is_ok()
|
|
}
|
|
|
|
fn client_protocol_server_is_running() -> bool {
|
|
client_protocol_server_is_running_at(&crate::server::headless::client_socket_path())
|
|
}
|
|
|
|
fn read_running_server_info() -> Result<Option<crate::api::RuntimeStatus>, String> {
|
|
crate::api::read_runtime_status_at(&crate::api::socket_path(), SERVER_STOP_RESPONSE_TIMEOUT)
|
|
.map_err(|e| format!("failed to read running server status: {e}"))
|
|
}
|
|
|
|
fn protocol_label(protocol: Option<u32>) -> String {
|
|
protocol
|
|
.map(|value| value.to_string())
|
|
.unwrap_or_else(|| "unknown".to_string())
|
|
}
|
|
|
|
fn version_label(version: Option<&str>) -> &str {
|
|
version.unwrap_or("unknown")
|
|
}
|
|
|
|
fn update_requires_server_stop(server: &crate::api::RuntimeStatus, release: &ReleaseInfo) -> bool {
|
|
match (server.protocol, release.target_protocol) {
|
|
(Some(server_protocol), Some(target_protocol)) => server_protocol != target_protocol,
|
|
_ => true,
|
|
}
|
|
}
|
|
|
|
fn parse_stop_server_before_update_response(input: &str) -> Option<bool> {
|
|
let trimmed = input.trim().to_ascii_lowercase();
|
|
match trimmed.as_str() {
|
|
"" | "n" | "no" => Some(false),
|
|
"y" | "yes" => Some(true),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn prompt_to_stop_server_before_update(
|
|
server: &crate::api::RuntimeStatus,
|
|
release: &ReleaseInfo,
|
|
requires_stop: bool,
|
|
) -> Result<bool, String> {
|
|
if !io::stdin().is_terminal() {
|
|
if requires_stop {
|
|
return Err(format!(
|
|
"a herdr server is running and updating to v{} requires stopping it; run `herdr server stop`, then run `herdr update` again",
|
|
release.version
|
|
));
|
|
}
|
|
|
|
eprintln!(
|
|
"a herdr server is running. updating the binary will not affect that server until it restarts."
|
|
);
|
|
return Ok(false);
|
|
}
|
|
|
|
eprintln!("a herdr server is currently running:");
|
|
eprintln!(
|
|
" server: v{} protocol {}",
|
|
version_label(server.version.as_deref()),
|
|
protocol_label(server.protocol)
|
|
);
|
|
eprintln!(
|
|
" update: v{} protocol {}",
|
|
release.version,
|
|
protocol_label(release.target_protocol)
|
|
);
|
|
eprintln!();
|
|
|
|
if requires_stop {
|
|
eprintln!(
|
|
"this update changes the herdr client/server protocol. the running server must be stopped before the new client can attach."
|
|
);
|
|
eprintln!("stopping the server will end the current herdr session and its panes.");
|
|
} else {
|
|
eprintln!("updating the binary will not affect the running server until it restarts.");
|
|
}
|
|
|
|
loop {
|
|
let prompt = if requires_stop {
|
|
"stop the server and continue updating? [y/N] "
|
|
} else {
|
|
"stop the server before updating? [y/N] "
|
|
};
|
|
eprint!("{prompt}");
|
|
io::stderr()
|
|
.flush()
|
|
.map_err(|e| format!("failed to flush prompt: {e}"))?;
|
|
|
|
let mut input = String::new();
|
|
let read = io::stdin()
|
|
.read_line(&mut input)
|
|
.map_err(|e| format!("failed to read prompt response: {e}"))?;
|
|
if read == 0 {
|
|
return Ok(false);
|
|
}
|
|
|
|
if let Some(answer) = parse_stop_server_before_update_response(&input) {
|
|
return Ok(answer);
|
|
}
|
|
|
|
eprintln!("please answer y or n");
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct RunningServerUpdatePlan {
|
|
server: crate::api::RuntimeStatus,
|
|
requires_stop: bool,
|
|
}
|
|
|
|
fn plan_running_server_update(
|
|
release: &ReleaseInfo,
|
|
) -> Result<Option<RunningServerUpdatePlan>, String> {
|
|
let Some(server) = read_running_server_info()? else {
|
|
if client_protocol_server_is_running() {
|
|
return Err(
|
|
"a herdr server is listening, but its status API is unavailable; try `herdr server stop`, or stop the old server process manually, then run `herdr update` again"
|
|
.to_string(),
|
|
);
|
|
}
|
|
return Ok(None);
|
|
};
|
|
|
|
let requires_stop = update_requires_server_stop(&server, release);
|
|
Ok(Some(RunningServerUpdatePlan {
|
|
server,
|
|
requires_stop,
|
|
}))
|
|
}
|
|
|
|
fn stop_running_server_for_update(
|
|
plan: Option<&RunningServerUpdatePlan>,
|
|
release: &ReleaseInfo,
|
|
) -> Result<bool, String> {
|
|
let Some(plan) = plan else {
|
|
return Ok(false);
|
|
};
|
|
|
|
let stop_server =
|
|
prompt_to_stop_server_before_update(&plan.server, release, plan.requires_stop)?;
|
|
if !stop_server {
|
|
if plan.requires_stop {
|
|
return Err(
|
|
"update cancelled; stop the running herdr server with `herdr server stop`, then run `herdr update` again"
|
|
.to_string(),
|
|
);
|
|
}
|
|
return Ok(false);
|
|
}
|
|
|
|
stop_server_via_api()?;
|
|
wait_for_server_shutdown(SERVER_SHUTDOWN_CONFIRM_TIMEOUT)?;
|
|
eprintln!("stopped the running herdr server.");
|
|
Ok(true)
|
|
}
|
|
|
|
fn stop_server_via_api_at(socket_path: &Path, timeout: Duration) -> Result<(), String> {
|
|
use crate::api::schema::{EmptyParams, Method, Request};
|
|
|
|
let request = Request {
|
|
id: "update:server:stop".into(),
|
|
method: Method::ServerStop(EmptyParams::default()),
|
|
};
|
|
|
|
let mut stream = UnixStream::connect(socket_path)
|
|
.map_err(|e| format!("failed to connect to running server: {e}"))?;
|
|
stream
|
|
.set_write_timeout(Some(timeout))
|
|
.map_err(|e| format!("failed to set server stop write timeout: {e}"))?;
|
|
stream
|
|
.set_read_timeout(Some(timeout))
|
|
.map_err(|e| format!("failed to set server stop read timeout: {e}"))?;
|
|
stream
|
|
.write_all(
|
|
serde_json::to_string(&request)
|
|
.map_err(|e| e.to_string())?
|
|
.as_bytes(),
|
|
)
|
|
.map_err(|e| format!("failed to send server stop request: {e}"))?;
|
|
stream
|
|
.write_all(b"\n")
|
|
.map_err(|e| format!("failed to finish server stop request: {e}"))?;
|
|
stream
|
|
.flush()
|
|
.map_err(|e| format!("failed to flush server stop request: {e}"))?;
|
|
|
|
let mut reader = BufReader::new(stream);
|
|
let mut line = String::new();
|
|
let read = reader
|
|
.read_line(&mut line)
|
|
.map_err(|e| format!("failed to read server stop response: {e}"))?;
|
|
if read == 0 || line.trim().is_empty() {
|
|
return Err("empty server stop response".into());
|
|
}
|
|
let response: serde_json::Value =
|
|
serde_json::from_str(&line).map_err(|e| format!("invalid server response: {e}"))?;
|
|
if let Some(error) = response.get("error") {
|
|
return Err(format!("server stop failed: {error}"));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn stop_server_via_api() -> Result<(), String> {
|
|
stop_server_via_api_at(&crate::api::socket_path(), SERVER_STOP_RESPONSE_TIMEOUT)
|
|
}
|
|
|
|
fn server_shutdown_confirmed_at(socket_path: &Path) -> Result<bool, String> {
|
|
if !socket_path.exists() {
|
|
return Ok(true);
|
|
}
|
|
|
|
match UnixStream::connect(socket_path) {
|
|
Ok(_) => Ok(false),
|
|
Err(err)
|
|
if matches!(
|
|
err.kind(),
|
|
io::ErrorKind::ConnectionRefused
|
|
| io::ErrorKind::NotFound
|
|
| io::ErrorKind::TimedOut
|
|
) =>
|
|
{
|
|
Ok(true)
|
|
}
|
|
Err(err) => Err(format!(
|
|
"failed to confirm whether the old server stopped on {}: {err}",
|
|
socket_path.display()
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn wait_for_server_shutdown_at(socket_path: &Path, timeout: Duration) -> Result<(), String> {
|
|
let deadline = Instant::now() + timeout;
|
|
loop {
|
|
if server_shutdown_confirmed_at(socket_path)? {
|
|
return Ok(());
|
|
}
|
|
if Instant::now() >= deadline {
|
|
return Err(format!(
|
|
"shutdown was requested, but the old server is still responding on {} after {} seconds",
|
|
socket_path.display(),
|
|
timeout.as_secs()
|
|
));
|
|
}
|
|
std::thread::sleep(SERVER_SHUTDOWN_POLL_INTERVAL);
|
|
}
|
|
}
|
|
|
|
fn wait_for_server_shutdown(timeout: Duration) -> Result<(), String> {
|
|
wait_for_server_shutdown_at(&crate::api::socket_path(), timeout)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Manual self-update command (`herdr update`).
|
|
pub fn self_update() -> Result<Version, String> {
|
|
if running_inside_herdr() {
|
|
return Err("run `herdr update` outside herdr after detaching from the session".into());
|
|
}
|
|
|
|
eprintln!("checking for updates...");
|
|
|
|
let current = Version::current();
|
|
|
|
let release = match check_latest()? {
|
|
Some(r) => r,
|
|
None => {
|
|
eprintln!("already up to date (v{current})");
|
|
return Ok(current);
|
|
}
|
|
};
|
|
|
|
let running_server_plan = plan_running_server_update(&release)?;
|
|
|
|
eprintln!("downloading v{}...", release.version);
|
|
if let Err(e) =
|
|
crate::release_notes::save_pending(&release.version.to_string(), &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();
|
|
let stopped_server = stop_running_server_for_update(running_server_plan.as_ref(), &release)?;
|
|
install_downloaded_update(downloaded_update)?;
|
|
eprintln!("updated to v{}", release.version);
|
|
print_outdated_integration_notice_with_updated_binary(&updated_exe);
|
|
|
|
if stopped_server {
|
|
eprintln!("run herdr again to start the updated server.");
|
|
} else if api_server_is_running() {
|
|
eprintln!("the running herdr server will use the new version after it restarts.");
|
|
} else {
|
|
eprintln!("run herdr again.");
|
|
}
|
|
|
|
Ok(release.version)
|
|
}
|
|
|
|
fn print_outdated_integration_notice_with_updated_binary(updated_exe: &Path) {
|
|
let status = Command::new(updated_exe)
|
|
.args(["integration", "status", "--outdated-only"])
|
|
.status();
|
|
|
|
if !status.is_ok_and(|status| status.success()) {
|
|
crate::integration::print_outdated_update_notice();
|
|
}
|
|
}
|
|
|
|
/// Background update check: only surface availability and release notes.
|
|
/// Runs in a background thread at startup.
|
|
pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
|
|
crate::logging::update_check_started();
|
|
if let Ok(version) = env::var(FAKE_UPDATE_VERSION_ENV) {
|
|
let version = version.trim();
|
|
if !version.is_empty() {
|
|
tracing::info!(
|
|
env = FAKE_UPDATE_VERSION_ENV,
|
|
version,
|
|
"using fake update version for local testing"
|
|
);
|
|
if let Err(e) =
|
|
crate::release_notes::save_pending(version, &fake_release_notes_body(version))
|
|
{
|
|
tracing::warn!("failed to save fake pending release notes: {e}");
|
|
}
|
|
let _ = events.blocking_send(crate::events::AppEvent::UpdateReady {
|
|
version: version.to_string(),
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
let release = match check_latest() {
|
|
Ok(Some(r)) => r,
|
|
Ok(None) => return,
|
|
Err(err) => {
|
|
crate::logging::update_check_failed(&err);
|
|
return;
|
|
}
|
|
};
|
|
|
|
crate::logging::update_available(&release.version.to_string());
|
|
tracing::info!(
|
|
"new version v{} available at {}",
|
|
release.version,
|
|
release.download_url
|
|
);
|
|
|
|
if let Err(e) =
|
|
crate::release_notes::save_pending(&release.version.to_string(), &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
|
|
);
|
|
|
|
// Notify the TUI — blocking_send is safe from a std::thread
|
|
let _ = events.blocking_send(crate::events::AppEvent::UpdateReady {
|
|
version: release.version.to_string(),
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn platform_target() -> (&'static str, &'static str) {
|
|
let os = if cfg!(target_os = "linux") {
|
|
"linux"
|
|
} else if cfg!(target_os = "macos") {
|
|
"macos"
|
|
} else {
|
|
"unknown"
|
|
};
|
|
|
|
let arch = if cfg!(target_arch = "x86_64") {
|
|
"x86_64"
|
|
} else if cfg!(target_arch = "aarch64") {
|
|
"aarch64"
|
|
} else {
|
|
"unknown"
|
|
};
|
|
|
|
(os, arch)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::os::unix::net::UnixListener;
|
|
use std::sync::{
|
|
atomic::{AtomicBool, Ordering},
|
|
Arc,
|
|
};
|
|
use std::thread;
|
|
|
|
fn unique_test_socket_path(name: &str) -> std::path::PathBuf {
|
|
let nanos = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos();
|
|
std::path::PathBuf::from(format!(
|
|
"/tmp/hu-{name}-{}-{nanos}.sock",
|
|
std::process::id()
|
|
))
|
|
}
|
|
|
|
fn spawn_accept_loop(path: &Path) -> (Arc<AtomicBool>, thread::JoinHandle<()>) {
|
|
let listener = UnixListener::bind(path).unwrap();
|
|
listener.set_nonblocking(true).unwrap();
|
|
let running = Arc::new(AtomicBool::new(true));
|
|
let running_thread = Arc::clone(&running);
|
|
let handle = thread::spawn(move || {
|
|
while running_thread.load(Ordering::Relaxed) {
|
|
match listener.accept() {
|
|
Ok((_stream, _)) => {}
|
|
Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
|
|
thread::sleep(Duration::from_millis(10));
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
});
|
|
(running, handle)
|
|
}
|
|
|
|
#[test]
|
|
fn parse_version_basic() {
|
|
assert_eq!(
|
|
Version::parse("1.2.3"),
|
|
Some(Version {
|
|
major: 1,
|
|
minor: 2,
|
|
patch: 3
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_version_with_v_prefix() {
|
|
assert_eq!(
|
|
Version::parse("v0.1.0"),
|
|
Some(Version {
|
|
major: 0,
|
|
minor: 1,
|
|
patch: 0
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_version_invalid() {
|
|
assert_eq!(Version::parse("1.2"), None);
|
|
assert_eq!(Version::parse("abc"), None);
|
|
assert_eq!(Version::parse(""), None);
|
|
}
|
|
|
|
#[test]
|
|
fn fake_release_notes_default_to_real_large_changelog_section() {
|
|
std::env::remove_var(FAKE_UPDATE_NOTES_VERSION_ENV);
|
|
|
|
let body = fake_release_notes_body("9.4.9");
|
|
assert!(body.contains("### Major Changes"));
|
|
assert!(body.contains("Added tabs within workspaces"));
|
|
}
|
|
|
|
#[test]
|
|
fn fake_release_notes_fallback_include_version_and_context() {
|
|
std::env::set_var(FAKE_UPDATE_NOTES_VERSION_ENV, "does-not-exist");
|
|
|
|
let body = fake_release_notes_body("9.4.9");
|
|
assert!(body.contains("v9.4.9"));
|
|
assert!(body.contains("local UI validation"));
|
|
assert!(body.contains(FAKE_UPDATE_VERSION_ENV));
|
|
|
|
std::env::remove_var(FAKE_UPDATE_NOTES_VERSION_ENV);
|
|
}
|
|
|
|
#[test]
|
|
fn running_inside_herdr_env_requires_marker() {
|
|
assert!(running_inside_herdr_env(Some(crate::HERDR_ENV_VALUE)));
|
|
assert!(!running_inside_herdr_env(None));
|
|
assert!(!running_inside_herdr_env(Some("0")));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_stop_server_before_update_response_defaults_no_for_blank() {
|
|
assert_eq!(parse_stop_server_before_update_response(""), Some(false));
|
|
assert_eq!(parse_stop_server_before_update_response("\n"), Some(false));
|
|
assert_eq!(parse_stop_server_before_update_response("n"), Some(false));
|
|
assert_eq!(parse_stop_server_before_update_response("no"), Some(false));
|
|
assert_eq!(parse_stop_server_before_update_response("y"), Some(true));
|
|
assert_eq!(parse_stop_server_before_update_response("yes"), Some(true));
|
|
assert_eq!(parse_stop_server_before_update_response("later"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn update_requires_server_stop_when_target_protocol_differs_or_unknown() {
|
|
let server = crate::api::RuntimeStatus {
|
|
version: Some("0.5.5".to_string()),
|
|
protocol: Some(2),
|
|
};
|
|
let compatible_release = ReleaseInfo {
|
|
version: Version::parse("0.5.6").unwrap(),
|
|
target_protocol: Some(2),
|
|
download_url: "https://example.com/herdr".to_string(),
|
|
notes_body: "### Changed\n- One".to_string(),
|
|
};
|
|
let incompatible_release = ReleaseInfo {
|
|
target_protocol: Some(4),
|
|
..compatible_release.clone()
|
|
};
|
|
let unknown_release = ReleaseInfo {
|
|
target_protocol: None,
|
|
..compatible_release.clone()
|
|
};
|
|
|
|
assert!(!update_requires_server_stop(&server, &compatible_release));
|
|
assert!(update_requires_server_stop(&server, &incompatible_release));
|
|
assert!(update_requires_server_stop(&server, &unknown_release));
|
|
}
|
|
|
|
#[test]
|
|
fn client_protocol_server_is_running_at_detects_live_socket() {
|
|
let socket_path = unique_test_socket_path("client-live");
|
|
let listener = UnixListener::bind(&socket_path).unwrap();
|
|
|
|
assert!(client_protocol_server_is_running_at(&socket_path));
|
|
|
|
drop(listener);
|
|
let _ = fs::remove_file(&socket_path);
|
|
}
|
|
|
|
#[test]
|
|
fn client_protocol_server_is_running_at_ignores_missing_socket() {
|
|
let socket_path = unique_test_socket_path("client-missing");
|
|
|
|
assert!(!client_protocol_server_is_running_at(&socket_path));
|
|
}
|
|
|
|
#[test]
|
|
fn stop_server_via_api_accepts_success_response() {
|
|
let socket_path = unique_test_socket_path("stop-ok");
|
|
let listener = UnixListener::bind(&socket_path).unwrap();
|
|
let handle = thread::spawn(move || {
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
let mut request = String::new();
|
|
BufReader::new(stream.try_clone().unwrap())
|
|
.read_line(&mut request)
|
|
.unwrap();
|
|
assert!(
|
|
request.contains("server.stop")
|
|
|| request.contains("ServerStop")
|
|
|| request.contains("server_stop")
|
|
|| request.contains("ServerStop")
|
|
);
|
|
stream
|
|
.write_all(b"{\"id\":\"update:server:stop\",\"result\":{}}\n")
|
|
.unwrap();
|
|
stream.flush().unwrap();
|
|
});
|
|
|
|
let result = stop_server_via_api_at(&socket_path, Duration::from_millis(200));
|
|
let _ = handle.join();
|
|
let _ = fs::remove_file(&socket_path);
|
|
assert!(
|
|
result.is_ok(),
|
|
"expected stop request to succeed: {result:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stop_server_via_api_times_out_when_server_never_replies() {
|
|
let socket_path = unique_test_socket_path("stop-timeout");
|
|
let listener = UnixListener::bind(&socket_path).unwrap();
|
|
let handle = thread::spawn(move || {
|
|
let (_stream, _) = listener.accept().unwrap();
|
|
thread::sleep(Duration::from_millis(200));
|
|
});
|
|
|
|
let err = stop_server_via_api_at(&socket_path, Duration::from_millis(50)).unwrap_err();
|
|
let _ = handle.join();
|
|
let _ = fs::remove_file(&socket_path);
|
|
assert!(
|
|
err.contains("failed to read server stop response")
|
|
|| err.contains("empty server stop response"),
|
|
"unexpected error: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wait_for_server_shutdown_succeeds_once_socket_stops_accepting() {
|
|
let socket_path = unique_test_socket_path("shutdown-ok");
|
|
let (running, handle) = spawn_accept_loop(&socket_path);
|
|
let running_for_stop = Arc::clone(&running);
|
|
let stopper = thread::spawn(move || {
|
|
thread::sleep(Duration::from_millis(60));
|
|
running_for_stop.store(false, Ordering::Relaxed);
|
|
});
|
|
|
|
let result = wait_for_server_shutdown_at(&socket_path, Duration::from_millis(500));
|
|
running.store(false, Ordering::Relaxed);
|
|
let _ = stopper.join();
|
|
let _ = handle.join();
|
|
let _ = fs::remove_file(&socket_path);
|
|
assert!(result.is_ok(), "expected shutdown confirmation: {result:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn wait_for_server_shutdown_times_out_while_socket_keeps_responding() {
|
|
let socket_path = unique_test_socket_path("shutdown-timeout");
|
|
let (running, handle) = spawn_accept_loop(&socket_path);
|
|
|
|
let err =
|
|
wait_for_server_shutdown_at(&socket_path, Duration::from_millis(120)).unwrap_err();
|
|
running.store(false, Ordering::Relaxed);
|
|
let _ = handle.join();
|
|
let _ = fs::remove_file(&socket_path);
|
|
assert!(err.contains("still responding"), "unexpected error: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn version_ordering() {
|
|
let v010 = Version::parse("0.1.0").unwrap();
|
|
let v011 = Version::parse("0.1.1").unwrap();
|
|
let v020 = Version::parse("0.2.0").unwrap();
|
|
let v100 = Version::parse("1.0.0").unwrap();
|
|
|
|
assert!(v010 < v011);
|
|
assert!(v011 < v020);
|
|
assert!(v020 < v100);
|
|
assert!(v010 == Version::parse("0.1.0").unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn version_display() {
|
|
let v = Version {
|
|
major: 0,
|
|
minor: 1,
|
|
patch: 0,
|
|
};
|
|
assert_eq!(v.to_string(), "0.1.0");
|
|
}
|
|
|
|
#[test]
|
|
fn current_version_parses() {
|
|
let v = Version::current();
|
|
assert!(v.major < 100);
|
|
}
|
|
|
|
#[test]
|
|
fn platform_target_is_known() {
|
|
let (os, arch) = platform_target();
|
|
assert!(os == "linux" || os == "macos", "os: {os}");
|
|
assert!(arch == "x86_64" || arch == "aarch64", "arch: {arch}");
|
|
}
|
|
|
|
#[test]
|
|
fn update_manifest_deserializes() {
|
|
let json = "{\n\
|
|
\"version\": \"0.2.0\",\n\
|
|
\"protocol\": 4,\n\
|
|
\"notes\": \"### Changed\\n- One\",\n\
|
|
\"assets\": {\n\
|
|
\"linux-x86_64\": \"https://example.com/herdr-linux-x86_64\",\n\
|
|
\"macos-aarch64\": \"https://example.com/herdr-macos-aarch64\"\n\
|
|
}\n\
|
|
}";
|
|
let manifest: UpdateManifest = serde_json::from_str(json).unwrap();
|
|
assert_eq!(manifest.version, "0.2.0");
|
|
assert_eq!(manifest.protocol, Some(4));
|
|
assert_eq!(manifest.assets.len(), 2);
|
|
assert_eq!(manifest.notes_body(), "### Changed\n- One");
|
|
assert_eq!(
|
|
manifest.download_url_for("linux", "x86_64").as_deref(),
|
|
Some("https://example.com/herdr-linux-x86_64")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn update_manifest_requires_notes_field() {
|
|
let json = r#"{
|
|
"version": "0.2.0",
|
|
"assets": {
|
|
"linux-x86_64": "https://example.com/herdr-linux-x86_64"
|
|
}
|
|
}"#;
|
|
|
|
assert!(serde_json::from_str::<UpdateManifest>(json).is_err());
|
|
}
|
|
|
|
#[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");
|
|
|
|
assert!(!manifest.notes_body().is_empty());
|
|
assert_eq!(
|
|
manifest.protocol,
|
|
Some(crate::server::protocol::PROTOCOL_VERSION)
|
|
);
|
|
assert_eq!(manifest.assets.len(), 4);
|
|
|
|
for target in [
|
|
"linux-x86_64",
|
|
"linux-aarch64",
|
|
"macos-x86_64",
|
|
"macos-aarch64",
|
|
] {
|
|
let url = manifest
|
|
.assets
|
|
.get(target)
|
|
.unwrap_or_else(|| panic!("missing asset URL for {target}"));
|
|
assert!(
|
|
url.contains(&format!("/releases/download/v{}/", manifest.version)),
|
|
"unexpected release URL for {target}: {url}"
|
|
);
|
|
assert!(
|
|
url.ends_with(&format!("herdr-{target}")),
|
|
"unexpected asset name for {target}: {url}"
|
|
);
|
|
}
|
|
}
|
|
}
|