1use super::time_source::SharedTimeSource;
10use std::env;
11use std::path::{Path, PathBuf};
12use std::sync::mpsc::{self, Receiver, TryRecvError};
13use std::thread::{self, JoinHandle};
14use std::time::Duration;
15
16pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
18
19pub const DEFAULT_RELEASES_URL: &str = "https://api.github.com/repos/sinelaw/fresh/releases/latest";
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum InstallMethod {
25 Homebrew,
27 Cargo,
29 Npm,
31 PackageManager,
33 Aur,
35 Unknown,
37}
38
39impl InstallMethod {
40 pub fn update_command(&self) -> Option<&'static str> {
42 Some(match self {
43 Self::Homebrew => "brew upgrade fresh-editor",
44 Self::Cargo => "cargo install --locked fresh-editor",
45 Self::Npm => "npm update -g @fresh-editor/fresh-editor",
46 Self::Aur => "yay -Syu fresh-editor # or use your AUR helper",
47 Self::PackageManager => "Update using your system package manager",
48 Self::Unknown => return None,
49 })
50 }
51}
52
53#[derive(Debug, Clone)]
55pub struct ReleaseCheckResult {
56 pub latest_version: String,
58 pub update_available: bool,
60 pub install_method: InstallMethod,
62}
63
64pub struct UpdateCheckHandle {
68 receiver: Receiver<Result<ReleaseCheckResult, String>>,
69 #[allow(dead_code)]
70 thread: JoinHandle<()>,
71}
72
73impl UpdateCheckHandle {
74 pub fn try_get_result(self) -> Option<Result<ReleaseCheckResult, String>> {
78 match self.receiver.try_recv() {
79 Ok(result) => {
80 tracing::debug!("Update check completed");
81 Some(result)
82 }
83 Err(TryRecvError::Empty) => {
84 tracing::debug!("Update check still running, abandoning");
86 drop(self.thread);
87 None
88 }
89 Err(TryRecvError::Disconnected) => {
90 tracing::debug!("Update check thread disconnected");
92 None
93 }
94 }
95 }
96}
97
98pub struct UpdateChecker {
103 receiver: Receiver<Result<ReleaseCheckResult, String>>,
105 #[allow(dead_code)]
107 thread: JoinHandle<()>,
108 last_result: Option<ReleaseCheckResult>,
110}
111
112pub type PeriodicUpdateChecker = UpdateChecker;
114
115impl UpdateChecker {
116 pub fn poll_result(&mut self) -> Option<Result<ReleaseCheckResult, String>> {
121 match self.receiver.try_recv() {
122 Ok(result) => {
123 if let Ok(ref release_result) = result {
124 tracing::debug!(
125 "Update check completed: update_available={}",
126 release_result.update_available
127 );
128 self.last_result = Some(release_result.clone());
129 }
130 Some(result)
131 }
132 Err(TryRecvError::Empty) => None,
133 Err(TryRecvError::Disconnected) => None,
134 }
135 }
136
137 pub fn get_cached_result(&self) -> Option<&ReleaseCheckResult> {
139 self.last_result.as_ref()
140 }
141
142 pub fn is_update_available(&self) -> bool {
144 self.last_result
145 .as_ref()
146 .map(|r| r.update_available)
147 .unwrap_or(false)
148 }
149
150 pub fn latest_version(&self) -> Option<&str> {
152 self.last_result.as_ref().and_then(|r| {
153 if r.update_available {
154 Some(r.latest_version.as_str())
155 } else {
156 None
157 }
158 })
159 }
160}
161
162pub fn start_periodic_update_check(
168 releases_url: &str,
169 time_source: SharedTimeSource,
170 data_dir: PathBuf,
171) -> UpdateChecker {
172 tracing::debug!("Starting update checker");
173 let url = releases_url.to_string();
174 let (tx, rx) = mpsc::channel();
175
176 let handle = thread::spawn(move || {
177 if let Some(unique_id) =
178 super::telemetry::should_run_daily_check(time_source.as_ref(), &data_dir)
179 {
180 super::telemetry::track_open(&unique_id);
181 let result = check_for_update(&url);
182 #[allow(clippy::let_underscore_must_use)]
184 let _ = tx.send(result);
185 }
186 });
187
188 UpdateChecker {
189 receiver: rx,
190 thread: handle,
191 last_result: None,
192 }
193}
194
195#[doc(hidden)]
197pub fn start_periodic_update_check_with_interval(
198 releases_url: &str,
199 _check_interval: Duration,
200 time_source: SharedTimeSource,
201 data_dir: PathBuf,
202) -> UpdateChecker {
203 start_periodic_update_check(releases_url, time_source, data_dir)
205}
206
207pub fn start_update_check(
213 releases_url: &str,
214 time_source: SharedTimeSource,
215 data_dir: PathBuf,
216) -> UpdateCheckHandle {
217 tracing::debug!("Starting background update check");
218 let url = releases_url.to_string();
219 let (tx, rx) = mpsc::channel();
220
221 let handle = thread::spawn(move || {
222 if let Some(unique_id) =
223 super::telemetry::should_run_daily_check(time_source.as_ref(), &data_dir)
224 {
225 super::telemetry::track_open(&unique_id);
226 let result = check_for_update(&url);
227 #[allow(clippy::let_underscore_must_use)]
229 let _ = tx.send(result);
230 }
231 });
232
233 UpdateCheckHandle {
234 receiver: rx,
235 thread: handle,
236 }
237}
238
239pub fn fetch_latest_version(url: &str) -> Result<String, String> {
244 tracing::debug!("Fetching latest version from {}", url);
245 let body = super::http::get_release_json(url)?;
246 let version = parse_version_from_json(&body)?;
247 tracing::debug!("Latest version: {}", version);
248 Ok(version)
249}
250
251fn parse_version_from_json(json: &str) -> Result<String, String> {
253 let tag_name_key = "\"tag_name\"";
254 let start = json
255 .find(tag_name_key)
256 .ok_or_else(|| "tag_name not found in response".to_string())?;
257
258 let after_key = &json[start + tag_name_key.len()..];
259
260 let value_start = after_key
261 .find('"')
262 .ok_or_else(|| "Invalid JSON: missing quote after tag_name".to_string())?;
263
264 let value_content = &after_key[value_start + 1..];
265 let value_end = value_content
266 .find('"')
267 .ok_or_else(|| "Invalid JSON: unclosed quote".to_string())?;
268
269 let tag = &value_content[..value_end];
270
271 Ok(tag.strip_prefix('v').unwrap_or(tag).to_string())
273}
274
275pub fn detect_install_method() -> InstallMethod {
277 match env::current_exe() {
278 Ok(path) => detect_install_method_from_path(&path),
279 Err(_) => InstallMethod::Unknown,
280 }
281}
282
283pub fn detect_install_method_from_path(exe_path: &Path) -> InstallMethod {
285 let path_str = exe_path.to_string_lossy();
286
287 if path_str.contains("/opt/homebrew/")
289 || path_str.contains("/usr/local/Cellar/")
290 || path_str.contains("/home/linuxbrew/")
291 || path_str.contains("/.linuxbrew/")
292 {
293 return InstallMethod::Homebrew;
294 }
295
296 if path_str.contains("/.cargo/bin/") || path_str.contains("\\.cargo\\bin\\") {
298 return InstallMethod::Cargo;
299 }
300
301 if path_str.contains("/node_modules/")
303 || path_str.contains("\\node_modules\\")
304 || path_str.contains("/npm/")
305 || path_str.contains("/lib/node_modules/")
306 {
307 return InstallMethod::Npm;
308 }
309
310 if path_str.starts_with("/usr/bin/") && is_arch_linux() {
312 return InstallMethod::Aur;
313 }
314
315 if path_str.starts_with("/usr/bin/")
317 || path_str.starts_with("/usr/local/bin/")
318 || path_str.starts_with("/bin/")
319 {
320 return InstallMethod::PackageManager;
321 }
322
323 InstallMethod::Unknown
324}
325
326fn is_arch_linux() -> bool {
328 std::fs::read_to_string("/etc/os-release")
329 .map(|content| content.contains("Arch Linux") || content.contains("ID=arch"))
330 .unwrap_or(false)
331}
332
333pub fn is_newer_version(current: &str, latest: &str) -> bool {
336 let parse_version = |v: &str| -> Option<(u32, u32, u32)> {
337 let parts: Vec<&str> = v.split('.').collect();
338 if parts.len() >= 3 {
339 Some((
340 parts[0].parse().ok()?,
341 parts[1].parse().ok()?,
342 parts[2].split('-').next()?.parse().ok()?,
343 ))
344 } else if parts.len() == 2 {
345 Some((parts[0].parse().ok()?, parts[1].parse().ok()?, 0))
346 } else {
347 None
348 }
349 };
350
351 match (parse_version(current), parse_version(latest)) {
352 (Some((c_major, c_minor, c_patch)), Some((l_major, l_minor, l_patch))) => {
353 (l_major, l_minor, l_patch) > (c_major, c_minor, c_patch)
354 }
355 _ => false,
356 }
357}
358
359pub fn check_for_update(releases_url: &str) -> Result<ReleaseCheckResult, String> {
361 let latest_version = fetch_latest_version(releases_url)?;
362 let install_method = detect_install_method();
363 let update_available = is_newer_version(CURRENT_VERSION, &latest_version);
364
365 tracing::debug!(
366 current = CURRENT_VERSION,
367 latest = %latest_version,
368 update_available,
369 install_method = ?install_method,
370 "Release check complete"
371 );
372
373 Ok(ReleaseCheckResult {
374 latest_version,
375 update_available,
376 install_method,
377 })
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383 use std::path::PathBuf;
384
385 #[test]
386 fn test_is_newer_version() {
387 let cases = [
389 ("0.1.26", "1.0.0", true), ("0.1.26", "0.2.0", true), ("0.1.26", "0.1.27", true), ("0.1.26", "0.1.26", false), ("0.1.26", "0.1.25", false), ("0.2.0", "0.1.26", false), ("1.0.0", "0.1.26", false), ("0.1.26-alpha", "0.1.27", true), ("0.1.26", "0.1.27-beta", true), ];
399 for (current, latest, expected) in cases {
400 assert_eq!(
401 is_newer_version(current, latest),
402 expected,
403 "is_newer_version({:?}, {:?})",
404 current,
405 latest
406 );
407 }
408 }
409
410 #[test]
411 fn test_detect_install_method() {
412 let cases = [
413 (
414 "/opt/homebrew/Cellar/fresh/0.1.26/bin/fresh",
415 InstallMethod::Homebrew,
416 ),
417 (
418 "/usr/local/Cellar/fresh/0.1.26/bin/fresh",
419 InstallMethod::Homebrew,
420 ),
421 (
422 "/home/linuxbrew/.linuxbrew/bin/fresh",
423 InstallMethod::Homebrew,
424 ),
425 ("/home/user/.cargo/bin/fresh", InstallMethod::Cargo),
426 (
427 "C:\\Users\\user\\.cargo\\bin\\fresh.exe",
428 InstallMethod::Cargo,
429 ),
430 (
431 "/usr/local/lib/node_modules/fresh-editor/bin/fresh",
432 InstallMethod::Npm,
433 ),
434 ("/usr/local/bin/fresh", InstallMethod::PackageManager),
435 ("/home/user/downloads/fresh", InstallMethod::Unknown),
436 ];
437 for (path, expected) in cases {
438 assert_eq!(
439 detect_install_method_from_path(&PathBuf::from(path)),
440 expected,
441 "detect_install_method({:?})",
442 path
443 );
444 }
445 }
446
447 #[test]
448 fn test_parse_version_from_json() {
449 let cases = [
451 (r#"{"tag_name": "v0.1.27"}"#, "0.1.27"),
452 (r#"{"tag_name": "0.1.27"}"#, "0.1.27"),
453 (
454 r#"{"tag_name": "v0.2.0", "name": "v0.2.0", "draft": false}"#,
455 "0.2.0",
456 ),
457 ];
458 for (json, expected) in cases {
459 assert_eq!(parse_version_from_json(json).unwrap(), expected);
460 }
461
462 let version = parse_version_from_json(r#"{"tag_name": "v99.0.0"}"#).unwrap();
464 assert!(is_newer_version(CURRENT_VERSION, &version));
465 }
466
467 #[test]
468 fn test_current_version_is_valid() {
469 let parts: Vec<&str> = CURRENT_VERSION.split('.').collect();
470 assert!(parts.len() >= 2, "Version should have at least major.minor");
471 assert!(parts[0].parse::<u32>().is_ok());
472 assert!(parts[1].parse::<u32>().is_ok());
473 }
474
475 use std::sync::mpsc as std_mpsc;
476
477 fn start_mock_release_server(version: &str) -> (std_mpsc::Sender<()>, String) {
480 let server = tiny_http::Server::http("127.0.0.1:0").expect("Failed to start test server");
481 let port = server.server_addr().to_ip().unwrap().port();
482 let url = format!("http://127.0.0.1:{}/releases/latest", port);
483
484 let (stop_tx, stop_rx) = std_mpsc::channel::<()>();
485
486 let version = version.to_string();
488 thread::spawn(move || {
489 loop {
490 if stop_rx.try_recv().is_ok() {
492 break;
493 }
494
495 match server.recv_timeout(Duration::from_millis(100)) {
497 Ok(Some(request)) => {
498 let response_body = format!(r#"{{"tag_name": "v{}"}}"#, version);
499 let response = tiny_http::Response::from_string(response_body).with_header(
500 tiny_http::Header::from_bytes(
501 &b"Content-Type"[..],
502 &b"application/json"[..],
503 )
504 .unwrap(),
505 );
506 drop(request.respond(response));
507 }
508 Ok(None) => {
509 }
511 Err(_) => {
512 break;
514 }
515 }
516 }
517 });
518
519 (stop_tx, url)
520 }
521
522 #[test]
523 fn test_update_checker_detects_new_version() {
524 let (stop_tx, url) = start_mock_release_server("99.0.0");
525 let time_source = super::super::time_source::TestTimeSource::shared();
526 let temp_dir = tempfile::tempdir().unwrap();
527
528 let mut checker =
529 start_periodic_update_check(&url, time_source, temp_dir.path().to_path_buf());
530
531 let start = std::time::Instant::now();
533 while start.elapsed() < Duration::from_secs(2) {
534 if checker.poll_result().is_some() {
535 break;
536 }
537 thread::sleep(Duration::from_millis(10));
538 }
539
540 assert!(checker.is_update_available());
541 assert_eq!(checker.latest_version(), Some("99.0.0"));
542
543 stop_tx.send(()).ok();
544 }
545
546 #[test]
547 fn test_update_checker_no_update_when_current() {
548 let (stop_tx, url) = start_mock_release_server(CURRENT_VERSION);
549 let time_source = super::super::time_source::TestTimeSource::shared();
550 let temp_dir = tempfile::tempdir().unwrap();
551
552 let mut checker =
553 start_periodic_update_check(&url, time_source, temp_dir.path().to_path_buf());
554
555 let start = std::time::Instant::now();
557 while start.elapsed() < Duration::from_secs(2) {
558 if checker.poll_result().is_some() {
559 break;
560 }
561 thread::sleep(Duration::from_millis(10));
562 }
563
564 assert!(!checker.is_update_available());
565 assert!(checker.latest_version().is_none());
566 assert!(checker.get_cached_result().is_some());
567
568 stop_tx.send(()).ok();
569 }
570
571 #[test]
572 fn test_update_checker_api_before_result() {
573 let (stop_tx, url) = start_mock_release_server("99.0.0");
574 let time_source = super::super::time_source::TestTimeSource::shared();
575 let temp_dir = tempfile::tempdir().unwrap();
576
577 let checker = start_periodic_update_check(&url, time_source, temp_dir.path().to_path_buf());
578
579 assert!(!checker.is_update_available());
581 assert!(checker.latest_version().is_none());
582 assert!(checker.get_cached_result().is_none());
583
584 stop_tx.send(()).ok();
585 }
586}