use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
const REPO: &str = "raine/workmux";
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60;
const NOTIFY_COOLDOWN_SECS: u64 = 7 * 24 * 60 * 60;
const NOTIFY_BURST_COUNT: u64 = 3;
fn platform_suffix() -> Result<&'static str> {
match (std::env::consts::OS, std::env::consts::ARCH) {
("macos", "aarch64") => Ok("darwin-arm64"),
("macos", "x86_64") => Ok("darwin-amd64"),
("linux", "x86_64") => Ok("linux-amd64"),
("linux", "aarch64") => Ok("linux-arm64"),
(os, arch) => bail!("Unsupported platform: {os}/{arch}"),
}
}
fn is_homebrew_install(exe_path: &std::path::Path) -> bool {
let path_str = exe_path.to_string_lossy();
path_str.contains("/Cellar/") || path_str.contains("/homebrew/")
}
fn fetch_latest_version() -> Result<String> {
let output = Command::new("curl")
.args([
"-sSf",
&format!("https://api.github.com/repos/{REPO}/releases/latest"),
])
.output()
.context("Failed to run curl. Is curl installed?")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to fetch latest release: {}", stderr.trim());
}
let body: serde_json::Value =
serde_json::from_slice(&output.stdout).context("Failed to parse GitHub API response")?;
let tag = body["tag_name"]
.as_str()
.context("No tag_name in GitHub API response")?;
Ok(tag.strip_prefix('v').unwrap_or(tag).to_string())
}
fn download(url: &str, dest: &std::path::Path) -> Result<()> {
let status = Command::new("curl")
.args(["-sSLf", "-o"])
.arg(dest)
.arg(url)
.status()
.context("Failed to run curl")?;
if !status.success() {
bail!("Download failed: {url}");
}
Ok(())
}
fn extract_tar(archive: &std::path::Path, dest: &std::path::Path) -> Result<()> {
let status = Command::new("tar")
.arg("-xzf")
.arg(archive)
.arg("-C")
.arg(dest)
.status()
.context("Failed to run tar")?;
if !status.success() {
bail!("Failed to extract archive");
}
Ok(())
}
fn sha256_of(path: &std::path::Path) -> Result<String> {
if let Ok(output) = Command::new("sha256sum").arg(path).output()
&& output.status.success()
{
let out = String::from_utf8_lossy(&output.stdout);
if let Some(hash) = out.split_whitespace().next() {
return Ok(hash.to_string());
}
}
let output = Command::new("shasum")
.args(["-a", "256"])
.arg(path)
.output()
.context("Neither sha256sum nor shasum found. Cannot verify checksum.")?;
if !output.status.success() {
bail!("Checksum command failed");
}
let out = String::from_utf8_lossy(&output.stdout);
out.split_whitespace()
.next()
.map(|s| s.to_string())
.context("Could not parse checksum output")
}
fn verify_checksum(file: &std::path::Path, expected_line: &str) -> Result<()> {
let expected_hash = expected_line
.split_whitespace()
.next()
.context("Invalid checksum file format")?;
let actual_hash = sha256_of(file)?;
if actual_hash != expected_hash {
bail!("Checksum mismatch!\n Expected: {expected_hash}\n Got: {actual_hash}");
}
Ok(())
}
fn replace_binary(new_binary: &std::path::Path, current_exe: &std::path::Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let exe_dir = current_exe
.parent()
.context("Could not determine binary directory")?;
let staged = exe_dir.join(".workmux.new");
std::fs::copy(new_binary, &staged).context("Failed to copy new binary to install directory")?;
std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))?;
let backup = exe_dir.join(".workmux.old");
std::fs::rename(current_exe, &backup).context("Failed to move current binary aside")?;
if let Err(e) = std::fs::rename(&staged, current_exe) {
let _ = std::fs::rename(&backup, current_exe);
return Err(e).context("Failed to install new binary (rolled back)");
}
let _ = std::fs::remove_file(&backup);
Ok(())
}
fn do_update(
pb: &indicatif::ProgressBar,
artifact_name: &str,
current_exe: &std::path::Path,
) -> Result<String> {
let latest_version = fetch_latest_version()?;
if latest_version == CURRENT_VERSION {
return Ok(format!("Already up to date (v{CURRENT_VERSION})"));
}
pb.set_message(format!("Downloading v{latest_version}..."));
let tmp = tempfile::tempdir().context("Failed to create temp directory")?;
let tar_path = tmp.path().join(format!("{artifact_name}.tar.gz"));
let sha_path = tmp.path().join(format!("{artifact_name}.sha256"));
let base_url = format!("https://github.com/{REPO}/releases/download/v{latest_version}");
download(&format!("{base_url}/{artifact_name}.tar.gz"), &tar_path)?;
download(&format!("{base_url}/{artifact_name}.sha256"), &sha_path)?;
pb.set_message("Verifying checksum...");
let sha_content = std::fs::read_to_string(&sha_path).context("Failed to read checksum file")?;
verify_checksum(&tar_path, &sha_content)?;
pb.set_message("Installing...");
let extract_dir = tmp.path().join("extract");
std::fs::create_dir(&extract_dir)?;
extract_tar(&tar_path, &extract_dir)?;
let new_binary = extract_dir.join("workmux");
if !new_binary.exists() {
bail!("Extracted archive does not contain 'workmux' binary");
}
replace_binary(&new_binary, current_exe)?;
Ok(format!(
"Updated workmux v{CURRENT_VERSION} -> v{latest_version}"
))
}
pub fn run() -> Result<()> {
let current_exe =
std::env::current_exe().context("Could not determine current executable path")?;
let canonical_exe = std::fs::canonicalize(¤t_exe).unwrap_or(current_exe.clone());
if is_homebrew_install(&canonical_exe) {
bail!("workmux is managed by Homebrew. Run `brew upgrade workmux` instead.");
}
let platform = platform_suffix()?;
let artifact_name = format!("workmux-{platform}");
let pb = indicatif::ProgressBar::new_spinner();
pb.set_style(
indicatif::ProgressStyle::default_spinner()
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
.template("{spinner:.blue} {msg}")
.unwrap(),
);
pb.enable_steady_tick(std::time::Duration::from_millis(120));
pb.set_message("Checking for updates...");
match do_update(&pb, &artifact_name, ¤t_exe) {
Ok(msg) => {
pb.finish_with_message(format!("✔ {msg}"));
Ok(())
}
Err(e) => {
pb.finish_with_message("✘ Update failed".to_string());
Err(e)
}
}
}
#[derive(Debug, Serialize, Deserialize, Default)]
struct UpdateCache {
latest_version: Option<String>,
last_checked: Option<u64>,
last_notified: Option<u64>,
notify_count: Option<u64>,
}
fn update_cache_path() -> Option<std::path::PathBuf> {
let home = home::home_dir()?;
let dir = home.join(".cache").join("workmux");
std::fs::create_dir_all(&dir).ok()?;
Some(dir.join("update_check.json"))
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn load_cache(path: &std::path::Path) -> UpdateCache {
std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn save_cache(path: &std::path::Path, cache: &UpdateCache) {
if let Ok(json) = serde_json::to_string(cache) {
let _ = std::fs::write(path, json);
}
}
fn is_newer_version(latest: &str, current: &str) -> bool {
let parse = |v: &str| -> Vec<u64> { v.split('.').filter_map(|s| s.parse().ok()).collect() };
let l = parse(latest);
let c = parse(current);
l > c
}
pub fn check_and_notify(config: &crate::config::Config) {
if config.auto_update_check == Some(false) {
return;
}
if std::env::var("WORKMUX_NO_UPDATE_CHECK").is_ok() {
return;
}
use std::io::IsTerminal;
if !std::io::stdout().is_terminal() || !std::io::stderr().is_terminal() {
return;
}
let cache_path = match update_cache_path() {
Some(p) => p,
None => return,
};
let mut cache = load_cache(&cache_path);
let now = now_secs();
if now.saturating_sub(cache.last_checked.unwrap_or(0)) > CHECK_INTERVAL_SECS {
let spawned = std::env::current_exe().ok().and_then(|exe| {
Command::new(exe)
.arg("_check-update")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.ok()
});
if spawned.is_some() {
cache.last_checked = Some(now);
save_cache(&cache_path, &cache);
}
}
if let Some(ref latest) = cache.latest_version
&& is_newer_version(latest, CURRENT_VERSION)
{
let count = cache.notify_count.unwrap_or(0);
let in_cooldown = count >= NOTIFY_BURST_COUNT
&& now.saturating_sub(cache.last_notified.unwrap_or(0)) <= NOTIFY_COOLDOWN_SECS;
if !in_cooldown {
let count = if count >= NOTIFY_BURST_COUNT {
0
} else {
count
};
let is_brew = std::env::current_exe()
.ok()
.and_then(|p| std::fs::canonicalize(&p).ok())
.is_some_and(|p| is_homebrew_install(&p));
let update_cmd = if is_brew {
"brew upgrade workmux"
} else {
"workmux update"
};
eprintln!(
"Update available: workmux v{CURRENT_VERSION} -> v{latest} (run `{update_cmd}`)"
);
cache.last_notified = Some(now);
cache.notify_count = Some(count + 1);
save_cache(&cache_path, &cache);
}
}
}
fn fetch_latest_version_with_timeout() -> Result<String> {
let output = Command::new("curl")
.args([
"-sSf",
"--connect-timeout",
"5",
"--max-time",
"10",
&format!("https://api.github.com/repos/{REPO}/releases/latest"),
])
.output()
.context("Failed to run curl")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to fetch latest release: {}", stderr.trim());
}
let body: serde_json::Value =
serde_json::from_slice(&output.stdout).context("Failed to parse GitHub API response")?;
let tag = body["tag_name"]
.as_str()
.context("No tag_name in GitHub API response")?;
Ok(tag.strip_prefix('v').unwrap_or(tag).to_string())
}
pub fn run_background_check() -> Result<()> {
let latest = fetch_latest_version_with_timeout()?;
let now = now_secs();
let cache_path = update_cache_path().context("Could not determine cache path")?;
let mut cache = load_cache(&cache_path);
if cache.latest_version.as_deref() != Some(&latest) {
cache.notify_count = Some(0);
}
cache.latest_version = Some(latest);
cache.last_checked = Some(now);
save_cache(&cache_path, &cache);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_platform_suffix_current() {
let suffix = platform_suffix().unwrap();
assert!(["darwin-arm64", "darwin-amd64", "linux-amd64", "linux-arm64"].contains(&suffix));
}
#[test]
fn test_is_homebrew_cellar() {
assert!(is_homebrew_install(std::path::Path::new(
"/opt/homebrew/Cellar/workmux/0.1.124/bin/workmux"
)));
}
#[test]
fn test_is_homebrew_prefix() {
assert!(is_homebrew_install(std::path::Path::new(
"/usr/local/Cellar/workmux/0.1.124/bin/workmux"
)));
}
#[test]
fn test_is_not_homebrew_local_bin() {
assert!(!is_homebrew_install(std::path::Path::new(
"/usr/local/bin/workmux"
)));
}
#[test]
fn test_is_not_homebrew_home() {
assert!(!is_homebrew_install(std::path::Path::new(
"/home/user/.local/bin/workmux"
)));
}
#[test]
fn test_is_newer_version_patch() {
assert!(is_newer_version("0.1.10", "0.1.9"));
}
#[test]
fn test_is_newer_version_minor() {
assert!(is_newer_version("0.2.0", "0.1.124"));
}
#[test]
fn test_is_newer_version_major() {
assert!(is_newer_version("1.0.0", "0.99.99"));
}
#[test]
fn test_is_not_newer_same() {
assert!(!is_newer_version("0.1.124", "0.1.124"));
}
#[test]
fn test_is_not_newer_older() {
assert!(!is_newer_version("0.1.9", "0.1.10"));
}
}