Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/murmur-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,9 @@ pub struct Config {
/// Auto-generate summary when meeting ends
#[serde(default)]
pub auto_summary: bool,
/// Automatically check for and apply updates on startup (default: false)
#[serde(default)]
pub auto_update: bool,
}

fn default_true() -> bool {
Expand Down Expand Up @@ -284,6 +287,7 @@ impl Default for Config {
ollama_url: default_ollama_url(),
sessions_dir: None,
auto_summary: false,
auto_update: false,
}
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/murmur-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ pub mod context;
pub mod input;
pub mod llm;
pub mod transcription;
pub mod update;
330 changes: 330 additions & 0 deletions crates/murmur-core/src/update.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,330 @@
//! Self-update: check GitHub Releases for newer versions, download and replace
//! the running binary in-place.
//!
//! Replacing at the same path preserves macOS TCC grants (Accessibility,
//! Input Monitoring, Microphone).

use anyhow::{bail, Context, Result};
use log::info;
use serde::Deserialize;
use std::fs;
use std::path::{Path, PathBuf};

const GITHUB_REPO: &str = "jafreck/murmur";

// ── Public types ────────────────────────────────────────────────────────────

/// Information about an available update.
#[derive(Debug, Clone)]
pub struct UpdateInfo {
pub current_version: String,
pub latest_version: String,
pub download_url: String,
pub tag: String,
}

// ── GitHub API types ────────────────────────────────────────────────────────

#[derive(Deserialize)]
struct GitHubRelease {
tag_name: String,
assets: Vec<GitHubAsset>,
}

#[derive(Deserialize)]
struct GitHubAsset {
name: String,
browser_download_url: String,
}

// ── Version comparison ──────────────────────────────────────────────────────

/// Parse a version string like "0.1.2" into (major, minor, patch).
fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
let v = v.strip_prefix('v').unwrap_or(v);
let mut parts = v.splitn(3, '.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
Some((major, minor, patch))
}

/// Returns true if `latest` is strictly newer than `current`.
fn is_newer(current: &str, latest: &str) -> bool {
match (parse_version(current), parse_version(latest)) {
(Some(c), Some(l)) => l > c,
_ => false,
}
}

// ── Platform detection ──────────────────────────────────────────────────────

/// The artifact name (without extension) for this platform.
fn platform_artifact() -> Result<&'static str> {
if cfg!(target_os = "macos") && cfg!(target_arch = "aarch64") {
Ok("murmur-darwin-arm64")
} else if cfg!(target_os = "macos") && cfg!(target_arch = "x86_64") {
Ok("murmur-darwin-x86_64")
} else if cfg!(target_os = "linux") && cfg!(target_arch = "x86_64") {
Ok("murmur-linux-x86_64")
} else if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
Ok("murmur-windows-x86_64")
} else {
bail!("No pre-built binary for this platform")
}
}

/// The archive extension for this platform.
fn archive_extension() -> &'static str {
if cfg!(target_os = "windows") {
"zip"
} else {
"tar.gz"
}
}

// ── Public API ──────────────────────────────────────────────────────────────

/// Check GitHub Releases for a version newer than `current_version`.
///
/// Returns `None` when the running version is already the latest.
pub fn check_for_update(current_version: &str) -> Result<Option<UpdateInfo>> {
let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases/latest");

let client = reqwest::blocking::Client::builder()
.user_agent("murmur-updater")
.build()?;

let resp = client
.get(&url)
.send()
.context("Failed to reach GitHub Releases API")?;

if !resp.status().is_success() {
bail!(
"GitHub API returned {} when checking for updates",
resp.status()
);
}

let release: GitHubRelease = resp.json().context("Failed to parse release JSON")?;
let latest = release
.tag_name
.strip_prefix('v')
.unwrap_or(&release.tag_name);

if !is_newer(current_version, latest) {
return Ok(None);
}

let artifact = platform_artifact()?;
let ext = archive_extension();
let asset_name = format!("{artifact}.{ext}");

let asset = release
.assets
.iter()
.find(|a| a.name == asset_name)
.with_context(|| format!("Release {} has no asset '{asset_name}'", release.tag_name))?;

Ok(Some(UpdateInfo {
current_version: current_version.to_string(),
latest_version: latest.to_string(),
download_url: asset.browser_download_url.clone(),
tag: release.tag_name,
}))
}

