1use std::io::Read;
2use std::path::Path;
3use std::sync::mpsc;
4
5use anyhow::{Context, Result};
6use log::{debug, info, warn};
7
8use crate::event::AppEvent;
9
10pub fn current_version() -> &'static str {
12 env!("CARGO_PKG_VERSION")
13}
14
15const HEADLINE_MAX_BYTES: usize = 200;
18
19fn extract_headline(notes: &str) -> Option<String> {
23 let line = notes
24 .lines()
25 .map(|l| l.trim())
26 .find(|l| !l.is_empty() && !l.starts_with('#'))?;
27 let trimmed = line.strip_prefix("- ").unwrap_or(line);
28 if trimmed.len() <= HEADLINE_MAX_BYTES {
29 return Some(trimmed.to_string());
30 }
31 let mut cut = HEADLINE_MAX_BYTES;
32 while cut > 0 && !trimmed.is_char_boundary(cut) {
33 cut -= 1;
34 }
35 Some(trimmed[..cut].to_string())
36}
37
38fn parse_version(v: &str) -> Option<(u32, u32, u32)> {
40 let mut parts = v.splitn(3, '.');
41 let major = parts.next()?.parse().ok()?;
42 let minor = parts.next()?.parse().ok()?;
43 let patch = parts.next()?.parse().ok()?;
44 Some((major, minor, patch))
45}
46
47fn is_newer(current: &str, latest: &str) -> bool {
49 match (parse_version(current), parse_version(latest)) {
50 (Some(c), Some(l)) => l > c,
51 _ => false,
52 }
53}
54
55struct ReleaseInfo {
57 version: String,
58 notes: String,
60}
61
62fn extract_release_info(json: &serde_json::Value) -> Result<ReleaseInfo> {
64 let tag = json["tag_name"]
65 .as_str()
66 .context("Missing tag_name in release")?;
67
68 let version = tag.strip_prefix('v').unwrap_or(tag);
69
70 if parse_version(version).is_none() {
71 anyhow::bail!("Invalid version format: {}", version);
72 }
73
74 let notes = json["body"].as_str().unwrap_or("").to_string();
75
76 Ok(ReleaseInfo {
77 version: version.to_string(),
78 notes,
79 })
80}
81
82fn check_latest_release(agent: &ureq::Agent) -> Result<ReleaseInfo> {
84 let mut resp = agent
85 .get("https://api.github.com/repos/erickochen/purple/releases/latest")
86 .header("Accept", "application/vnd.github+json")
87 .header("User-Agent", &format!("purple-ssh/{}", current_version()))
88 .call()
89 .context("Failed to fetch latest release. GitHub may be rate-limited.")?;
90
91 let mut body = Vec::new();
92 resp.body_mut()
93 .as_reader()
94 .take(1_048_576) .read_to_end(&mut body)
96 .context("Failed to read release JSON")?;
97
98 let json: serde_json::Value =
99 serde_json::from_slice(&body).context("Failed to parse release JSON")?;
100
101 extract_release_info(&json)
102}
103
104const VERSION_CHECK_TTL: std::time::Duration = std::time::Duration::from_secs(60 * 60);
106
107#[derive(Debug, PartialEq)]
109struct CachedVersion {
110 version: String,
111 headline: Option<String>,
112}
113
114fn parse_version_cache(
120 content: &str,
121 now_secs: u64,
122 current: &str,
123) -> Option<Option<CachedVersion>> {
124 let mut lines = content.lines();
125 let timestamp: u64 = lines.next()?.parse().ok()?;
126 let version = lines.next()?.to_string();
127 let headline = lines
128 .next()
129 .map(|s| s.to_string())
130 .filter(|s| !s.is_empty());
131
132 if version.is_empty() || parse_version(&version).is_none() {
133 return None; }
135
136 if now_secs.saturating_sub(timestamp) > VERSION_CHECK_TTL.as_secs() {
137 return None; }
139
140 if is_newer(current, &version) {
141 Some(Some(CachedVersion { version, headline }))
142 } else {
143 Some(None) }
145}
146
147fn read_cached_version() -> Option<Option<CachedVersion>> {
152 let path = dirs::home_dir()?.join(".purple").join("last_version_check");
153 let content = std::fs::read_to_string(&path).ok()?;
154 let now = std::time::SystemTime::now()
155 .duration_since(std::time::UNIX_EPOCH)
156 .ok()?
157 .as_secs();
158 parse_version_cache(&content, now, current_version())
159}
160
161fn write_version_cache(version: &str, headline: Option<&str>) {
163 let Some(dir) = dirs::home_dir().map(|h| h.join(".purple")) else {
164 return;
165 };
166 if let Err(e) = std::fs::create_dir_all(&dir) {
167 debug!("[config] Failed to create version cache directory: {e}");
168 return;
169 }
170 let now = std::time::SystemTime::now()
171 .duration_since(std::time::UNIX_EPOCH)
172 .unwrap_or_default()
173 .as_secs();
174 let hl = headline.unwrap_or("");
175 let content = format!("{}\n{}\n{}\n", now, version, hl);
176 if let Err(e) =
177 crate::fs_util::atomic_write(&dir.join("last_version_check"), content.as_bytes())
178 {
179 debug!("[config] Failed to write version cache: {e}");
180 }
181}
182
183pub fn spawn_version_check(tx: mpsc::Sender<AppEvent>) {
187 let _ = std::thread::Builder::new()
188 .name("version-check".to_string())
189 .spawn(move || {
190 debug!("Version check started");
191 match read_cached_version() {
193 Some(Some(cached)) => {
194 debug!(
195 "Version check: current={} latest={}",
196 current_version(),
197 cached.version
198 );
199 let _ = tx.send(AppEvent::UpdateAvailable {
200 version: cached.version,
201 headline: cached.headline,
202 });
203 return;
204 }
205 Some(None) => return, None => {} }
208
209 let agent = ureq::Agent::config_builder()
212 .timeout_global(Some(std::time::Duration::from_secs(5)))
213 .build()
214 .new_agent();
215
216 match check_latest_release(&agent) {
217 Ok(info) => {
218 let current = current_version();
219 debug!("Version check: current={current} latest={}", info.version);
220 let headline = extract_headline(&info.notes);
221 write_version_cache(&info.version, headline.as_deref());
222 if is_newer(current, &info.version) {
223 let _ = tx.send(AppEvent::UpdateAvailable {
224 version: info.version,
225 headline,
226 });
227 }
228 }
229 Err(err) => {
230 warn!("[external] Version check failed: {err}");
231 }
232 }
233 });
234}
235
236fn bold(text: &str) -> String {
238 if std::env::var_os("NO_COLOR").is_some() {
239 text.to_string()
240 } else {
241 format!("\x1b[1m{}\x1b[0m", text)
242 }
243}
244
245fn bold_purple(text: &str) -> String {
247 if std::env::var_os("NO_COLOR").is_some() {
248 text.to_string()
249 } else {
250 format!("\x1b[1;35m{}\x1b[0m", text)
251 }
252}
253
254enum InstallMethod {
256 Homebrew,
257 Cargo,
258 CurlOrManual,
259}
260
261fn is_homebrew_path(exe_path: &Path, cellar: &Path) -> bool {
265 if cellar.file_name().and_then(|n| n.to_str()) != Some("Cellar") {
267 return false;
268 }
269 if !exe_path.starts_with(cellar) {
271 return false;
272 }
273 exe_path
275 .strip_prefix(cellar)
276 .is_ok_and(|rest| rest.components().count() >= 1)
277}
278
279fn is_cargo_path(exe_path: &Path, cargo_home: &Path) -> bool {
281 let cargo_bin = cargo_home.join("bin");
282 exe_path.parent() == Some(cargo_bin.as_path())
283}
284
285fn detect_install_method(exe_path: &Path) -> InstallMethod {
292 if let Ok(cellar) = std::env::var("HOMEBREW_CELLAR") {
296 if is_homebrew_path(exe_path, Path::new(&cellar)) {
297 return InstallMethod::Homebrew;
298 }
299 }
300 if let Ok(prefix) = std::env::var("HOMEBREW_PREFIX") {
301 let cellar = std::path::PathBuf::from(&prefix).join("Cellar");
302 if is_homebrew_path(exe_path, &cellar) {
303 return InstallMethod::Homebrew;
304 }
305 }
306 for cellar in [
308 "/opt/homebrew/Cellar",
309 "/usr/local/Cellar",
310 "/home/linuxbrew/.linuxbrew/Cellar",
311 ] {
312 if is_homebrew_path(exe_path, Path::new(cellar)) {
313 return InstallMethod::Homebrew;
314 }
315 }
316
317 if let Ok(cargo_home) = std::env::var("CARGO_HOME") {
320 if is_cargo_path(exe_path, Path::new(&cargo_home)) {
321 return InstallMethod::Cargo;
322 }
323 }
324 if let Some(parent) = exe_path.parent() {
325 if parent.file_name().and_then(|n| n.to_str()) == Some("bin") {
326 if let Some(grandparent) = parent.parent() {
327 if grandparent.file_name().and_then(|n| n.to_str()) == Some(".cargo") {
328 return InstallMethod::Cargo;
329 }
330 }
331 }
332 }
333
334 InstallMethod::CurlOrManual
335}
336
337pub fn update_hint() -> &'static str {
339 if !matches!(std::env::consts::OS, "macos" | "linux") {
340 return "cargo install purple-ssh";
341 }
342 if let Ok(exe) = std::env::current_exe() {
343 let path = std::fs::canonicalize(&exe).unwrap_or(exe);
344 return match detect_install_method(&path) {
345 InstallMethod::Homebrew => "brew upgrade erickochen/purple/purple",
346 InstallMethod::Cargo => "cargo install purple-ssh",
347 InstallMethod::CurlOrManual => "purple update",
348 };
349 }
350 "purple update"
351}
352
353pub fn self_update() -> Result<()> {
355 if !matches!(std::env::consts::OS, "macos" | "linux") {
357 anyhow::bail!(
358 "Self-update is available on macOS and Linux only.\n \
359 Update via: cargo install purple-ssh"
360 );
361 }
362
363 println!("{}", crate::messages::update::header(&bold("purple.")));
364
365 let exe_path = std::env::current_exe().context("Failed to detect binary path")?;
367 let exe_path = std::fs::canonicalize(&exe_path).unwrap_or(exe_path);
368 println!("{}", crate::messages::update::binary_path(&exe_path));
369
370 match detect_install_method(&exe_path) {
372 InstallMethod::Homebrew => {
373 anyhow::bail!(
374 "purple appears to be installed via Homebrew.\n \
375 Update with: brew upgrade erickochen/purple/purple"
376 );
377 }
378 InstallMethod::Cargo => {
379 anyhow::bail!(
380 "purple appears to be installed via cargo.\n \
381 Update with: cargo install purple-ssh"
382 );
383 }
384 InstallMethod::CurlOrManual => {}
385 }
386
387 print!("{}", crate::messages::update::STEP_CHECKING);
389 let agent = ureq::Agent::config_builder()
390 .timeout_global(Some(std::time::Duration::from_secs(30)))
391 .build()
392 .new_agent();
393 let info = check_latest_release(&agent)?;
394 let latest = info.version;
395 let current = current_version();
396
397 if !is_newer(current, &latest) {
398 println!("{}", crate::messages::update::already_on(current));
399 return Ok(());
400 }
401
402 println!("{}", crate::messages::update::available(&latest, current));
403 info!("[purple] Update started: {current} -> {latest}");
404
405 let target = match (std::env::consts::ARCH, std::env::consts::OS) {
407 ("aarch64", "macos") => "aarch64-apple-darwin",
408 ("x86_64", "macos") => "x86_64-apple-darwin",
409 ("aarch64", "linux") => "aarch64-unknown-linux-gnu",
410 ("x86_64", "linux") => "x86_64-unknown-linux-gnu",
411 (arch, os) => anyhow::bail!("Unsupported platform: {}-{}", arch, os),
412 };
413
414 let parent = exe_path
416 .parent()
417 .context("Binary has no parent directory")?;
418
419 if std::env::var_os("SUDO_USER").is_some() {
421 eprintln!("{}", crate::messages::update::sudo_warning_line(&bold("!")));
422 }
423
424 if !is_writable(parent) {
425 anyhow::bail!(
426 "No write permission to {}.\n Check directory permissions or run with elevated privileges.",
427 parent.display()
428 );
429 }
430
431 clean_stale_staged(parent);
433
434 let tmp_dir = std::env::temp_dir().join(format!(
436 "purple_update_{}_{}",
437 std::process::id(),
438 std::time::SystemTime::now()
439 .duration_since(std::time::UNIX_EPOCH)
440 .unwrap_or_default()
441 .as_nanos()
442 ));
443 std::fs::create_dir(&tmp_dir).context("Failed to create temp directory")?;
444
445 #[cfg(unix)]
446 {
447 use std::os::unix::fs::PermissionsExt;
448 std::fs::set_permissions(&tmp_dir, std::fs::Permissions::from_mode(0o700))
449 .context("Failed to set temp directory permissions")?;
450 }
451
452 let _cleanup = TempCleanup(&tmp_dir);
454
455 let tarball_name = format!("purple-{}-{}.tar.gz", latest, target);
456 let base_url = format!(
457 "https://github.com/erickochen/purple/releases/download/v{}",
458 latest
459 );
460
461 print!("{}", crate::messages::update::step_downloading(&latest));
463 let tarball_path = tmp_dir.join(&tarball_name);
464 download_file(
465 &agent,
466 &format!("{}/{}", base_url, tarball_name),
467 &tarball_path,
468 )?;
469
470 let sha_path = tmp_dir.join(format!("{}.sha256", tarball_name));
472 download_file(
473 &agent,
474 &format!("{}/{}.sha256", base_url, tarball_name),
475 &sha_path,
476 )?;
477 println!("{}", crate::messages::update::DONE);
478
479 print!("{}", crate::messages::update::STEP_VERIFYING_CHECKSUM);
481 verify_checksum(&tarball_path, &sha_path)?;
482 println!("{}", crate::messages::update::CHECKSUM_OK);
483
484 print!("{}", crate::messages::update::STEP_INSTALLING);
486 let status = std::process::Command::new("tar")
487 .arg("-xzf")
488 .arg(&tarball_path)
489 .arg("-C")
490 .arg(&tmp_dir)
491 .status()
492 .context("Failed to run tar")?;
493 if !status.success() {
494 anyhow::bail!("tar extraction failed");
495 }
496
497 let new_binary = tmp_dir.join("purple");
498 if !new_binary.exists() {
499 anyhow::bail!("Binary not found in archive");
500 }
501
502 let staged_path = parent.join(format!(".purple_new_{}", std::process::id()));
506 {
507 use std::io::Write;
508 let source = std::fs::read(&new_binary).context("Failed to read new binary")?;
509 let mut dest = std::fs::OpenOptions::new()
510 .write(true)
511 .create_new(true) .open(&staged_path)
513 .context("Failed to create staged binary")?;
514 dest.write_all(&source)
515 .context("Failed to write staged binary")?;
516 }
517
518 #[cfg(unix)]
519 {
520 use std::os::unix::fs::PermissionsExt;
521 std::fs::set_permissions(&staged_path, std::fs::Permissions::from_mode(0o755))
522 .context("Failed to set permissions")?;
523 }
524
525 if let Err(e) = std::fs::rename(&staged_path, &exe_path) {
526 let _ = std::fs::remove_file(&staged_path);
528 return Err(e).context("Failed to replace binary");
529 }
530
531 println!("{}", crate::messages::update::DONE);
532 info!("[purple] Update completed: {latest}");
533 println!(
534 "{}",
535 crate::messages::update::installed_at(
536 &bold_purple(&format!("purple v{}", latest)),
537 &exe_path,
538 )
539 );
540
541 println!("{}", crate::messages::update::whats_new_hint_indented());
542 println!();
543
544 Ok(())
545}
546
547fn download_file(agent: &ureq::Agent, url: &str, dest: &Path) -> Result<()> {
549 let mut resp = agent
550 .get(url)
551 .call()
552 .with_context(|| format!("Failed to download {}", url))?;
553
554 let mut bytes = Vec::new();
555 resp.body_mut()
556 .as_reader()
557 .take(100 * 1024 * 1024) .read_to_end(&mut bytes)
559 .context("Failed to read download")?;
560
561 if bytes.is_empty() {
562 anyhow::bail!("Empty response from {}", url);
563 }
564
565 crate::fs_util::atomic_write(dest, &bytes).context("Failed to write file")?;
566 Ok(())
567}
568
569fn verify_checksum(file: &Path, sha_file: &Path) -> Result<()> {
571 let expected = std::fs::read_to_string(sha_file).context("Failed to read checksum file")?;
572 let expected = expected
573 .split_whitespace()
574 .next()
575 .context("Empty checksum file")?;
576
577 use sha2::{Digest, Sha256};
578 let bytes = std::fs::read(file).context("Failed to read file for checksum")?;
579 let actual = format!("{:x}", Sha256::digest(&bytes));
580
581 if expected != actual {
582 anyhow::bail!(
583 "Checksum mismatch.\n Expected: {}\n Got: {}",
584 expected,
585 actual
586 );
587 }
588
589 Ok(())
590}
591
592fn clean_stale_staged(dir: &Path) {
594 if let Ok(entries) = std::fs::read_dir(dir) {
595 for entry in entries.flatten() {
596 if let Some(name) = entry.file_name().to_str() {
597 if name.starts_with(".purple_new_") {
598 let _ = std::fs::remove_file(entry.path());
599 }
600 }
601 }
602 }
603}
604
605fn is_writable(path: &Path) -> bool {
607 let probe = path.join(format!(".purple_write_test_{}", std::process::id()));
608 if std::fs::File::create(&probe).is_ok() {
609 let _ = std::fs::remove_file(&probe);
610 true
611 } else {
612 false
613 }
614}
615
616struct TempCleanup<'a>(&'a Path);
618
619impl Drop for TempCleanup<'_> {
620 fn drop(&mut self) {
621 let _ = std::fs::remove_dir_all(self.0);
622 }
623}
624
625#[cfg(test)]
626#[path = "update_tests.rs"]
627mod tests;