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> {
241 tracing::debug!("Fetching latest version from {}", url);
242 let agent = ureq::Agent::config_builder()
243 .timeout_global(Some(Duration::from_secs(15)))
244 .build()
245 .new_agent();
246 let response = agent
247 .get(url)
248 .header("User-Agent", "fresh-editor-update-checker")
249 .header("Accept", "application/vnd.github.v3+json")
250 .call()
251 .map_err(|e| {
252 tracing::debug!("HTTP request failed: {}", e);
253 format!("HTTP request failed: {}", e)
254 })?;
255
256 let body = response
257 .into_body()
258 .read_to_string()
259 .map_err(|e| format!("Failed to read response body: {}", e))?;
260
261 let version = parse_version_from_json(&body)?;
262 tracing::debug!("Latest version: {}", version);
263 Ok(version)
264}
265
266fn parse_version_from_json(json: &str) -> Result<String, String> {
268 let tag_name_key = "\"tag_name\"";
269 let start = json
270 .find(tag_name_key)
271 .ok_or_else(|| "tag_name not found in response".to_string())?;
272
273 let after_key = &json[start + tag_name_key.len()..];
274
275 let value_start = after_key
276 .find('"')
277 .ok_or_else(|| "Invalid JSON: missing quote after tag_name".to_string())?;
278
279 let value_content = &after_key[value_start + 1..];
280 let value_end = value_content
281 .find('"')
282 .ok_or_else(|| "Invalid JSON: unclosed quote".to_string())?;
283
284 let tag = &value_content[..value_end];
285
286 Ok(tag.strip_prefix('v').unwrap_or(tag).to_string())
288}
289
290pub fn detect_install_method() -> InstallMethod {
292 match env::current_exe() {
293 Ok(path) => detect_install_method_from_path(&path),
294 Err(_) => InstallMethod::Unknown,
295 }
296}
297
298pub fn detect_install_method_from_path(exe_path: &Path) -> InstallMethod {
300 let path_str = exe_path.to_string_lossy();
301
302 if path_str.contains("/opt/homebrew/")
304 || path_str.contains("/usr/local/Cellar/")
305 || path_str.contains("/home/linuxbrew/")
306 || path_str.contains("/.linuxbrew/")
307 {
308 return InstallMethod::Homebrew;
309 }
310
311 if path_str.contains("/.cargo/bin/") || path_str.contains("\\.cargo\\bin\\") {
313 return InstallMethod::Cargo;
314 }
315
316 if path_str.contains("/node_modules/")
318 || path_str.contains("\\node_modules\\")
319 || path_str.contains("/npm/")
320 || path_str.contains("/lib/node_modules/")
321 {
322 return InstallMethod::Npm;
323 }
324
325 if path_str.starts_with("/usr/bin/") && is_arch_linux() {
327 return InstallMethod::Aur;
328 }
329
330 if path_str.starts_with("/usr/bin/")
332 || path_str.starts_with("/usr/local/bin/")
333 || path_str.starts_with("/bin/")
334 {
335 return InstallMethod::PackageManager;
336 }
337
338 InstallMethod::Unknown
339}
340
341fn is_arch_linux() -> bool {
343 std::fs::read_to_string("/etc/os-release")
344 .map(|content| content.contains("Arch Linux") || content.contains("ID=arch"))
345 .unwrap_or(false)
346}
347
348pub fn is_newer_version(current: &str, latest: &str) -> bool {
351 let parse_version = |v: &str| -> Option<(u32, u32, u32)> {
352 let parts: Vec<&str> = v.split('.').collect();
353 if parts.len() >= 3 {
354 Some((
355 parts[0].parse().ok()?,
356 parts[1].parse().ok()?,
357 parts[2].split('-').next()?.parse().ok()?,
358 ))
359 } else if parts.len() == 2 {
360 Some((parts[0].parse().ok()?, parts[1].parse().ok()?, 0))
361 } else {
362 None
363 }
364 };
365
366 match (parse_version(current), parse_version(latest)) {
367 (Some((c_major, c_minor, c_patch)), Some((l_major, l_minor, l_patch))) => {
368 (l_major, l_minor, l_patch) > (c_major, c_minor, c_patch)
369 }
370 _ => false,
371 }
372}
373
374pub fn check_for_update(releases_url: &str) -> Result<ReleaseCheckResult, String> {
376 let latest_version = fetch_latest_version(releases_url)?;
377 let install_method = detect_install_method();
378 let update_available = is_newer_version(CURRENT_VERSION, &latest_version);
379
380 tracing::debug!(
381 current = CURRENT_VERSION,
382 latest = %latest_version,
383 update_available,
384 install_method = ?install_method,
385 "Release check complete"
386 );
387
388 Ok(ReleaseCheckResult {
389 latest_version,
390 update_available,
391 install_method,
392 })
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398 use std::path::PathBuf;
399
400 #[test]
401 fn test_is_newer_version() {
402 let cases = [
404 ("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), ];
414 for (current, latest, expected) in cases {
415 assert_eq!(
416 is_newer_version(current, latest),
417 expected,
418 "is_newer_version({:?}, {:?})",
419 current,
420 latest
421 );
422 }
423 }
424
425 #[test]
426 fn test_detect_install_method() {
427 let cases = [
428 (
429 "/opt/homebrew/Cellar/fresh/0.1.26/bin/fresh",
430 InstallMethod::Homebrew,
431 ),
432 (
433 "/usr/local/Cellar/fresh/0.1.26/bin/fresh",
434 InstallMethod::Homebrew,
435 ),
436 (
437 "/home/linuxbrew/.linuxbrew/bin/fresh",
438 InstallMethod::Homebrew,
439 ),
440 ("/home/user/.cargo/bin/fresh", InstallMethod::Cargo),
441 (
442 "C:\\Users\\user\\.cargo\\bin\\fresh.exe",
443 InstallMethod::Cargo,
444 ),
445 (
446 "/usr/local/lib/node_modules/fresh-editor/bin/fresh",
447 InstallMethod::Npm,
448 ),
449 ("/usr/local/bin/fresh", InstallMethod::PackageManager),
450 ("/home/user/downloads/fresh", InstallMethod::Unknown),
451 ];
452 for (path, expected) in cases {
453 assert_eq!(
454 detect_install_method_from_path(&PathBuf::from(path)),
455 expected,
456 "detect_install_method({:?})",
457 path
458 );
459 }
460 }
461
462 #[test]
463 fn test_parse_version_from_json() {
464 let cases = [
466 (r#"{"tag_name": "v0.1.27"}"#, "0.1.27"),
467 (r#"{"tag_name": "0.1.27"}"#, "0.1.27"),
468 (
469 r#"{"tag_name": "v0.2.0", "name": "v0.2.0", "draft": false}"#,
470 "0.2.0",
471 ),
472 ];
473 for (json, expected) in cases {
474 assert_eq!(parse_version_from_json(json).unwrap(), expected);
475 }
476
477 let version = parse_version_from_json(r#"{"tag_name": "v99.0.0"}"#).unwrap();
479 assert!(is_newer_version(CURRENT_VERSION, &version));
480 }
481
482 #[test]
483 fn test_current_version_is_valid() {
484 let parts: Vec<&str> = CURRENT_VERSION.split('.').collect();
485 assert!(parts.len() >= 2, "Version should have at least major.minor");
486 assert!(parts[0].parse::<u32>().is_ok());
487 assert!(parts[1].parse::<u32>().is_ok());
488 }
489
490 use std::sync::mpsc as std_mpsc;
491
492 fn start_mock_release_server(version: &str) -> (std_mpsc::Sender<()>, String) {
495 let server = tiny_http::Server::http("127.0.0.1:0").expect("Failed to start test server");
496 let port = server.server_addr().to_ip().unwrap().port();
497 let url = format!("http://127.0.0.1:{}/releases/latest", port);
498
499 let (stop_tx, stop_rx) = std_mpsc::channel::<()>();
500
501 let version = version.to_string();
503 thread::spawn(move || {
504 loop {
505 if stop_rx.try_recv().is_ok() {
507 break;
508 }
509
510 match server.recv_timeout(Duration::from_millis(100)) {
512 Ok(Some(request)) => {
513 let response_body = format!(r#"{{"tag_name": "v{}"}}"#, version);
514 let response = tiny_http::Response::from_string(response_body).with_header(
515 tiny_http::Header::from_bytes(
516 &b"Content-Type"[..],
517 &b"application/json"[..],
518 )
519 .unwrap(),
520 );
521 drop(request.respond(response));
522 }
523 Ok(None) => {
524 }
526 Err(_) => {
527 break;
529 }
530 }
531 }
532 });
533
534 (stop_tx, url)
535 }
536
537 #[test]
538 fn test_update_checker_detects_new_version() {
539 let (stop_tx, url) = start_mock_release_server("99.0.0");
540 let time_source = super::super::time_source::TestTimeSource::shared();
541 let temp_dir = tempfile::tempdir().unwrap();
542
543 let mut checker =
544 start_periodic_update_check(&url, time_source, temp_dir.path().to_path_buf());
545
546 let start = std::time::Instant::now();
548 while start.elapsed() < Duration::from_secs(2) {
549 if checker.poll_result().is_some() {
550 break;
551 }
552 thread::sleep(Duration::from_millis(10));
553 }
554
555 assert!(checker.is_update_available());
556 assert_eq!(checker.latest_version(), Some("99.0.0"));
557
558 stop_tx.send(()).ok();
559 }
560
561 #[test]
562 fn test_update_checker_no_update_when_current() {
563 let (stop_tx, url) = start_mock_release_server(CURRENT_VERSION);
564 let time_source = super::super::time_source::TestTimeSource::shared();
565 let temp_dir = tempfile::tempdir().unwrap();
566
567 let mut checker =
568 start_periodic_update_check(&url, time_source, temp_dir.path().to_path_buf());
569
570 let start = std::time::Instant::now();
572 while start.elapsed() < Duration::from_secs(2) {
573 if checker.poll_result().is_some() {
574 break;
575 }
576 thread::sleep(Duration::from_millis(10));
577 }
578
579 assert!(!checker.is_update_available());
580 assert!(checker.latest_version().is_none());
581 assert!(checker.get_cached_result().is_some());
582
583 stop_tx.send(()).ok();
584 }
585
586 #[test]
587 fn test_update_checker_api_before_result() {
588 let (stop_tx, url) = start_mock_release_server("99.0.0");
589 let time_source = super::super::time_source::TestTimeSource::shared();
590 let temp_dir = tempfile::tempdir().unwrap();
591
592 let checker = start_periodic_update_check(&url, time_source, temp_dir.path().to_path_buf());
593
594 assert!(!checker.is_update_available());
596 assert!(checker.latest_version().is_none());
597 assert!(checker.get_cached_result().is_none());
598
599 stop_tx.send(()).ok();
600 }
601}