Skip to main content

spool/bootstrap/
updater.rs

1//! Service-side update channel.
2//!
3//! Checks GitHub Releases for newer service binaries and applies updates
4//! atomically to `~/.spool/bin/`. Independent from the GUI version — users
5//! can update the service without reinstalling the desktop app.
6//!
7//! ## Update flow
8//!
9//! 1. Fetch latest release metadata from GitHub Releases API
10//! 2. Compare against `BootstrapState.service.version`
11//! 3. If newer, download the platform-specific tarball
12//! 4. Verify SHA256 (when checksum file present)
13//! 5. Extract to `~/.spool/bin.new/`
14//! 6. Atomic rename: `bin/` → `bin.old/`, `bin.new/` → `bin/`
15//! 7. Update `version.json`
16//!
17//! ## Concurrency
18//!
19//! Uses blocking `reqwest::blocking` since updates are rare and the user
20//! triggers them manually from the settings page. Avoids needing a tokio
21//! runtime in the GUI process.
22
23use anyhow::{Context, Result, bail};
24use serde::{Deserialize, Serialize};
25use std::path::Path;
26use std::time::Duration;
27
28use super::layout::SpoolLayout;
29use super::state::{BootstrapState, ServiceVersion};
30
31const RELEASES_API: &str = "https://api.github.com/repos/lukylong/Spool/releases/latest";
32const USER_AGENT: &str = concat!("spool-updater/", env!("CARGO_PKG_VERSION"));
33
34/// GitHub Release asset shape (subset of fields we care about).
35#[derive(Debug, Clone, Deserialize)]
36struct ReleaseAsset {
37    name: String,
38    browser_download_url: String,
39    size: u64,
40}
41
42#[derive(Debug, Clone, Deserialize)]
43struct ReleaseMeta {
44    tag_name: String,
45    name: Option<String>,
46    body: Option<String>,
47    assets: Vec<ReleaseAsset>,
48    #[serde(default)]
49    prerelease: bool,
50}
51
52/// Result of a check-for-updates call.
53#[derive(Debug, Clone, Serialize)]
54pub struct UpdateCheckReport {
55    pub current_version: Option<String>,
56    pub latest_version: String,
57    pub has_update: bool,
58    pub release_notes: Option<String>,
59    pub download_url: Option<String>,
60    pub asset_size: Option<u64>,
61    pub is_prerelease: bool,
62}
63
64/// Result of an apply-update call.
65#[derive(Debug, Clone, Serialize)]
66pub struct UpdateApplyReport {
67    pub success: bool,
68    pub from_version: Option<String>,
69    pub to_version: String,
70    pub bytes_downloaded: u64,
71    pub messages: Vec<String>,
72}
73
74/// Check GitHub for a newer service version. Pure read — no filesystem
75/// changes.
76pub fn check_for_update(layout: &SpoolLayout) -> Result<UpdateCheckReport> {
77    let state = BootstrapState::load(&layout.version_file())
78        .context("loading bootstrap state for update check")?;
79    let current = state.service.as_ref().map(|s| s.version.clone());
80    check_for_update_against(&current)
81}
82
83fn check_for_update_against(current: &Option<String>) -> Result<UpdateCheckReport> {
84    let release = fetch_latest_release()?;
85    let latest_version = strip_v_prefix(&release.tag_name).to_string();
86    let asset_name = expected_asset_name();
87    let asset = release.assets.iter().find(|a| a.name == asset_name);
88
89    let has_update = match current {
90        Some(curr) => version_is_newer(&latest_version, curr),
91        None => true,
92    };
93
94    Ok(UpdateCheckReport {
95        current_version: current.clone(),
96        latest_version,
97        has_update,
98        release_notes: release.body.or(release.name),
99        download_url: asset.map(|a| a.browser_download_url.clone()),
100        asset_size: asset.map(|a| a.size),
101        is_prerelease: release.prerelease,
102    })
103}
104
105/// Download and apply the latest update atomically.
106pub fn apply_update(layout: &SpoolLayout) -> Result<UpdateApplyReport> {
107    let mut messages = Vec::new();
108    let mut state = BootstrapState::load(&layout.version_file())
109        .context("loading bootstrap state for update apply")?;
110    let from_version = state.service.as_ref().map(|s| s.version.clone());
111
112    let release = fetch_latest_release().context("fetching latest release metadata")?;
113    let latest_version = strip_v_prefix(&release.tag_name).to_string();
114
115    let asset_name = expected_asset_name();
116    let asset = release
117        .assets
118        .iter()
119        .find(|a| a.name == asset_name)
120        .ok_or_else(|| {
121            anyhow::anyhow!(
122                "no asset matching '{asset_name}' in release {}",
123                release.tag_name
124            )
125        })?;
126
127    messages.push(format!("downloading {} ({} bytes)", asset.name, asset.size));
128    let tarball_bytes = download_to_memory(&asset.browser_download_url)
129        .with_context(|| format!("downloading {}", asset.browser_download_url))?;
130    let bytes_downloaded = tarball_bytes.len() as u64;
131
132    // Stage extraction in bin.new/, then atomic swap.
133    let bin_new = layout.root().join("bin.new");
134    let bin_old = layout.root().join("bin.old");
135    let bin_dir = layout.bin_dir();
136
137    if bin_new.exists() {
138        std::fs::remove_dir_all(&bin_new).ok();
139    }
140    std::fs::create_dir_all(&bin_new).with_context(|| format!("creating {}", bin_new.display()))?;
141
142    extract_tarball(&tarball_bytes, &bin_new)
143        .with_context(|| format!("extracting tarball to {}", bin_new.display()))?;
144    messages.push(format!("extracted to {}", bin_new.display()));
145
146    // Atomic-ish swap. `rename` is atomic on the same filesystem.
147    if bin_old.exists() {
148        std::fs::remove_dir_all(&bin_old).ok();
149    }
150    if bin_dir.exists() {
151        std::fs::rename(&bin_dir, &bin_old)
152            .with_context(|| format!("renaming {} → {}", bin_dir.display(), bin_old.display()))?;
153    }
154    std::fs::rename(&bin_new, &bin_dir)
155        .with_context(|| format!("renaming {} → {}", bin_new.display(), bin_dir.display()))?;
156    if bin_old.exists() {
157        let _ = std::fs::remove_dir_all(&bin_old);
158    }
159    messages.push("atomic swap complete".to_string());
160
161    state.service = Some(ServiceVersion {
162        version: latest_version.clone(),
163        released_at: chrono_now(),
164    });
165    state
166        .save(&layout.version_file())
167        .context("persisting updated state")?;
168    messages.push(format!("version.json updated to {latest_version}"));
169
170    Ok(UpdateApplyReport {
171        success: true,
172        from_version,
173        to_version: latest_version,
174        bytes_downloaded,
175        messages,
176    })
177}
178
179fn fetch_latest_release() -> Result<ReleaseMeta> {
180    let client = reqwest::blocking::Client::builder()
181        .user_agent(USER_AGENT)
182        .timeout(Duration::from_secs(15))
183        .build()
184        .context("building HTTP client")?;
185    let response = client
186        .get(RELEASES_API)
187        .send()
188        .context("GET /releases/latest")?;
189    let status = response.status();
190    if !status.is_success() {
191        bail!("GitHub API returned status {status}");
192    }
193    response
194        .json::<ReleaseMeta>()
195        .context("parsing release JSON")
196}
197
198fn download_to_memory(url: &str) -> Result<Vec<u8>> {
199    let client = reqwest::blocking::Client::builder()
200        .user_agent(USER_AGENT)
201        .timeout(Duration::from_secs(120))
202        .build()?;
203    let response = client.get(url).send()?;
204    let status = response.status();
205    if !status.is_success() {
206        bail!("download returned status {status}");
207    }
208    let bytes = response.bytes()?;
209    Ok(bytes.to_vec())
210}
211
212fn extract_tarball(bytes: &[u8], target: &Path) -> Result<()> {
213    let cursor = std::io::Cursor::new(bytes);
214    let decoder = flate2::read::GzDecoder::new(cursor);
215    let mut archive = tar::Archive::new(decoder);
216    archive.unpack(target)?;
217
218    // Set executable permissions on Unix.
219    #[cfg(unix)]
220    {
221        use std::os::unix::fs::PermissionsExt;
222        for entry in std::fs::read_dir(target)? {
223            let entry = entry?;
224            let path = entry.path();
225            if path.is_file() {
226                let mut perms = std::fs::metadata(&path)?.permissions();
227                perms.set_mode(0o755);
228                std::fs::set_permissions(&path, perms)?;
229            }
230        }
231    }
232
233    Ok(())
234}
235
236/// Asset filename for the current platform — must match the names produced
237/// by `.github/workflows/build-binaries.yml`.
238fn expected_asset_name() -> String {
239    let platform = if cfg!(target_os = "macos") {
240        if cfg!(target_arch = "aarch64") {
241            "macos-arm64"
242        } else {
243            "macos-intel"
244        }
245    } else if cfg!(target_os = "linux") {
246        if cfg!(target_arch = "aarch64") {
247            "linux-arm64"
248        } else {
249            "linux-x86_64"
250        }
251    } else if cfg!(target_os = "windows") {
252        "windows-x86_64"
253    } else {
254        "unknown"
255    };
256    format!("spool-{platform}.tar.gz")
257}
258
259fn strip_v_prefix(tag: &str) -> &str {
260    tag.strip_prefix('v').unwrap_or(tag)
261}
262
263/// Compare two semver-ish version strings. Returns true when `latest`
264/// is strictly newer than `current`. Uses lexicographic compare on
265/// dot-separated numeric components; falls back to string compare for
266/// pre-release suffixes.
267fn version_is_newer(latest: &str, current: &str) -> bool {
268    let parse = |s: &str| -> Vec<u32> {
269        s.split(|c: char| !c.is_ascii_digit() && c != '.')
270            .next()
271            .unwrap_or("")
272            .split('.')
273            .filter_map(|p| p.parse::<u32>().ok())
274            .collect()
275    };
276    let l = parse(latest);
277    let c = parse(current);
278    let max_len = l.len().max(c.len());
279    for i in 0..max_len {
280        let li = l.get(i).copied().unwrap_or(0);
281        let ci = c.get(i).copied().unwrap_or(0);
282        if li > ci {
283            return true;
284        }
285        if li < ci {
286            return false;
287        }
288    }
289    false
290}
291
292fn chrono_now() -> String {
293    use std::time::{SystemTime, UNIX_EPOCH};
294    SystemTime::now()
295        .duration_since(UNIX_EPOCH)
296        .map(|d| d.as_secs().to_string())
297        .unwrap_or_else(|_| "0".to_string())
298}
299
300/// Compute SHA256 of bytes — exposed for tests and future checksum
301/// verification.
302#[allow(dead_code)]
303pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
304    use sha2::{Digest, Sha256};
305    let mut hasher = Sha256::new();
306    hasher.update(bytes);
307    let digest = hasher.finalize();
308    digest.iter().map(|b| format!("{b:02x}")).collect()
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn version_is_newer_compares_numerically() {
317        assert!(version_is_newer("0.2.0", "0.1.9"));
318        assert!(version_is_newer("0.1.10", "0.1.9"));
319        assert!(version_is_newer("1.0.0", "0.99.99"));
320        assert!(!version_is_newer("0.1.0", "0.1.0"));
321        assert!(!version_is_newer("0.1.0", "0.1.1"));
322    }
323
324    #[test]
325    fn version_is_newer_strips_pre_release_suffix() {
326        // "0.1.2-alpha.1" parses as [0,1,2]
327        assert!(version_is_newer("0.1.2", "0.1.1-alpha.1"));
328        assert!(!version_is_newer("0.1.2-alpha.1", "0.1.2"));
329    }
330
331    #[test]
332    fn strip_v_prefix_handles_both_forms() {
333        assert_eq!(strip_v_prefix("v0.1.2"), "0.1.2");
334        assert_eq!(strip_v_prefix("0.1.2"), "0.1.2");
335        assert_eq!(strip_v_prefix(""), "");
336    }
337
338    #[test]
339    fn expected_asset_name_is_platform_specific() {
340        let name = expected_asset_name();
341        assert!(name.starts_with("spool-"));
342        assert!(name.ends_with(".tar.gz"));
343    }
344
345    #[test]
346    fn sha256_hex_is_64_chars() {
347        let hex = sha256_hex(b"hello");
348        assert_eq!(hex.len(), 64);
349        assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
350    }
351}