1use 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#[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#[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
40fn 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
52fn 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
60fn 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
77fn archive_extension() -> &'static str {
79 if cfg!(target_os = "windows") {
80 "zip"
81 } else {
82 "tar.gz"
83 }
84}
85
86pub 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
138pub 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 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 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 progress("Installing...");
168 replace_binary(&extracted_binary, ¤t_exe)?;
169
170 #[cfg(target_os = "macos")]
172 {
173 progress("Re-signing binary...");
174 codesign(¤t_exe);
175 }
176
177 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
192fn 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
256fn replace_binary(new_binary: &Path, target: &Path) -> Result<()> {
259 #[cfg(unix)]
261 let permissions = fs::metadata(target).ok().map(|m| m.permissions());
262
263 #[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 if fs::rename(new_binary, target).is_err() {
273 fs::copy(new_binary, target).context("Failed to copy new binary into place")?;
275 }
276
277 #[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); fs::set_permissions(target, p).ok();
284 }
285
286 Ok(())
287}
288
289#[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#[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 let result = platform_artifact();
326 assert!(result.is_ok());
327 let name = result.unwrap();
328 assert!(name.starts_with("murmur-"));
329 }
330}