Skip to main content

murmur_core/
update.rs

1//! Self-update: check GitHub Releases for newer versions, download and replace
2//! the running binary in-place.
3//!
4//! Replacing at the same path preserves macOS TCC grants (Accessibility,
5//! Input Monitoring, Microphone).
6
7use anyhow::{bail, Context, Result};
8use log::info;
9use serde::Deserialize;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13const GITHUB_REPO: &str = "jafreck/murmur";
14
15// ── Public types ────────────────────────────────────────────────────────────
16
17/// Information about an available update.
18#[derive(Debug, Clone)]
19pub struct UpdateInfo {
20    pub current_version: String,
21    pub latest_version: String,
22    pub download_url: String,
23    pub tag: String,
24}
25
26// ── GitHub API types ────────────────────────────────────────────────────────
27
28#[derive(Deserialize)]
29struct GitHubRelease {
30    tag_name: String,
31    assets: Vec<GitHubAsset>,
32}
33
34#[derive(Deserialize)]
35struct GitHubAsset {
36    name: String,
37    browser_download_url: String,
38}
39
40// ── Version comparison ──────────────────────────────────────────────────────
41
42/// Parse a version string like "0.1.2" into (major, minor, patch).
43fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
44    let v = v.strip_prefix('v').unwrap_or(v);
45    let mut parts = v.splitn(3, '.');
46    let major = parts.next()?.parse().ok()?;
47    let minor = parts.next()?.parse().ok()?;
48    let patch = parts.next()?.parse().ok()?;
49    Some((major, minor, patch))
50}
51
52/// Returns true if `latest` is strictly newer than `current`.
53fn is_newer(current: &str, latest: &str) -> bool {
54    match (parse_version(current), parse_version(latest)) {
55        (Some(c), Some(l)) => l > c,
56        _ => false,
57    }
58}
59
60// ── Platform detection ──────────────────────────────────────────────────────
61
62/// The artifact name (without extension) for this platform.
63fn platform_artifact() -> Result<&'static str> {
64    if cfg!(target_os = "macos") && cfg!(target_arch = "aarch64") {
65        Ok("murmur-darwin-arm64")
66    } else if cfg!(target_os = "macos") && cfg!(target_arch = "x86_64") {
67        Ok("murmur-darwin-x86_64")
68    } else if cfg!(target_os = "linux") && cfg!(target_arch = "x86_64") {
69        Ok("murmur-linux-x86_64")
70    } else if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
71        Ok("murmur-windows-x86_64")
72    } else {
73        bail!("No pre-built binary for this platform")
74    }
75}
76
77/// The archive extension for this platform.
78fn archive_extension() -> &'static str {
79    if cfg!(target_os = "windows") {
80        "zip"
81    } else {
82        "tar.gz"
83    }
84}
85
86// ── Public API ──────────────────────────────────────────────────────────────
87
88/// Check GitHub Releases for a version newer than `current_version`.
89///
90/// Returns `None` when the running version is already the latest.
91pub fn check_for_update(current_version: &str) -> Result<Option<UpdateInfo>> {
92    let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases/latest");
93
94    let client = reqwest::blocking::Client::builder()
95        .user_agent("murmur-updater")
96        .build()?;
97
98    let resp = client
99        .get(&url)
100        .send()
101        .context("Failed to reach GitHub Releases API")?;
102
103    if !resp.status().is_success() {
104        bail!(
105            "GitHub API returned {} when checking for updates",
106            resp.status()
107        );
108    }
109
110    let release: GitHubRelease = resp.json().context("Failed to parse release JSON")?;
111    let latest = release
112        .tag_name
113        .strip_prefix('v')
114        .unwrap_or(&release.tag_name);
115
116    if !is_newer(current_version, latest) {
117        return Ok(None);
118    }
119
120    let artifact = platform_artifact()?;
121    let ext = archive_extension();
122    let asset_name = format!("{artifact}.{ext}");
123
124    let asset = release
125        .assets
126        .iter()
127        .find(|a| a.name == asset_name)
128        .with_context(|| format!("Release {} has no asset '{asset_name}'", release.tag_name))?;
129
130    Ok(Some(UpdateInfo {
131        current_version: current_version.to_string(),
132        latest_version: latest.to_string(),
133        download_url: asset.browser_download_url.clone(),
134        tag: release.tag_name,
135    }))
136}
137
138/// Download the release artifact and replace the current binary in-place.
139///
140/// `progress` is called with human-readable status messages.
141pub fn apply_update(info: &UpdateInfo, progress: impl Fn(&str)) -> Result<()> {
142    let current_exe =
143        std::env::current_exe().context("Cannot determine current executable path")?;
144    let artifact = platform_artifact()?;
145
146    // Download to a temporary directory
147    let tmp_dir = tempdir().context("Failed to create temp directory")?;
148    let archive_name = format!("{artifact}.{}", archive_extension());
149    let archive_path = tmp_dir.join(&archive_name);
150
151    progress("Downloading update...");
152    download_file(&info.download_url, &archive_path)?;
153
154    // Extract
155    progress("Extracting...");
156    extract_archive(&archive_path, &tmp_dir)?;
157
158    let extracted_binary = tmp_dir.join(artifact);
159    if !extracted_binary.exists() {
160        bail!(
161            "Expected binary '{}' not found after extraction",
162            extracted_binary.display()
163        );
164    }
165
166    // Replace the running binary
167    progress("Installing...");
168    replace_binary(&extracted_binary, &current_exe)?;
169
170    // Platform-specific post-install
171    #[cfg(target_os = "macos")]
172    {
173        progress("Re-signing binary...");
174        codesign(&current_exe);
175    }
176
177    // Clean up
178    let _ = fs::remove_dir_all(&tmp_dir);
179
180    info!(
181        "Updated murmur from v{} to v{}",
182        info.current_version, info.latest_version
183    );
184    progress(&format!(
185        "Updated to v{} (was v{})",
186        info.latest_version, info.current_version
187    ));
188
189    Ok(())
190}
191
192// ── Internal helpers ────────────────────────────────────────────────────────
193
194fn tempdir() -> Result<PathBuf> {
195    let dir = std::env::temp_dir().join(format!("murmur-update-{}", std::process::id()));
196    fs::create_dir_all(&dir)?;
197    Ok(dir)
198}
199
200fn download_file(url: &str, dest: &Path) -> Result<()> {
201    let client = reqwest::blocking::Client::builder()
202        .user_agent("murmur-updater")
203        .build()?;
204
205    let resp = client
206        .get(url)
207        .send()
208        .context("Failed to download update")?;
209
210    if !resp.status().is_success() {
211        bail!("Download failed with HTTP {}", resp.status());
212    }
213
214    let bytes = resp.bytes()?;
215    fs::write(dest, &bytes).context("Failed to write archive")?;
216    Ok(())
217}
218
219fn extract_archive(archive: &Path, dest: &Path) -> Result<()> {
220    #[cfg(not(target_os = "windows"))]
221    {
222        let status = std::process::Command::new("tar")
223            .args(["xzf", &archive.to_string_lossy()])
224            .current_dir(dest)
225            .status()
226            .context("Failed to run tar")?;
227
228        if !status.success() {
229            bail!("tar extraction failed");
230        }
231    }
232
233    #[cfg(target_os = "windows")]
234    {
235        let status = std::process::Command::new("powershell")
236            .args([
237                "-NoProfile",
238                "-Command",
239                &format!(
240                    "Expand-Archive -Path '{}' -DestinationPath '{}' -Force",
241                    archive.to_string_lossy(),
242                    dest.to_string_lossy()
243                ),
244            ])
245            .status()
246            .context("Failed to run PowerShell Expand-Archive")?;
247
248        if !status.success() {
249            bail!("Archive extraction failed");
250        }
251    }
252
253    Ok(())
254}
255
256/// Replace the target binary with the new one using atomic rename where
257/// possible. On failure, falls back to copy.
258fn replace_binary(new_binary: &Path, target: &Path) -> Result<()> {
259    // Preserve permissions from the existing binary
260    #[cfg(unix)]
261    let permissions = fs::metadata(target).ok().map(|m| m.permissions());
262
263    // Remove macOS quarantine attribute
264    #[cfg(target_os = "macos")]
265    {
266        let _ = std::process::Command::new("xattr")
267            .args(["-d", "com.apple.quarantine", &new_binary.to_string_lossy()])
268            .status();
269    }
270
271    // Try atomic rename first (works if same filesystem)
272    if fs::rename(new_binary, target).is_err() {
273        // Fall back to copy
274        fs::copy(new_binary, target).context("Failed to copy new binary into place")?;
275    }
276
277    // Restore permissions
278    #[cfg(unix)]
279    if let Some(perms) = permissions {
280        use std::os::unix::fs::PermissionsExt;
281        let mut p = perms;
282        p.set_mode(p.mode() | 0o111); // ensure executable
283        fs::set_permissions(target, p).ok();
284    }
285
286    Ok(())
287}
288
289/// Ad-hoc codesign the binary so macOS TCC recognises it.
290#[cfg(target_os = "macos")]
291fn codesign(binary: &Path) {
292    let _ = std::process::Command::new("codesign")
293        .args(["-s", "-", "-f", &binary.to_string_lossy()])
294        .status();
295}
296
297// ── Tests ───────────────────────────────────────────────────────────────────
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn test_parse_version() {
305        assert_eq!(parse_version("0.1.2"), Some((0, 1, 2)));
306        assert_eq!(parse_version("v1.2.3"), Some((1, 2, 3)));
307        assert_eq!(parse_version("10.20.30"), Some((10, 20, 30)));
308        assert_eq!(parse_version("bad"), None);
309        assert_eq!(parse_version("1.2"), None);
310    }
311
312    #[test]
313    fn test_is_newer() {
314        assert!(is_newer("0.1.0", "0.1.1"));
315        assert!(is_newer("0.1.1", "0.2.0"));
316        assert!(is_newer("0.1.1", "1.0.0"));
317        assert!(!is_newer("0.1.1", "0.1.1"));
318        assert!(!is_newer("0.2.0", "0.1.9"));
319        assert!(is_newer("0.1.1", "v0.1.2"));
320    }
321
322    #[test]
323    fn test_platform_artifact() {
324        // Should return a valid artifact name on any CI platform
325        let result = platform_artifact();
326        assert!(result.is_ok());
327        let name = result.unwrap();
328        assert!(name.starts_with("murmur-"));
329    }
330}