/// Download the release artifact and replace the current binary in-place.
///
/// `progress` is called with human-readable status messages.
pub fn apply_update(info: &UpdateInfo, progress: impl Fn(&str)) -> Result<()> {
let current_exe =
std::env::current_exe().context("Cannot determine current executable path")?;
let artifact = platform_artifact()?;

// Download to a temporary directory
let tmp_dir = tempdir().context("Failed to create temp directory")?;
let archive_name = format!("{artifact}.{}", archive_extension());
let archive_path = tmp_dir.join(&archive_name);

progress("Downloading update...");
download_file(&info.download_url, &archive_path)?;

// Extract
progress("Extracting...");
extract_archive(&archive_path, &tmp_dir)?;

let extracted_binary = tmp_dir.join(artifact);
if !extracted_binary.exists() {
bail!(
"Expected binary '{}' not found after extraction",
extracted_binary.display()
);
}

// Replace the running binary
progress("Installing...");
replace_binary(&extracted_binary, &current_exe)?;

// Platform-specific post-install
#[cfg(target_os = "macos")]
{
progress("Re-signing binary...");
codesign(&current_exe);
}

// Clean up
let _ = fs::remove_dir_all(&tmp_dir);

info!(
"Updated murmur from v{} to v{}",
info.current_version, info.latest_version
);
progress(&format!(
"Updated to v{} (was v{})",
info.latest_version, info.current_version
));

Ok(())
}

// ── Internal helpers ────────────────────────────────────────────────────────

fn tempdir() -> Result<PathBuf> {
let dir = std::env::temp_dir().join(format!("murmur-update-{}", std::process::id()));
fs::create_dir_all(&dir)?;
Ok(dir)
}

fn download_file(url: &str, dest: &Path) -> Result<()> {
let client = reqwest::blocking::Client::builder()
.user_agent("murmur-updater")
.build()?;

let resp = client
.get(url)
.send()
.context("Failed to download update")?;

if !resp.status().is_success() {
bail!("Download failed with HTTP {}", resp.status());
}

let bytes = resp.bytes()?;
fs::write(dest, &bytes).context("Failed to write archive")?;
Ok(())
}

fn extract_archive(archive: &Path, dest: &Path) -> Result<()> {
#[cfg(not(target_os = "windows"))]
{
let status = std::process::Command::new("tar")
.args(["xzf", &archive.to_string_lossy()])
.current_dir(dest)
.status()
.context("Failed to run tar")?;

if !status.success() {
bail!("tar extraction failed");
}
}

#[cfg(target_os = "windows")]
{
let status = std::process::Command::new("powershell")
.args([
"-NoProfile",
"-Command",
&format!(
"Expand-Archive -Path '{}' -DestinationPath '{}' -Force",
archive.to_string_lossy(),
dest.to_string_lossy()
),
])
.status()
.context("Failed to run PowerShell Expand-Archive")?;

if !status.success() {
bail!("Archive extraction failed");
}
}

Ok(())
}

/// Replace the target binary with the new one using atomic rename where
/// possible. On failure, falls back to copy.
fn replace_binary(new_binary: &Path, target: &Path) -> Result<()> {
// Preserve permissions from the existing binary
#[cfg(unix)]
let permissions = fs::metadata(target).ok().map(|m| m.permissions());

// Remove macOS quarantine attribute
#[cfg(target_os = "macos")]
{
let _ = std::process::Command::new("xattr")
.args(["-d", "com.apple.quarantine", &new_binary.to_string_lossy()])
.status();
}

// Try atomic rename first (works if same filesystem)
if fs::rename(new_binary, target).is_err() {
// Fall back to copy
fs::copy(new_binary, target).context("Failed to copy new binary into place")?;
}

// Restore permissions
#[cfg(unix)]
if let Some(perms) = permissions {
use std::os::unix::fs::PermissionsExt;
let mut p = perms;
p.set_mode(p.mode() | 0o111); // ensure executable
fs::set_permissions(target, p).ok();
}

Ok(())
}

/// Ad-hoc codesign the binary so macOS TCC recognises it.
#[cfg(target_os = "macos")]
fn codesign(binary: &Path) {
let _ = std::process::Command::new("codesign")
.args(["-s", "-", "-f", &binary.to_string_lossy()])
.status();
}

// ── Tests ───────────────────────────────────────────────────────────────────

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

#[test]
fn test_parse_version() {
assert_eq!(parse_version("0.1.2"), Some((0, 1, 2)));
assert_eq!(parse_version("v1.2.3"), Some((1, 2, 3)));
assert_eq!(parse_version("10.20.30"), Some((10, 20, 30)));
assert_eq!(parse_version("bad"), None);
assert_eq!(parse_version("1.2"), None);
}

#[test]
fn test_is_newer() {
assert!(is_newer("0.1.0", "0.1.1"));
assert!(is_newer("0.1.1", "0.2.0"));
assert!(is_newer("0.1.1", "1.0.0"));
assert!(!is_newer("0.1.1", "0.1.1"));
assert!(!is_newer("0.2.0", "0.1.9"));
assert!(is_newer("0.1.1", "v0.1.2"));
}

#[test]
fn test_platform_artifact() {
// Should return a valid artifact name on any CI platform
let result = platform_artifact();
assert!(result.is_ok());
let name = result.unwrap();
assert!(name.starts_with("murmur-"));
}
}
Loading