use chrono::{DateTime, Utc};
use nono::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::thread;
const UPDATE_STATE_FILE: &str = "update-check.json";
const CHECK_INTERVAL_SECS: i64 = 86400;
const CHECK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
const UPDATE_SERVICE_URL: &str = "https://update.nono.sh/v1/check";
#[derive(Debug, Serialize, Deserialize)]
struct UpdateCheckState {
uuid: String,
last_check: DateTime<Utc>,
cached_result: Option<UpdateInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateInfo {
pub latest_version: String,
pub update_available: bool,
pub message: Option<String>,
pub release_url: Option<String>,
}
#[derive(Debug, Serialize)]
struct UpdateCheckRequest {
uuid: String,
version: String,
platform: String,
arch: String,
}
pub struct UpdateCheckHandle {
result: Arc<Mutex<Option<UpdateInfo>>>,
handle: Option<thread::JoinHandle<()>>,
}
impl UpdateCheckHandle {
pub fn take_result(mut self) -> Option<UpdateInfo> {
if let Some(h) = self.handle.take() {
let _ = h.join();
}
let guard = self.result.lock().ok()?;
guard.clone()
}
}
pub fn start_background_check() -> Option<UpdateCheckHandle> {
if std::env::var("NONO_NO_UPDATE_CHECK").is_ok() {
return None;
}
if is_opted_out_via_config() {
return None;
}
let state = load_or_create_state()?;
let now = Utc::now();
let elapsed = now.signed_duration_since(state.last_check).num_seconds();
if elapsed < CHECK_INTERVAL_SECS {
let current = env!("CARGO_PKG_VERSION");
if state
.cached_result
.as_ref()
.is_some_and(|r| r.update_available && is_newer_version(current, &r.latest_version))
{
let result = Arc::new(Mutex::new(state.cached_result));
return Some(UpdateCheckHandle {
result,
handle: None,
});
}
return None;
}
let uuid = state.uuid.clone();
let result: Arc<Mutex<Option<UpdateInfo>>> = Arc::new(Mutex::new(None));
let result_clone = Arc::clone(&result);
let handle = thread::spawn(move || {
let current = env!("CARGO_PKG_VERSION");
if let Some(info) = perform_check(&uuid) {
let updated_state = UpdateCheckState {
uuid,
last_check: Utc::now(),
cached_result: Some(info.clone()),
};
let _ = save_state(&updated_state);
if info.update_available && is_newer_version(current, &info.latest_version) {
if let Ok(mut guard) = result_clone.lock() {
*guard = Some(info);
}
}
}
});
Some(UpdateCheckHandle {
result,
handle: Some(handle),
})
}
fn is_opted_out_via_config() -> bool {
match crate::config::user::load_user_config() {
Ok(Some(config)) => !config.updates.check,
_ => false,
}
}
fn state_file_path() -> Option<PathBuf> {
crate::config::user_state_dir().map(|d| d.join(UPDATE_STATE_FILE))
}
fn load_or_create_state() -> Option<UpdateCheckState> {
let path = state_file_path()?;
if path.exists() {
let content = std::fs::read_to_string(&path).ok()?;
let state: UpdateCheckState = serde_json::from_str(&content).ok()?;
return Some(state);
}
let state = UpdateCheckState {
uuid: generate_uuid(),
last_check: DateTime::UNIX_EPOCH,
cached_result: None,
};
save_state(&state).ok()?;
Some(state)
}
fn save_state(state: &UpdateCheckState) -> Result<()> {
let path = state_file_path().ok_or_else(|| {
nono::NonoError::ConfigParse("Could not determine state directory".to_string())
})?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| nono::NonoError::ConfigWrite {
path: parent.to_path_buf(),
source: e,
})?;
}
let content = serde_json::to_string_pretty(state)
.map_err(|e| nono::NonoError::ConfigParse(format!("Failed to serialize state: {}", e)))?;
std::fs::write(&path, content).map_err(|e| nono::NonoError::ConfigWrite { path, source: e })?;
Ok(())
}
fn generate_uuid() -> String {
use rand::RngExt;
let mut rng = rand::rng();
let bytes: [u8; 16] = rng.random();
let time_hi = (u16::from_be_bytes([bytes[6], bytes[7]]) & 0x0fff) | 0x4000;
let clock_seq = (u16::from_be_bytes([bytes[8], bytes[9]]) & 0x3fff) | 0x8000;
format!(
"{:08x}-{:04x}-{:04x}-{:04x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
u16::from_be_bytes([bytes[4], bytes[5]]),
time_hi,
clock_seq,
bytes[10],
bytes[11],
bytes[12],
bytes[13],
bytes[14],
bytes[15],
)
}
fn update_url() -> String {
std::env::var("NONO_UPDATE_URL").unwrap_or_else(|_| UPDATE_SERVICE_URL.to_string())
}
fn is_newer_version(current: &str, latest: &str) -> bool {
let parse = |s: &str| -> Option<(u64, u64, u64)> {
let s = s.strip_prefix('v').unwrap_or(s);
let mut parts = s.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
Some((major, minor, patch))
};
match (parse(current), parse(latest)) {
(Some(c), Some(l)) => l > c,
_ => false,
}
}
fn perform_check(uuid: &str) -> Option<UpdateInfo> {
let request = UpdateCheckRequest {
uuid: uuid.to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
platform: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
};
let body = serde_json::to_string(&request).ok()?;
let url = update_url();
let agent = ureq::Agent::config_builder()
.timeout_global(Some(CHECK_TIMEOUT))
.build()
.new_agent();
let response = agent
.post(&url)
.header("Content-Type", "application/json")
.header(
"User-Agent",
&format!("nono-cli/{}", env!("CARGO_PKG_VERSION")),
)
.send(body.as_bytes())
.ok()?;
if response.status() != 200 {
return None;
}
let response_body = response.into_body().read_to_string().ok()?;
serde_json::from_str(&response_body).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_uuid_format() {
let uuid = generate_uuid();
let parts: Vec<&str> = uuid.split('-').collect();
assert_eq!(parts.len(), 5, "UUID should have 5 groups: {}", uuid);
assert_eq!(parts[0].len(), 8);
assert_eq!(parts[1].len(), 4);
assert_eq!(parts[2].len(), 4);
assert_eq!(parts[3].len(), 4);
assert_eq!(parts[4].len(), 12);
assert!(
parts[2].starts_with('4'),
"Version nibble should be 4: {}",
parts[2]
);
let variant_char = parts[3].chars().next().unwrap_or('0');
assert!(
['8', '9', 'a', 'b'].contains(&variant_char),
"Variant nibble should be 8-b: {}",
parts[3]
);
}
#[test]
fn test_generate_uuid_uniqueness() {
let a = generate_uuid();
let b = generate_uuid();
assert_ne!(a, b, "Two UUIDs should not be equal");
}
#[test]
fn test_state_roundtrip() {
let state = UpdateCheckState {
uuid: "test-uuid-1234".to_string(),
last_check: Utc::now(),
cached_result: Some(UpdateInfo {
latest_version: "1.0.0".to_string(),
update_available: true,
message: Some("New release!".to_string()),
release_url: Some("https://example.com".to_string()),
}),
};
let json = serde_json::to_string(&state).expect("serialize");
let restored: UpdateCheckState = serde_json::from_str(&json).expect("deserialize");
assert_eq!(restored.uuid, "test-uuid-1234");
let cached = restored.cached_result.expect("should have cached result");
assert_eq!(cached.latest_version, "1.0.0");
assert!(cached.update_available);
assert_eq!(cached.message.as_deref(), Some("New release!"));
}
#[test]
fn test_state_roundtrip_no_cached() {
let state = UpdateCheckState {
uuid: "test-uuid".to_string(),
last_check: DateTime::UNIX_EPOCH,
cached_result: None,
};
let json = serde_json::to_string(&state).expect("serialize");
let restored: UpdateCheckState = serde_json::from_str(&json).expect("deserialize");
assert_eq!(restored.uuid, "test-uuid");
assert!(restored.cached_result.is_none());
}
#[test]
fn test_env_var_opt_out() {
let _lock = match crate::test_env::ENV_LOCK.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
let _env = crate::test_env::EnvVarGuard::set_all(&[("NONO_NO_UPDATE_CHECK", "1")]);
let handle = start_background_check();
assert!(handle.is_none());
}
#[test]
fn test_is_newer_version() {
assert!(is_newer_version("0.6.0", "0.6.1"));
assert!(is_newer_version("0.6.1", "0.7.0"));
assert!(is_newer_version("0.6.1", "1.0.0"));
assert!(!is_newer_version("0.6.1", "0.6.1"));
assert!(!is_newer_version("0.6.1", "0.6.0"));
assert!(!is_newer_version("1.0.0", "0.9.9"));
assert!(is_newer_version("v0.6.0", "v0.6.1"));
assert!(is_newer_version("0.6.0", "v0.6.1"));
assert!(!is_newer_version("v0.6.1", "0.6.0"));
assert!(!is_newer_version("bad", "0.6.1"));
assert!(!is_newer_version("0.6.1", "bad"));
assert!(!is_newer_version("", ""));
}
#[test]
fn test_update_info_deserialize() {
let json = r#"{
"latest_version": "0.7.0",
"update_available": true,
"message": null,
"release_url": "https://github.com/always-further/nono/releases/tag/v0.7.0"
}"#;
let info: UpdateInfo = serde_json::from_str(json).expect("deserialize");
assert_eq!(info.latest_version, "0.7.0");
assert!(info.update_available);
assert!(info.message.is_none());
assert!(info.release_url.is_some());
}
}