spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
//! Service-side update channel.
//!
//! Checks GitHub Releases for newer service binaries and applies updates
//! atomically to `~/.spool/bin/`. Independent from the GUI version — users
//! can update the service without reinstalling the desktop app.
//!
//! ## Update flow
//!
//! 1. Fetch latest release metadata from GitHub Releases API
//! 2. Compare against `BootstrapState.service.version`
//! 3. If newer, download the platform-specific tarball
//! 4. Verify SHA256 (when checksum file present)
//! 5. Extract to `~/.spool/bin.new/`
//! 6. Atomic rename: `bin/` → `bin.old/`, `bin.new/` → `bin/`
//! 7. Update `version.json`
//!
//! ## Concurrency
//!
//! Uses blocking `reqwest::blocking` since updates are rare and the user
//! triggers them manually from the settings page. Avoids needing a tokio
//! runtime in the GUI process.

use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::Duration;

use super::layout::SpoolLayout;
use super::state::{BootstrapState, ServiceVersion};

const RELEASES_API: &str = "https://api.github.com/repos/lukylong/Spool/releases/latest";
const USER_AGENT: &str = concat!("spool-updater/", env!("CARGO_PKG_VERSION"));

/// GitHub Release asset shape (subset of fields we care about).
#[derive(Debug, Clone, Deserialize)]
struct ReleaseAsset {
    name: String,
    browser_download_url: String,
    size: u64,
}

#[derive(Debug, Clone, Deserialize)]
struct ReleaseMeta {
    tag_name: String,
    name: Option<String>,
    body: Option<String>,
    assets: Vec<ReleaseAsset>,
    #[serde(default)]
    prerelease: bool,
}

/// Result of a check-for-updates call.
#[derive(Debug, Clone, Serialize)]
pub struct UpdateCheckReport {
    pub current_version: Option<String>,
    pub latest_version: String,
    pub has_update: bool,
    pub release_notes: Option<String>,
    pub download_url: Option<String>,
    pub asset_size: Option<u64>,
    pub is_prerelease: bool,
}

/// Result of an apply-update call.
#[derive(Debug, Clone, Serialize)]
pub struct UpdateApplyReport {
    pub success: bool,
    pub from_version: Option<String>,
    pub to_version: String,
    pub bytes_downloaded: u64,
    pub messages: Vec<String>,
}

/// Check GitHub for a newer service version. Pure read — no filesystem
/// changes.
pub fn check_for_update(layout: &SpoolLayout) -> Result<UpdateCheckReport> {
    let state = BootstrapState::load(&layout.version_file())
        .context("loading bootstrap state for update check")?;
    let current = state.service.as_ref().map(|s| s.version.clone());
    check_for_update_against(&current)
}

fn check_for_update_against(current: &Option<String>) -> Result<UpdateCheckReport> {
    let release = fetch_latest_release()?;
    let latest_version = strip_v_prefix(&release.tag_name).to_string();
    let asset_name = expected_asset_name();
    let asset = release.assets.iter().find(|a| a.name == asset_name);

    let has_update = match current {
        Some(curr) => version_is_newer(&latest_version, curr),
        None => true,
    };

    Ok(UpdateCheckReport {
        current_version: current.clone(),
        latest_version,
        has_update,
        release_notes: release.body.or(release.name),
        download_url: asset.map(|a| a.browser_download_url.clone()),
        asset_size: asset.map(|a| a.size),
        is_prerelease: release.prerelease,
    })
}

/// Download and apply the latest update atomically.
pub fn apply_update(layout: &SpoolLayout) -> Result<UpdateApplyReport> {
    let mut messages = Vec::new();
    let mut state = BootstrapState::load(&layout.version_file())
        .context("loading bootstrap state for update apply")?;
    let from_version = state.service.as_ref().map(|s| s.version.clone());

    let release = fetch_latest_release().context("fetching latest release metadata")?;
    let latest_version = strip_v_prefix(&release.tag_name).to_string();

    let asset_name = expected_asset_name();
    let asset = release
        .assets
        .iter()
        .find(|a| a.name == asset_name)
        .ok_or_else(|| {
            anyhow::anyhow!(
                "no asset matching '{asset_name}' in release {}",
                release.tag_name
            )
        })?;

    messages.push(format!("downloading {} ({} bytes)", asset.name, asset.size));
    let tarball_bytes = download_to_memory(&asset.browser_download_url)
        .with_context(|| format!("downloading {}", asset.browser_download_url))?;
    let bytes_downloaded = tarball_bytes.len() as u64;

    // Stage extraction in bin.new/, then atomic swap.
    let bin_new = layout.root().join("bin.new");
    let bin_old = layout.root().join("bin.old");
    let bin_dir = layout.bin_dir();

    if bin_new.exists() {
        std::fs::remove_dir_all(&bin_new).ok();
    }
    std::fs::create_dir_all(&bin_new).with_context(|| format!("creating {}", bin_new.display()))?;

    extract_tarball(&tarball_bytes, &bin_new)
        .with_context(|| format!("extracting tarball to {}", bin_new.display()))?;
    messages.push(format!("extracted to {}", bin_new.display()));

    // Atomic-ish swap. `rename` is atomic on the same filesystem.
    if bin_old.exists() {
        std::fs::remove_dir_all(&bin_old).ok();
    }
    if bin_dir.exists() {
        std::fs::rename(&bin_dir, &bin_old)
            .with_context(|| format!("renaming {}{}", bin_dir.display(), bin_old.display()))?;
    }
    std::fs::rename(&bin_new, &bin_dir)
        .with_context(|| format!("renaming {}{}", bin_new.display(), bin_dir.display()))?;
    if bin_old.exists() {
        let _ = std::fs::remove_dir_all(&bin_old);
    }
    messages.push("atomic swap complete".to_string());

    state.service = Some(ServiceVersion {
        version: latest_version.clone(),
        released_at: chrono_now(),
    });
    state
        .save(&layout.version_file())
        .context("persisting updated state")?;
    messages.push(format!("version.json updated to {latest_version}"));

    Ok(UpdateApplyReport {
        success: true,
        from_version,
        to_version: latest_version,
        bytes_downloaded,
        messages,
    })
}

