use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use serde::Serialize;
use tokio::sync::RwLock;
const POLL_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60);
const FETCH_TIMEOUT: Duration = Duration::from_secs(10);
const CRATE_NAME: &str = "mobux";
const USER_AGENT: &str = concat!(
"mobux/",
env!("CARGO_PKG_VERSION"),
" (self-update; +https://github.com/mvhenten/mobux)"
);
#[derive(Clone)]
pub struct UpdateState {
inner: Arc<RwLock<Cache>>,
running: Arc<std::sync::atomic::AtomicBool>,
}
#[derive(Default)]
struct Cache {
latest: Option<String>,
checked_at: Option<String>,
last_error: Option<String>,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct UpdateStatus {
pub current: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub latest: Option<String>,
pub available: bool,
#[serde(rename = "checkedAt", skip_serializing_if = "Option::is_none")]
pub checked_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl UpdateState {
pub fn new() -> Self {
Self {
inner: Arc::new(RwLock::new(Cache::default())),
running: Arc::new(std::sync::atomic::AtomicBool::new(false)),
}
}
pub fn current_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
pub fn try_begin_run(&self) -> bool {
use std::sync::atomic::Ordering;
self.running
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
}
pub fn end_run(&self) {
self.running
.store(false, std::sync::atomic::Ordering::SeqCst);
}
pub async fn status(&self) -> UpdateStatus {
let c = self.inner.read().await;
let current = Self::current_version().to_string();
let available = c
.latest
.as_deref()
.map(|l| is_newer(¤t, l))
.unwrap_or(false);
UpdateStatus {
current,
latest: c.latest.clone(),
available,
checked_at: c.checked_at.clone(),
error: c.last_error.clone(),
}
}
pub async fn refresh(&self) -> UpdateStatus {
match fetch_latest_version().await {
Ok(latest) => {
let mut c = self.inner.write().await;
c.latest = Some(latest);
c.checked_at = Some(now_rfc3339());
c.last_error = None;
}
Err(e) => {
let mut c = self.inner.write().await;
c.last_error = Some(e);
}
}
self.status().await
}
}
impl Default for UpdateState {
fn default() -> Self {
Self::new()
}
}
pub fn spawn_checker(state: UpdateState) {
tokio::spawn(async move {
let mut tick = tokio::time::interval(POLL_INTERVAL);
loop {
tick.tick().await;
let _ = state.refresh().await;
}
});
}
fn now_rfc3339() -> String {
chrono::Utc::now().to_rfc3339()
}
fn check_url() -> String {
if let Ok(u) = std::env::var("MOBUX_UPDATE_CHECK_URL") {
if !u.trim().is_empty() {
return u;
}
}
sparse_index_url(CRATE_NAME)
}
fn sparse_index_url(name: &str) -> String {
let lower = name.to_lowercase();
let path = match lower.len() {
0 => lower.clone(),
1 => format!("1/{lower}"),
2 => format!("2/{lower}"),
3 => format!("3/{}/{}", &lower[0..1], lower),
_ => format!("{}/{}/{}", &lower[0..2], &lower[2..4], lower),
};
format!("https://index.crates.io/{path}")
}
async fn fetch_latest_version() -> Result<String, String> {
let url = check_url();
let client = reqwest::Client::builder()
.user_agent(USER_AGENT)
.timeout(FETCH_TIMEOUT)
.build()
.map_err(|e| format!("building http client: {e}"))?;
let resp = client
.get(&url)
.send()
.await
.map_err(|e| format!("fetching {url}: {e}"))?;
if !resp.status().is_success() {
return Err(format!("{url} returned HTTP {}", resp.status()));
}
let body = resp
.text()
.await
.map_err(|e| format!("reading {url}: {e}"))?;
latest_from_index(&body)
.ok_or_else(|| format!("no usable version found in index response from {url}"))
}
pub fn latest_from_index(body: &str) -> Option<String> {
#[derive(serde::Deserialize)]
struct Entry {
vers: String,
#[serde(default)]
yanked: bool,
}
let mut best: Option<(SemVer, String)> = None;
for line in body.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(entry) = serde_json::from_str::<Entry>(line) else {
continue;
};
if entry.yanked {
continue;
}
let Some(parsed) = SemVer::parse(&entry.vers) else {
continue;
};
match &best {
Some((b, _)) if parsed <= *b => {}
_ => best = Some((parsed, entry.vers)),
}
}
best.map(|(_, s)| s)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SemVer {
major: u64,
minor: u64,
patch: u64,
pre: Vec<String>,
}
impl SemVer {
fn parse(s: &str) -> Option<SemVer> {
let s = s.trim();
let s = s.split('+').next().unwrap_or(s);
let (core, pre) = match s.split_once('-') {
Some((c, p)) => (c, p),
None => (s, ""),
};
let mut parts = core.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None; }
let pre = if pre.is_empty() {
Vec::new()
} else {
pre.split('.').map(|p| p.to_string()).collect()
};
Some(SemVer {
major,
minor,
patch,
pre,
})
}
}
impl PartialOrd for SemVer {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for SemVer {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
use std::cmp::Ordering;
match (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch)) {
Ordering::Equal => {}
ord => return ord,
}
match (self.pre.is_empty(), other.pre.is_empty()) {
(true, true) => Ordering::Equal,
(true, false) => Ordering::Greater,
(false, true) => Ordering::Less,
(false, false) => cmp_pre(&self.pre, &other.pre),
}
}
}
fn cmp_pre(a: &[String], b: &[String]) -> std::cmp::Ordering {
use std::cmp::Ordering;
for (x, y) in a.iter().zip(b.iter()) {
let xn = x.parse::<u64>().ok();
let yn = y.parse::<u64>().ok();
let ord = match (xn, yn) {
(Some(xv), Some(yv)) => xv.cmp(&yv),
(Some(_), None) => Ordering::Less, (None, Some(_)) => Ordering::Greater,
(None, None) => x.cmp(y),
};
if ord != Ordering::Equal {
return ord;
}
}
a.len().cmp(&b.len())
}
pub fn is_newer(current: &str, latest: &str) -> bool {
match (SemVer::parse(current), SemVer::parse(latest)) {
(Some(c), Some(l)) => l > c,
_ => false,
}
}
const UPDATER_SCRIPT: &str = include_str!("update_runner.sh");
#[derive(Debug, Serialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RunError {
NotSystemd { message: String },
NoUpdateAvailable { message: String },
AlreadyRunning { message: String },
SpawnFailed { message: String },
}
impl std::fmt::Display for RunError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RunError::NotSystemd { message }
| RunError::NoUpdateAvailable { message }
| RunError::AlreadyRunning { message }
| RunError::SpawnFailed { message } => write!(f, "{message}"),
}
}
}
pub fn resolve_service_name() -> Option<String> {
std::env::var_os("INVOCATION_ID")?;
let name = std::env::var("MOBUX_SERVICE_NAME")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "mobux".to_string());
Some(name)
}
fn current_exe() -> Result<PathBuf, RunError> {
std::env::current_exe().map_err(|e| RunError::SpawnFailed {
message: format!("resolving current executable: {e}"),
})
}
fn cargo_root(bin: &Path) -> String {
bin.parent()
.and_then(|p| p.parent())
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| {
directories::BaseDirs::new()
.map(|d| d.home_dir().join(".cargo").to_string_lossy().into_owned())
.unwrap_or_else(|| "~/.cargo".to_string())
})
}
fn write_updater_script(data_dir: &Path) -> Result<PathBuf, RunError> {
let path = data_dir.join("mobux-update.sh");
std::fs::write(&path, UPDATER_SCRIPT).map_err(|e| RunError::SpawnFailed {
message: format!("writing updater script to {}: {e}", path.display()),
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).map_err(|e| {
RunError::SpawnFailed {
message: format!("chmod updater script: {e}"),
}
})?;
}
Ok(path)
}
pub fn spawn_updater(
data_dir: &Path,
version: &str,
port: u16,
use_tls: bool,
) -> Result<PathBuf, RunError> {
if std::env::var_os("MOBUX_UPDATE_DISABLE_RUN").is_some() {
return Err(RunError::NotSystemd {
message: "in-app update is disabled on this host (MOBUX_UPDATE_DISABLE_RUN)"
.to_string(),
});
}
let Some(service) = resolve_service_name() else {
return Err(RunError::NotSystemd {
message: "not running under systemd (no INVOCATION_ID); in-app update \
is only available for the systemd --user service"
.to_string(),
});
};
let bin = current_exe()?;
let root = cargo_root(&bin);
let script = write_updater_script(data_dir)?;
let log_path = data_dir.join("mobux-update.log");
let log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.map_err(|e| RunError::SpawnFailed {
message: format!("opening update log {}: {e}", log_path.display()),
})?;
let log_err = log.try_clone().map_err(|e| RunError::SpawnFailed {
message: format!("cloning log handle: {e}"),
})?;
let scheme = if use_tls { "https" } else { "http" };
let mut cmd = std::process::Command::new("setsid");
cmd.arg("bash")
.arg(&script)
.env("MOBUX_UPDATE_VERSION", version)
.env("MOBUX_UPDATE_BIN", &bin)
.env("MOBUX_UPDATE_ROOT", &root)
.env("MOBUX_UPDATE_SERVICE", &service)
.env("MOBUX_UPDATE_PORT", port.to_string())
.env("MOBUX_UPDATE_SCHEME", scheme)
.env("MOBUX_UPDATE_LOG", &log_path)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::from(log))
.stderr(std::process::Stdio::from(log_err));
cmd.spawn().map_err(|e| RunError::SpawnFailed {
message: format!("spawning updater (setsid bash {}): {e}", script.display()),
})?;
Ok(log_path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sparse_index_path_layout() {
assert_eq!(
sparse_index_url("mobux"),
"https://index.crates.io/mo/bu/mobux"
);
assert_eq!(sparse_index_url("a"), "https://index.crates.io/1/a");
assert_eq!(sparse_index_url("ab"), "https://index.crates.io/2/ab");
assert_eq!(sparse_index_url("abc"), "https://index.crates.io/3/a/abc");
assert_eq!(
sparse_index_url("serde"),
"https://index.crates.io/se/rd/serde"
);
}
#[test]
fn semver_basic_ordering() {
assert!(is_newer("0.1.4", "0.1.5"));
assert!(is_newer("0.1.4", "0.2.0"));
assert!(is_newer("0.9.9", "1.0.0"));
assert!(!is_newer("0.1.4", "0.1.4"));
assert!(!is_newer("0.1.5", "0.1.4"));
assert!(!is_newer("1.0.0", "0.9.9"));
}
#[test]
fn disable_run_guard_refuses_without_spawning() {
let tmp = std::env::temp_dir().join(format!("mobux-update-test-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
unsafe { std::env::set_var("MOBUX_UPDATE_DISABLE_RUN", "1") };
let res = spawn_updater(&tmp, "999.0.0", 8281, false);
unsafe { std::env::remove_var("MOBUX_UPDATE_DISABLE_RUN") };
assert!(matches!(res, Err(RunError::NotSystemd { .. })));
assert!(!tmp.join("mobux-update.sh").exists());
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn run_guard_admits_one_and_rejects_concurrent() {
let st = UpdateState::new();
assert!(st.try_begin_run(), "first run must claim the lock");
assert!(!st.try_begin_run(), "second run must be rejected");
assert!(!st.try_begin_run(), "still rejected while running");
st.end_run();
assert!(st.try_begin_run(), "lock reusable after release");
}
#[test]
fn run_guard_shared_across_clones() {
let st = UpdateState::new();
let clone = st.clone();
assert!(st.try_begin_run());
assert!(!clone.try_begin_run(), "clone shares the running flag");
clone.end_run();
assert!(st.try_begin_run(), "release via one handle frees the other");
}
#[test]
fn semver_not_string_compare() {
assert!(is_newer("0.1.9", "0.1.10"));
assert!(!is_newer("0.1.10", "0.1.9"));
assert!(is_newer("0.9.0", "0.10.0"));
}
#[test]
fn semver_prerelease_ordering() {
assert!(is_newer("1.0.0-alpha", "1.0.0"));
assert!(!is_newer("1.0.0", "1.0.0-alpha"));
assert!(is_newer("1.0.0-alpha", "1.0.0-alpha.1"));
assert!(is_newer("1.0.0-alpha.1", "1.0.0-alpha.beta"));
assert!(is_newer("1.0.0-beta", "1.0.0-beta.2"));
assert!(is_newer("1.0.0-beta.11", "1.0.0-rc.1"));
assert!(is_newer("1.0.0-rc.1", "1.0.0"));
}
#[test]
fn semver_build_metadata_ignored() {
assert!(!is_newer("1.0.0+build.1", "1.0.0+build.2"));
assert!(!is_newer("1.0.0", "1.0.0+anything"));
}
#[test]
fn semver_garbage_is_safe() {
assert!(!is_newer("not-a-version", "1.0.0"));
assert!(!is_newer("1.0.0", "garbage"));
assert!(!is_newer("1.0", "1.0.1")); }
#[test]
fn parse_index_picks_highest_non_yanked() {
let body = r#"
{"name":"mobux","vers":"0.1.0","yanked":false}
{"name":"mobux","vers":"0.1.10","yanked":false}
{"name":"mobux","vers":"0.1.9","yanked":false}
{"name":"mobux","vers":"0.2.0","yanked":true}
"#;
assert_eq!(latest_from_index(body).as_deref(), Some("0.1.10"));
}
#[test]
fn parse_index_skips_malformed_lines() {
let body = "not json\n{\"name\":\"mobux\",\"vers\":\"1.2.3\",\"yanked\":false}\n{bad";
assert_eq!(latest_from_index(body).as_deref(), Some("1.2.3"));
}
#[test]
fn parse_index_all_yanked_is_none() {
let body = r#"{"name":"mobux","vers":"0.1.0","yanked":true}"#;
assert_eq!(latest_from_index(body), None);
}
#[test]
fn parse_index_empty_is_none() {
assert_eq!(latest_from_index(""), None);
assert_eq!(latest_from_index("\n \n"), None);
}
#[test]
fn parse_index_handles_prerelease_below_release() {
let body = r#"
{"name":"mobux","vers":"1.0.0-rc.1","yanked":false}
{"name":"mobux","vers":"0.9.0","yanked":false}
"#;
assert_eq!(latest_from_index(body).as_deref(), Some("1.0.0-rc.1"));
}
#[tokio::test]
async fn status_reports_available_when_cache_newer() {
let st = UpdateState::new();
{
let mut c = st.inner.write().await;
c.latest = Some("999.0.0".to_string());
c.checked_at = Some("2024-01-01T00:00:00Z".to_string());
}
let s = st.status().await;
assert!(s.available);
assert_eq!(s.latest.as_deref(), Some("999.0.0"));
assert_eq!(s.current, UpdateState::current_version());
assert_eq!(s.checked_at.as_deref(), Some("2024-01-01T00:00:00Z"));
}
#[tokio::test]
async fn status_not_available_when_cache_equals_current() {
let st = UpdateState::new();
{
let mut c = st.inner.write().await;
c.latest = Some(UpdateState::current_version().to_string());
}
let s = st.status().await;
assert!(!s.available);
}
#[tokio::test]
async fn status_empty_cache_not_available() {
let st = UpdateState::new();
let s = st.status().await;
assert!(!s.available);
assert_eq!(s.latest, None);
assert_eq!(s.checked_at, None);
}
}