use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use gpui::{Context, Timer};
use crate::state::TermdenApp;
pub const REPO: &str = "Sylviromi/termden";
const ASSET: &str = "termden-x86_64-linux.tar.gz";
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
const CHECK_EVERY: Duration = Duration::from_secs(6 * 3600);
fn install_dir() -> Option<PathBuf> {
let exe = std::env::current_exe().ok()?;
if exe.components().any(|c| c.as_os_str() == "target") {
return None; }
let dir = exe.parent()?.to_path_buf();
let probe = dir.join(".termden-write-test");
std::fs::write(&probe, b"").ok()?;
let _ = std::fs::remove_file(probe);
Some(dir)
}
fn latest() -> Option<String> {
let out = Command::new("curl")
.args(["-fsSL", "--max-time", "20"])
.arg(format!(
"https://api.github.com/repos/{REPO}/releases/latest"
))
.output()
.ok()?;
let json: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
json["tag_name"].as_str().map(String::from)
}
fn parse(version: &str) -> Option<(u64, u64, u64)> {
let mut parts = version.trim_start_matches('v').split('.');
let mut next = || parts.next()?.split('-').next()?.parse().ok();
Some((next()?, next()?, next()?))
}
fn newer(tag: &str, current: &str) -> bool {
matches!((parse(tag), parse(current)), (Some(a), Some(b)) if a > b)
}
fn install(tag: &str, dir: &Path) -> Result<(), String> {
let tmp = dir.join(".termden-update");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).map_err(|e| e.to_string())?;
let result = (|| {
let archive = tmp.join(ASSET);
let run = |cmd: &mut Command| match cmd.status() {
Ok(s) if s.success() => Ok(()),
_ => Err(format!("{:?} failed", cmd.get_program())),
};
run(Command::new("curl")
.args(["-fsSL", "--max-time", "300", "-o"])
.arg(&archive)
.arg(format!(
"https://github.com/{REPO}/releases/download/{tag}/{ASSET}"
)))?;
run(Command::new("tar")
.arg("-xzf")
.arg(&archive)
.arg("-C")
.arg(&tmp))?;
for bin in ["termdenctl", "termden"] {
let new = tmp.join("termden").join(bin);
if !new.is_file() {
return Err(format!("{bin} missing from the release"));
}
std::fs::rename(&new, dir.join(bin)).map_err(|e| e.to_string())?;
}
Ok(())
})();
let _ = std::fs::remove_dir_all(&tmp);
result
}
impl TermdenApp {
pub fn watch_updates(cx: &mut Context<Self>) {
if install_dir().is_none() {
return;
}
cx.spawn(async move |this, cx| {
loop {
let tag = cx.background_executor().spawn(async { latest() }).await;
let alive = this.update(cx, |this, cx| {
this.update_available = tag.filter(|t| newer(t, VERSION));
cx.notify();
});
if alive.is_err() {
break;
}
Timer::after(CHECK_EVERY).await;
}
})
.detach();
}
pub fn apply_update(&mut self, cx: &mut Context<Self>) -> String {
let (Some(tag), Some(dir)) = (self.update_available.clone(), install_dir()) else {
return format!("termden {VERSION} is up to date");
};
if self.updating {
return format!("already updating to {tag}");
}
self.updating = true;
self.log("Termden", format!("updating to {tag}"), 0);
let reply = format!("updating to {tag}");
cx.spawn(async move |this, cx| {
let result = cx
.background_executor()
.spawn(async move { install(&tag, &dir) })
.await;
let _ = this.update(cx, |this, cx| {
this.updating = false;
if let Err(error) = result {
this.log("Termden", format!("update failed: {error}"), 7);
}
cx.notify();
});
})
.detach();
reply
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compares_versions() {
assert!(newer("v0.2.0", "0.1.9"));
assert!(newer("v1.0.0", "0.10.0"));
assert!(!newer("v0.1.0", "0.1.0"));
assert!(!newer("v0.1.0-rc1", "0.1.0"));
assert!(!newer("nightly", "0.1.0"));
}
}