use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use decdn_common::config::load_file_config;
use super::release::{
Clients, Upgrade, decide, releases_base, resolve_latest, running_version, version_from_tag,
};
const INTERVAL: Duration = Duration::from_hours(24);
const LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
const GRACE: Duration = Duration::from_secs(1);
pub(crate) const ENV_CHECK: &str = "DECDN_SELF_UPDATE_CHECK";
#[derive(Clone, Copy, Debug)]
pub struct NudgePolicy {
pub json: bool,
pub excluded: bool,
}
pub(crate) fn stamp_path(data_dir: &Path) -> PathBuf {
data_dir.join("self-update-check")
}
pub(crate) fn is_due(stamp: Option<&str>, now_secs: u64) -> bool {
let Some(last) = stamp.and_then(|s| s.trim().parse::<u64>().ok()) else {
return true;
};
last > now_secs || now_secs - last >= INTERVAL.as_secs()
}
pub(crate) fn env_flag(raw: Option<&str>) -> Option<bool> {
match raw?.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
pub(crate) fn check_enabled(env: Option<bool>, file_check: Result<Option<bool>, ()>) -> bool {
match (env, file_check) {
(Some(v), _) => v,
(None, Ok(v)) => v.unwrap_or(true),
(None, Err(())) => false,
}
}
pub(crate) async fn notice(
stamp: &Path,
base: &str,
timeout: Duration,
now_secs: u64,
) -> Option<String> {
write_stamp(stamp, now_secs).ok()?;
let clients = Clients::build(timeout).ok()?;
let tag = tokio::time::timeout(timeout, resolve_latest(&clients, base))
.await
.ok()?
.ok()?;
let latest = version_from_tag(&tag).ok()?;
let running = running_version().ok()?;
match decide(&running, &latest) {
Upgrade::Newer => Some(format!(
"decdn {latest} is available; run `decdn self-update`"
)),
Upgrade::UpToDate | Upgrade::Downgrade | Upgrade::PreRelease => None,
}
}
fn write_stamp(stamp: &Path, now_secs: u64) -> std::io::Result<()> {
if let Some(dir) = stamp.parent() {
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700);
}
builder.create(dir)?;
}
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(stamp)?;
writeln!(file, "{now_secs}")
}
#[derive(Debug)]
pub struct Pending {
handle: tokio::task::JoinHandle<Option<String>>,
}
impl Pending {
pub async fn finish(self) {
if let Some(line) = settle(self.handle, GRACE).await {
eprintln!("{line}");
}
}
pub fn cancel(self) {
self.handle.abort();
}
}
pub(crate) async fn settle(
handle: tokio::task::JoinHandle<Option<String>>,
grace: Duration,
) -> Option<String> {
let abort = handle.abort_handle();
match tokio::time::timeout(grace, handle).await {
Ok(Ok(line)) => line,
Ok(Err(_)) => None,
Err(_elapsed) => {
abort.abort();
None
}
}
}
pub fn start(policy: NudgePolicy, config_path: Option<&Path>) -> Option<Pending> {
if policy.excluded
|| policy.json
|| !std::io::stdout().is_terminal()
|| !std::io::stderr().is_terminal()
{
return None;
}
let env = env_flag(std::env::var(ENV_CHECK).ok().as_deref());
let file_check = load_file_config(config_path)
.map(|f| f.self_update.and_then(|s| s.check))
.map_err(|_| ());
if !check_enabled(env, file_check) {
return None;
}
let data_dir = decdn_common::cli::default_data_dir()?;
let stamp = stamp_path(&data_dir);
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
if !is_due(std::fs::read_to_string(&stamp).ok().as_deref(), now_secs) {
return None;
}
let base = releases_base();
let handle = tokio::spawn(async move { notice(&stamp, &base, LOOKUP_TIMEOUT, now_secs).await });
Some(Pending { handle })
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod tests;