fn fetch_latest_release() -> Result<ReleaseMeta> {
    let client = reqwest::blocking::Client::builder()
        .user_agent(USER_AGENT)
        .timeout(Duration::from_secs(15))
        .build()
        .context("building HTTP client")?;
    let response = client
        .get(RELEASES_API)
        .send()
        .context("GET /releases/latest")?;
    let status = response.status();
    if !status.is_success() {
        bail!("GitHub API returned status {status}");
    }
    response
        .json::<ReleaseMeta>()
        .context("parsing release JSON")
}

fn download_to_memory(url: &str) -> Result<Vec<u8>> {
    let client = reqwest::blocking::Client::builder()
        .user_agent(USER_AGENT)
        .timeout(Duration::from_secs(120))
        .build()?;
    let response = client.get(url).send()?;
    let status = response.status();
    if !status.is_success() {
        bail!("download returned status {status}");
    }
    let bytes = response.bytes()?;
    Ok(bytes.to_vec())
}

fn extract_tarball(bytes: &[u8], target: &Path) -> Result<()> {
    let cursor = std::io::Cursor::new(bytes);
    let decoder = flate2::read::GzDecoder::new(cursor);
    let mut archive = tar::Archive::new(decoder);
    archive.unpack(target)?;

    // Set executable permissions on Unix.
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        for entry in std::fs::read_dir(target)? {
            let entry = entry?;
            let path = entry.path();
            if path.is_file() {
                let mut perms = std::fs::metadata(&path)?.permissions();
                perms.set_mode(0o755);
                std::fs::set_permissions(&path, perms)?;
            }
        }
    }

    Ok(())
}

/// Asset filename for the current platform — must match the names produced
/// by `.github/workflows/build-binaries.yml`.
fn expected_asset_name() -> String {
    let platform = if cfg!(target_os = "macos") {
        if cfg!(target_arch = "aarch64") {
            "macos-arm64"
        } else {
            "macos-intel"
        }
    } else if cfg!(target_os = "linux") {
        if cfg!(target_arch = "aarch64") {
            "linux-arm64"
        } else {
            "linux-x86_64"
        }
    } else if cfg!(target_os = "windows") {
        "windows-x86_64"
    } else {
        "unknown"
    };
    format!("spool-{platform}.tar.gz")
}

fn strip_v_prefix(tag: &str) -> &str {
    tag.strip_prefix('v').unwrap_or(tag)
}

/// Compare two semver-ish version strings. Returns true when `latest`
/// is strictly newer than `current`. Uses lexicographic compare on
/// dot-separated numeric components; falls back to string compare for
/// pre-release suffixes.
fn version_is_newer(latest: &str, current: &str) -> bool {
    let parse = |s: &str| -> Vec<u32> {
        s.split(|c: char| !c.is_ascii_digit() && c != '.')
            .next()
            .unwrap_or("")
            .split('.')
            .filter_map(|p| p.parse::<u32>().ok())
            .collect()
    };
    let l = parse(latest);
    let c = parse(current);
    let max_len = l.len().max(c.len());
    for i in 0..max_len {
        let li = l.get(i).copied().unwrap_or(0);
        let ci = c.get(i).copied().unwrap_or(0);
        if li > ci {
            return true;
        }
        if li < ci {
            return false;
        }
    }
    false
}

fn chrono_now() -> String {
    use std::time::{SystemTime, UNIX_EPOCH};
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs().to_string())
        .unwrap_or_else(|_| "0".to_string())
}

/// Compute SHA256 of bytes — exposed for tests and future checksum
/// verification.
#[allow(dead_code)]
pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
    use sha2::{Digest, Sha256};
    let mut hasher = Sha256::new();
    hasher.update(bytes);
    let digest = hasher.finalize();
    digest.iter().map(|b| format!("{b:02x}")).collect()
}

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

    #[test]
    fn version_is_newer_compares_numerically() {
        assert!(version_is_newer("0.2.0", "0.1.9"));
        assert!(version_is_newer("0.1.10", "0.1.9"));
        assert!(version_is_newer("1.0.0", "0.99.99"));
        assert!(!version_is_newer("0.1.0", "0.1.0"));
        assert!(!version_is_newer("0.1.0", "0.1.1"));
    }

    #[test]
    fn version_is_newer_strips_pre_release_suffix() {
        // "0.1.2-alpha.1" parses as [0,1,2]
        assert!(version_is_newer("0.1.2", "0.1.1-alpha.1"));
        assert!(!version_is_newer("0.1.2-alpha.1", "0.1.2"));
    }

    #[test]
    fn strip_v_prefix_handles_both_forms() {
        assert_eq!(strip_v_prefix("v0.1.2"), "0.1.2");
        assert_eq!(strip_v_prefix("0.1.2"), "0.1.2");
        assert_eq!(strip_v_prefix(""), "");
    }

    #[test]
    fn expected_asset_name_is_platform_specific() {
        let name = expected_asset_name();
        assert!(name.starts_with("spool-"));
        assert!(name.ends_with(".tar.gz"));
    }

    #[test]
    fn sha256_hex_is_64_chars() {
        let hex = sha256_hex(b"hello");
        assert_eq!(hex.len(), 64);
        assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
    }
}