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(
152 paths: Option<&crate::runtime::env::Paths>,
153) -> Option<Option<CachedVersion>> {
154 let path = paths?.last_version_check();
155 let content = std::fs::read_to_string(&path).ok()?;
156 let now = std::time::SystemTime::now()
157 .duration_since(std::time::UNIX_EPOCH)
158 .ok()?
159 .as_secs();
160 parse_version_cache(&content, now, current_version())
161}
162
163fn write_version_cache(
165 version: &str,
166 headline: Option<&str>,
167 paths: Option<&crate::runtime::env::Paths>,
168) {
169 let Some(paths) = paths else {
170 return;
171 };
172 let dir = paths.cache_dir();
173 if let Err(e) = std::fs::create_dir_all(dir) {
174 debug!("[config] Failed to create version cache directory: {e}");
175 return;
176 }
177 let now = std::time::SystemTime::now()
178 .duration_since(std::time::UNIX_EPOCH)
179 .unwrap_or_default()
180 .as_secs();
181 let hl = headline.unwrap_or("");
182 let content = format!("{}\n{}\n{}\n", now, version, hl);
183 if let Err(e) = crate::fs_util::atomic_write(&paths.last_version_check(), content.as_bytes()) {
184 debug!("[config] Failed to write version cache: {e}");
185 }
186}
187
188pub fn spawn_version_check(
192 tx: mpsc::Sender<AppEvent>,
193 env: std::sync::Arc<crate::runtime::env::Env>,
194) {
195 let _ = std::thread::Builder::new()
196 .name("version-check".to_string())
197 .spawn(move || {
198 debug!("[external] Version check started");
199 match read_cached_version(env.paths()) {
201 Some(Some(cached)) => {
202 debug!(
203 "[external] Version check: current={} latest={}",
204 current_version(),
205 cached.version
206 );
207 let _ = tx.send(AppEvent::UpdateAvailable {
208 version: cached.version,
209 headline: cached.headline,
210 });
211 return;
212 }
213 Some(None) => return, None => {} }
216
217 let agent = ureq::Agent::config_builder()
220 .timeout_global(Some(std::time::Duration::from_secs(5)))
221 .build()
222 .new_agent();
223
224 match check_latest_release(&agent) {
225 Ok(info) => {
226 let current = current_version();
227 debug!(
228 "[external] Version check: current={current} latest={}",
229 info.version
230 );
231 let headline = extract_headline(&info.notes);
232 write_version_cache(&info.version, headline.as_deref(), env.paths());
233 if is_newer(current, &info.version) {
234 let _ = tx.send(AppEvent::UpdateAvailable {
235 version: info.version,
236 headline,
237 });
238 }
239 }
240 Err(err) => {
241 warn!("[external] Version check failed: {err}");
242 }
243 }
244 });
245}
246
247fn bold(text: &str, no_color: bool) -> String {
249 if no_color {
250 text.to_string()
251 } else {
252 format!("\x1b[1m{}\x1b[0m", text)
253 }
254}
255
256fn bold_purple(text: &str, no_color: bool) -> String {
258 if no_color {
259 text.to_string()
260 } else {
261 format!("\x1b[1;35m{}\x1b[0m", text)
262 }
263}
264
265enum InstallMethod {
267 Homebrew,
268 Cargo,
269 CurlOrManual,
270}
271
272fn is_homebrew_path(exe_path: &Path, cellar: &Path) -> bool {
276 if cellar.file_name().and_then(|n| n.to_str()) != Some("Cellar") {
278 return false;
279 }
280 if !exe_path.starts_with(cellar) {
282 return false;
283 }
284 exe_path
286 .strip_prefix(cellar)
287 .is_ok_and(|rest| rest.components().count() >= 1)
288}
289
290fn is_cargo_path(exe_path: &Path, cargo_home: &Path) -> bool {
292 let cargo_bin = cargo_home.join("bin");
293 exe_path.parent() == Some(cargo_bin.as_path())
294}
295
296fn detect_install_method(exe_path: &Path, env: &crate::runtime::env::Env) -> InstallMethod {
303 if let Some(cellar) = env.var("HOMEBREW_CELLAR")
307 && is_homebrew_path(exe_path, Path::new(cellar))
308 {
309 return InstallMethod::Homebrew;
310 }
311 if let Some(prefix) = env.var("HOMEBREW_PREFIX") {
312 let cellar = std::path::PathBuf::from(prefix).join("Cellar");
313 if is_homebrew_path(exe_path, &cellar) {
314 return InstallMethod::Homebrew;
315 }
316 }
317 for cellar in [
319 "/opt/homebrew/Cellar",
320 "/usr/local/Cellar",
321 "/home/linuxbrew/.linuxbrew/Cellar",
322 ] {
323 if is_homebrew_path(exe_path, Path::new(cellar)) {
324 return InstallMethod::Homebrew;
325 }
326 }
327
328 if let Some(cargo_home) = env.var("CARGO_HOME")
331 && is_cargo_path(exe_path, Path::new(cargo_home))
332 {
333 return InstallMethod::Cargo;
334 }
335 if let Some(parent) = exe_path.parent()
336 && parent.file_name().and_then(|n| n.to_str()) == Some("bin")
337 && let Some(grandparent) = parent.parent()
338 && grandparent.file_name().and_then(|n| n.to_str()) == Some(".cargo")
339 {
340 return InstallMethod::Cargo;
341 }
342
343 InstallMethod::CurlOrManual
344}
345
346pub fn update_hint(env: &crate::runtime::env::Env) -> &'static str {
348 if !matches!(std::env::consts::OS, "macos" | "linux") {
349 return "cargo install purple-ssh";
350 }
351 if let Ok(exe) = std::env::current_exe() {
352 let path = std::fs::canonicalize(&exe).unwrap_or(exe);
353 return match detect_install_method(&path, env) {
354 InstallMethod::Homebrew => "brew upgrade erickochen/purple/purple",
355 InstallMethod::Cargo => "cargo install purple-ssh",
356 InstallMethod::CurlOrManual => "purple update",
357 };
358 }
359 "purple update"
360}
361
362pub fn self_update(env: &crate::runtime::env::Env) -> Result<()> {
364 if !matches!(std::env::consts::OS, "macos" | "linux") {
366 anyhow::bail!(
367 "Self-update is available on macOS and Linux only.\n \
368 Update via: cargo install purple-ssh"
369 );
370 }
371
372 let no_color = env.no_color();
373 println!(
374 "{}",
375 crate::messages::update::header(&bold("purple.", no_color))
376 );
377
378 let exe_path = std::env::current_exe().context("Failed to detect binary path")?;
380 let exe_path = std::fs::canonicalize(&exe_path).unwrap_or(exe_path);
381 println!("{}", crate::messages::update::binary_path(&exe_path));
382
383 match detect_install_method(&exe_path, env) {
385 InstallMethod::Homebrew => {
386 anyhow::bail!(
387 "purple appears to be installed via Homebrew.\n \
388 Update with: brew upgrade erickochen/purple/purple"
389 );
390 }
391 InstallMethod::Cargo => {
392 anyhow::bail!(
393 "purple appears to be installed via cargo.\n \
394 Update with: cargo install purple-ssh"
395 );
396 }
397 InstallMethod::CurlOrManual => {}
398 }
399
400 print!("{}", crate::messages::update::STEP_CHECKING);
402 let agent = ureq::Agent::config_builder()
403 .timeout_global(Some(std::time::Duration::from_secs(30)))
404 .build()
405 .new_agent();
406 let info = check_latest_release(&agent)?;
407 let latest = info.version;
408 let current = current_version();
409
410 if !is_newer(current, &latest) {
411 println!("{}", crate::messages::update::already_on(current));
412 return Ok(());
413 }
414
415 println!("{}", crate::messages::update::available(&latest, current));
416 info!("[purple] Update started: {current} -> {latest}");
417
418 let target = match (std::env::consts::ARCH, std::env::consts::OS) {
420 ("aarch64", "macos") => "aarch64-apple-darwin",
421 ("x86_64", "macos") => "x86_64-apple-darwin",
422 ("aarch64", "linux") => "aarch64-unknown-linux-gnu",
423 ("x86_64", "linux") => "x86_64-unknown-linux-gnu",
424 (arch, os) => anyhow::bail!("Unsupported platform: {}-{}", arch, os),
425 };
426
427 let parent = exe_path
429 .parent()
430 .context("Binary has no parent directory")?;
431
432 if env.var("SUDO_USER").is_some() {
434 eprintln!(
435 "{}",
436 crate::messages::update::sudo_warning_line(&bold("!", no_color))
437 );
438 }
439
440 if !is_writable(parent) {
441 anyhow::bail!(
442 "No write permission to {}.\n Check directory permissions or run with elevated privileges.",
443 parent.display()
444 );
445 }
446
447 clean_stale_staged(parent);
449
450 let tmp_dir = std::env::temp_dir().join(format!(
452 "purple_update_{}_{}",
453 std::process::id(),
454 std::time::SystemTime::now()
455 .duration_since(std::time::UNIX_EPOCH)
456 .unwrap_or_default()
457 .as_nanos()
458 ));
459 std::fs::create_dir(&tmp_dir).context("Failed to create temp directory")?;
460
461 #[cfg(unix)]
462 {
463 use std::os::unix::fs::PermissionsExt;
464 std::fs::set_permissions(&tmp_dir, std::fs::Permissions::from_mode(0o700))
465 .context("Failed to set temp directory permissions")?;
466 }
467
468 let _cleanup = TempCleanup(&tmp_dir);
470
471 let tarball_name = format!("purple-{}-{}.tar.gz", latest, target);
472 let base_url = format!(
473 "https://github.com/erickochen/purple/releases/download/v{}",
474 latest
475 );
476
477 print!("{}", crate::messages::update::step_downloading(&latest));
479 let tarball_path = tmp_dir.join(&tarball_name);
480 download_file(
481 &agent,
482 &format!("{}/{}", base_url, tarball_name),
483 &tarball_path,
484 )?;
485
486 let sha_path = tmp_dir.join(format!("{}.sha256", tarball_name));
488 download_file(
489 &agent,
490 &format!("{}/{}.sha256", base_url, tarball_name),
491 &sha_path,
492 )?;
493 println!("{}", crate::messages::update::DONE);
494
495 print!("{}", crate::messages::update::STEP_VERIFYING_CHECKSUM);
497 verify_checksum(&tarball_path, &sha_path)?;
498 println!("{}", crate::messages::update::CHECKSUM_OK);
499
500 print!("{}", crate::messages::update::STEP_INSTALLING);
502 let status = std::process::Command::new("tar")
503 .arg("-xzf")
504 .arg(&tarball_path)
505 .arg("-C")
506 .arg(&tmp_dir)
507 .status()
508 .context("Failed to run tar")?;
509 if !status.success() {
510 anyhow::bail!("tar extraction failed");
511 }
512
513 let new_binary = tmp_dir.join("purple");
514 if !new_binary.exists() {
515 anyhow::bail!("Binary not found in archive");
516 }
517
518 let staged_path = parent.join(format!(".purple_new_{}", std::process::id()));
522 {
523 use std::io::Write;
524 let source = std::fs::read(&new_binary).context("Failed to read new binary")?;
525 let mut dest = std::fs::OpenOptions::new()
526 .write(true)
527 .create_new(true) .open(&staged_path)
529 .context("Failed to create staged binary")?;
530 dest.write_all(&source)
531 .context("Failed to write staged binary")?;
532 }
533
534 #[cfg(unix)]
535 {
536 use std::os::unix::fs::PermissionsExt;
537 std::fs::set_permissions(&staged_path, std::fs::Permissions::from_mode(0o755))
538 .context("Failed to set permissions")?;
539 }
540
541 if let Err(e) = std::fs::rename(&staged_path, &exe_path) {
542 let _ = std::fs::remove_file(&staged_path);
544 return Err(e).context("Failed to replace binary");
545 }
546
547 println!("{}", crate::messages::update::DONE);
548 info!("[purple] Update completed: {latest}");
549 println!(
550 "{}",
551 crate::messages::update::installed_at(
552 &bold_purple(&format!("purple v{}", latest), no_color),
553 &exe_path,
554 )
555 );
556
557 println!("{}", crate::messages::update::whats_new_hint_indented());
558 println!();
559
560 Ok(())
561}
562
563fn download_file(agent: &ureq::Agent, url: &str, dest: &Path) -> Result<()> {
565 let mut resp = agent
566 .get(url)
567 .call()
568 .with_context(|| format!("Failed to download {}", url))?;
569
570 let mut bytes = Vec::new();
571 resp.body_mut()
572 .as_reader()
573 .take(100 * 1024 * 1024) .read_to_end(&mut bytes)
575 .context("Failed to read download")?;
576
577 if bytes.is_empty() {
578 anyhow::bail!("Empty response from {}", url);
579 }
580
581 crate::fs_util::atomic_write(dest, &bytes).context("Failed to write file")?;
582 Ok(())
583}
584
585fn verify_checksum(file: &Path, sha_file: &Path) -> Result<()> {
587 let expected = std::fs::read_to_string(sha_file).context("Failed to read checksum file")?;
588 let expected = expected
589 .split_whitespace()
590 .next()
591 .context("Empty checksum file")?;
592
593 use sha2::{Digest, Sha256};
594 let bytes = std::fs::read(file).context("Failed to read file for checksum")?;
595 let actual = format!("{:x}", Sha256::digest(&bytes));
596
597 if expected != actual {
598 anyhow::bail!(
599 "Checksum mismatch.\n Expected: {}\n Got: {}",
600 expected,
601 actual
602 );
603 }
604
605 Ok(())
606}
607
608fn clean_stale_staged(dir: &Path) {
610 if let Ok(entries) = std::fs::read_dir(dir) {
611 for entry in entries.flatten() {
612 if let Some(name) = entry.file_name().to_str()
613 && name.starts_with(".purple_new_")
614 {
615 let _ = std::fs::remove_file(entry.path());
616 }
617 }
618 }
619}
620
621fn is_writable(path: &Path) -> bool {
623 let probe = path.join(format!(".purple_write_test_{}", std::process::id()));
624 if std::fs::File::create(&probe).is_ok() {
625 let _ = std::fs::remove_file(&probe);
626 true
627 } else {
628 false
629 }
630}
631
632struct TempCleanup<'a>(&'a Path);
634
635impl Drop for TempCleanup<'_> {
636 fn drop(&mut self) {
637 let _ = std::fs::remove_dir_all(self.0);
638 }
639}
640
641#[cfg(test)]
642#[path = "update_tests.rs"]
643mod tests;