ferrodock 1.13.2

A fast, native container manager for Docker, written in Rust.
//! In-app updates from GitHub Releases.
//!
//! [`Updater::check`] looks for a newer release; [`Updater::install`]
//! downloads the build for this platform, checks it against the release's
//! `SHA256SUMS.txt`, and swaps it in place: the executable on Windows and
//! Linux, the whole `Ferrodock.app` on macOS. The app restarts when the
//! user says so. Both run on their own thread and report through
//! [`Status`].

mod install;

use std::sync::{Arc, Mutex};
use std::thread;

use self_update::backends::github::ReleaseList;

pub use install::Relaunch;

const OWNER: &str = "manimovassagh";
const REPO: &str = "Ferrodock";
pub const CURRENT: &str = env!("CARGO_PKG_VERSION");

/// A newer release, and where to get it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Available {
    pub version: String,
    pub notes: String,
    asset_name: String,
    asset_url: String,
    sums_url: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Status {
    #[default]
    Idle,
    Checking,
    UpToDate,
    Available(Available),
    Downloading {
        version: String,
        done: u64,
        total: Option<u64>,
    },
    /// Installed; takes effect after a restart.
    Ready {
        version: String,
        relaunch: Relaunch,
    },
    Failed(String),
}

#[derive(Clone)]
pub struct Updater {
    status: Arc<Mutex<Status>>,
    repaint: Arc<dyn Fn() + Send + Sync>,
}

impl Updater {
    /// `repaint` is called whenever the status changes, so the UI can
    /// redraw.
    pub fn new(repaint: impl Fn() + Send + Sync + 'static) -> Self {
        Self {
            status: Arc::new(Mutex::new(Status::Idle)),
            repaint: Arc::new(repaint),
        }
    }

    /// Shows a made-up status, for screenshots.
    #[cfg(test)]
    pub fn pretend(&self, status: Status) {
        self.set(status);
    }

    pub fn status(&self) -> Status {
        self.status.lock().map(|s| s.clone()).unwrap_or_default()
    }

    fn set(&self, status: Status) {
        if let Ok(mut current) = self.status.lock() {
            *current = status;
        }
        (self.repaint)();
    }

    /// Whether a check or download is running, so another can't start.
    fn busy(&self) -> bool {
        matches!(
            self.status(),
            Status::Checking | Status::Downloading { .. } | Status::Ready { .. }
        )
    }

    /// Looks for a newer release in the background.
    pub fn check(&self) {
        if self.busy() {
            return;
        }
        self.set(Status::Checking);
        let this = self.clone();
        thread::spawn(move || {
            let status = match latest() {
                Ok(Some(available)) => Status::Available(available),
                Ok(None) => Status::UpToDate,
                Err(e) => Status::Failed(format!("Couldn't check for updates: {e}")),
            };
            this.set(status);
        });
    }

    /// Downloads and installs `available` in the background.
    pub fn install(&self, available: Available) {
        if self.busy() {
            return;
        }
        let this = self.clone();
        thread::spawn(move || {
            let version = available.version.clone();
            let progress = {
                let this = this.clone();
                let version = version.clone();
                move |done, total| {
                    this.set(Status::Downloading {
                        version: version.clone(),
                        done,
                        total,
                    })
                }
            };
            progress(0, None);
            let status = match install::install(&available, progress) {
                Ok(relaunch) => Status::Ready { version, relaunch },
                Err(e) => Status::Failed(format!("Couldn't update: {e}")),
            };
            this.set(status);
        });
    }
}

#[cfg(test)]
impl Available {
    pub fn example(version: &str) -> Self {
        Self {
            version: version.into(),
            notes: "### Added\n- In-app updates".into(),
            asset_name: String::new(),
            asset_url: String::new(),
            sums_url: None,
        }
    }
}

/// The build archive's name suffix for this platform.
fn asset_suffix() -> Option<&'static str> {
    if cfg!(target_os = "macos") {
        Some("-macos-universal.zip")
    } else if cfg!(all(target_os = "windows", target_arch = "x86_64")) {
        Some("-windows-x64.zip")
    } else if cfg!(all(target_os = "linux", target_arch = "x86_64")) {
        Some("-linux-x64.tar.gz")
    } else {
        None
    }
}

/// The newest stable release, if it's newer than this build.
fn latest() -> Result<Option<Available>, String> {
    let suffix = asset_suffix().ok_or("there are no prebuilt updates for this platform")?;
    let releases = ReleaseList::configure()
        .repo_owner(OWNER)
        .repo_name(REPO)
        .build()
        .and_then(|list| list.fetch())
        .map_err(|e| e.to_string())?
        .into_vec();
    let newest = releases
        .into_iter()
        // Pre-releases (1.0.0-rc.1) are opt-in elsewhere; never offer them.
        .filter(|r| !r.version().contains('-'))
        .find(|r| r.assets().iter().any(|a| a.name().ends_with(suffix)));
    let Some(release) = newest else {
        return Ok(None);
    };
    if !is_newer(CURRENT, release.version()) {
        return Ok(None);
    }
    let asset = release
        .assets()
        .iter()
        .find(|a| a.name().ends_with(suffix))
        .expect("filtered above");
    let sums_url = release
        .assets()
        .iter()
        .find(|a| a.name() == "SHA256SUMS.txt")
        .map(|a| a.download_url().to_owned());
    Ok(Some(Available {
        version: release.version().to_owned(),
        notes: release.body().unwrap_or_default().to_owned(),
        asset_name: asset.name().to_owned(),
        asset_url: asset.download_url().to_owned(),
        sums_url,
    }))
}

fn is_newer(current: &str, other: &str) -> bool {
    self_update::version::bump_is_greater(current, other).unwrap_or(false)
}

/// Whether Ferrodock was installed by Homebrew, in which case `brew upgrade`
/// is the better way to update (it keeps brew's records right).
pub fn installed_with_homebrew() -> bool {
    cfg!(target_os = "macos")
        && [
            "/opt/homebrew/Caskroom/ferrodock",
            "/usr/local/Caskroom/ferrodock",
        ]
        .iter()
        .any(|p| std::path::Path::new(p).exists())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn compares_versions() {
        assert!(is_newer("0.5.0", "1.0.0"));
        assert!(is_newer("0.5.0", "0.5.1"));
        assert!(!is_newer("1.0.0", "1.0.0"));
        assert!(!is_newer("1.0.0", "0.9.9"));
    }

    #[test]
    fn has_an_asset_for_supported_platforms() {
        if cfg!(any(
            target_os = "macos",
            target_os = "linux",
            target_os = "windows"
        )) {
            assert!(asset_suffix().is_some());
        }
    }

    /// Talks to GitHub: `cargo test -- --ignored`.
    #[test]
    #[ignore]
    fn live_finds_releases() {
        // Pretend to be ancient so there's always something newer.
        let releases = ReleaseList::configure()
            .repo_owner(OWNER)
            .repo_name(REPO)
            .build()
            .unwrap()
            .fetch()
            .unwrap()
            .into_vec();
        assert!(!releases.is_empty());
        let suffix = asset_suffix().unwrap();
        assert!(
            releases
                .iter()
                .any(|r| r.assets().iter().any(|a| a.name().ends_with(suffix)))
        );
    }
}