Skip to main content

kasl/libs/
update.rs

1//! Self-updating functionality for the kasl application.
2//!
3//! Provides comprehensive auto-update capabilities that enable the application
4//! to automatically check for, download, and install newer versions from GitHub releases.
5//!
6//! ## Features
7//!
8//! - **Safety Mechanisms**: Automatic backup, rollback capability, atomic operations
9//! - **Platform Detection**: Architecture awareness, OS detection, ABI compatibility
10//! - **Network Resilience**: Throttled checks, graceful degradation, retry logic
11//! - **Version Management**: Semantic versioning, GitHub API integration
12//! - **Platform Support**: Windows, macOS Intel/Apple Silicon, Linux
13//!
14//! ## Usage
15//!
16//! ```rust,no_run
17//! use kasl::libs::update::Updater;
18//!
19//! #[tokio::main]
20//! async fn main() -> anyhow::Result<()> {
21//!     let mut updater = Updater::new()?;
22//!    
23//!     if updater.check_for_latest_release().await? {
24//!         updater.perform_update().await?;
25//!     }
26//!    
27//!     Ok(())
28//! }
29//! ```
30
31use crate::libs::data_storage::DataStorage;
32use crate::libs::messages::Message;
33use crate::{msg_bail_anyhow, msg_error_anyhow, msg_info};
34use anyhow::Result;
35use chrono::{DateTime, Duration, Utc};
36use flate2::read::GzDecoder;
37use reqwest::Client;
38use std::env;
39use std::fs::{self, File};
40use std::path::PathBuf;
41use tar::Archive;
42
43// Include application metadata (name, version, owner) generated at build time.
44include!(concat!(env!("OUT_DIR"), "/app_metadata.rs"));
45
46/// Filename for storing the timestamp of the last update check.
47///
48/// This file enables throttling of update checks to avoid excessive API calls
49/// and respect GitHub's rate limiting policies.
50const LAST_CHECK_FILE: &str = ".last_update_check";
51
52/// Minimum interval between update checks in days.
53///
54/// This prevents excessive API calls while ensuring users receive timely
55/// notifications about new releases. The interval balances user experience
56/// with API rate limiting considerations.
57const DAILY_CHECK_INTERVAL: i64 = 1;
58
59/// File extension used for backing up the current executable.
60///
61/// Before replacing the current executable, it's backed up with this extension
62/// to enable rollback in case of update failures.
63const BACKUP_EXTENSION: &str = "bak";
64
65/// Manages the complete application update process from version checking to binary replacement.
66///
67/// The Updater encapsulates all state and behavior needed for safe, reliable application
68/// updates. It handles GitHub API communication, platform detection, download management,
69/// and atomic binary replacement with backup and rollback capabilities.
70///
71/// ## State Management
72///
73/// The Updater maintains several pieces of state throughout the update process:
74/// - **Version Information**: Current and latest version tracking
75/// - **Download State**: URLs and file paths for update assets
76/// - **Configuration**: API endpoints and platform identification
77/// - **Check Throttling**: Timestamps for rate-limited update checks
78///
79/// ## Thread Safety
80///
81/// The Updater is designed for single-threaded use during update operations.
82/// While individual methods are safe to call, the update process itself should
83/// not be parallelized to avoid file system conflicts during binary replacement.
84#[derive(Debug)]
85pub struct Updater {
86    /// HTTP client for making API requests to GitHub.
87    ///
88    /// Configured with appropriate headers and timeouts for reliable
89    /// communication with GitHub's API endpoints.
90    pub client: Client,
91
92    /// GitHub repository owner (organization or user account).
93    ///
94    /// Extracted from build-time metadata to identify the source repository
95    /// for release information and asset downloads.
96    pub owner: String,
97
98    /// Application/repository name for GitHub API requests.
99    ///
100    /// Combined with owner to form complete repository identification for
101    /// API endpoint construction and asset discovery.
102    pub name: String,
103
104    /// Current version of the running application.
105    ///
106    /// Embedded at compile time to enable comparison with latest available
107    /// versions from GitHub releases. Used for determining update necessity.
108    pub version: String,
109
110    /// Latest version available from GitHub (if newer than current).
111    ///
112    /// Populated after successful version check if a newer version is found.
113    /// Used for user notifications and update confirmation messages.
114    pub latest_version: Option<String>,
115
116    /// Download URL for the latest release asset matching current platform.
117    ///
118    /// Determined through platform detection and asset filtering. Used for
119    /// downloading the appropriate binary for the current system configuration.
120    pub download_url: Option<String>,
121
122    /// URL of the repository's `releases/latest` page.
123    ///
124    /// The latest tag is read from this page's redirect `Location` header
125    /// instead of `api.github.com`: the API allows only 60 anonymous
126    /// requests per hour per IP, which starves every machine behind a
127    /// shared NAT (the same failure the installers hit).
128    releases_url: String,
129
130    /// Path to file storing the timestamp of the last update check.
131    ///
132    /// Enables throttling of update checks to respect API rate limits and
133    /// avoid excessive network requests while providing timely notifications.
134    last_check_file: PathBuf,
135}
136
137impl Updater {
138    /// Creates a new Updater instance with configuration from build-time metadata.
139    ///
140    /// This constructor initializes the updater with all necessary configuration
141    /// for communicating with GitHub's API and managing the update process. It
142    /// uses compile-time metadata to automatically configure repository information.
143    ///
144    /// ## Configuration Sources
145    ///
146    /// The constructor uses several sources for configuration:
147    /// - **Build Metadata**: Repository owner, name, and current version
148    /// - **GitHub API**: Standard endpoints for releases and asset discovery
149    /// - **Data Storage**: Platform-appropriate paths for cache and state files
150    /// - **Network Configuration**: HTTP client with reasonable defaults
151    ///
152    /// ## File System Setup
153    ///
154    /// The constructor creates necessary file system entries:
155    /// - **Check Cache File**: For storing last update check timestamp
156    /// - **Data Directory**: Platform-specific application data location
157    /// - **Permissions**: Appropriate read/write permissions for update operations
158    ///
159    /// # Returns
160    ///
161    /// Returns a configured Updater instance ready for version checking and
162    /// update operations, or an error if initialization fails.
163    ///
164    /// # Errors
165    ///
166    /// - **Data Storage**: Cannot determine or create application data directory
167    /// - **File System**: Permission issues with cache file creation
168    /// - **Configuration**: Invalid repository information in build metadata
169    ///
170    /// # Examples
171    ///
172    /// ```rust,no_run
173    /// # fn f() -> anyhow::Result<()> {
174    /// use kasl::libs::update::Updater;
175    ///
176    /// let updater = Updater::new()?;
177    /// println!("Updater configured for {} v{}", updater.name, updater.version);
178    /// # Ok(())
179    /// # }
180    /// ```
181    pub fn new() -> Result<Self> {
182        // Extract repository information from compile-time metadata
183        let owner = APP_METADATA_OWNER.to_owned();
184        let name = APP_METADATA_NAME.to_owned();
185
186        // Set up cache file for update check throttling
187        let last_check_file = DataStorage::new().get_path(LAST_CHECK_FILE)?;
188
189        // Release page whose redirect reveals the latest tag (no API quota)
190        let releases_url = format!("https://github.com/{}/{}/releases/latest", owner, name);
191
192        Ok(Self {
193            client: Client::new(),
194            owner,
195            name,
196            version: APP_METADATA_VERSION.to_owned(),
197            latest_version: None,
198            download_url: None,
199            last_check_file,
200            releases_url,
201        })
202    }
203
204    /// Displays a notification if a new version is available, with throttled checking.
205    ///
206    /// This method provides a user-friendly way to check for updates without being
207    /// intrusive. It implements intelligent throttling to avoid excessive API calls
208    /// while ensuring users are notified of important updates in a timely manner.
209    ///
210    /// ## Throttling Logic
211    ///
212    /// The method implements several levels of throttling:
213    /// 1. **Time-Based**: Respects the daily check interval configuration
214    /// 2. **Graceful Failure**: Silently handles initialization or network errors
215    /// 3. **Non-Blocking**: Returns immediately if checks aren't due
216    /// 4. **Background Operation**: Doesn't interrupt normal application flow
217    ///
218    /// ## User Experience
219    ///
220    /// - **Non-Intrusive**: Only shows notifications when updates are available
221    /// - **Informative**: Provides clear version information in notifications
222    /// - **Actionable**: Suggests how users can install available updates
223    /// - **Reliable**: Handles network errors gracefully without user impact
224    ///
225    /// ## Implementation Strategy
226    ///
227    /// The method uses a fail-fast approach:
228    /// - Returns immediately if updater initialization fails
229    /// - Skips check if not enough time has passed since last check
230    /// - Only displays notification if newer version is confirmed available
231    /// - Handles all errors silently to avoid disrupting user workflow
232    ///
233    /// # Examples
234    ///
235    /// ```rust,no_run
236    /// # async fn f() {
237    /// use kasl::libs::update::Updater;
238    ///
239    /// // Call during application startup
240    /// Updater::show_update_notification().await;
241    /// // User sees notification only if update is available and check is due
242    /// # }
243    /// ```
244    ///
245    /// # Background Behavior
246    ///
247    /// This method is designed to be called during application startup:
248    /// - **Startup Integration**: Called automatically during main application init
249    /// - **Non-Blocking**: Doesn't delay application startup or user operations
250    /// - **Error Resilience**: Network or API failures don't affect application functionality
251    /// - **Rate Limiting**: Respects GitHub API limits through intelligent throttling
252    pub async fn show_update_notification() {
253        // Attempt to create updater instance - fail silently if not possible
254        let mut updater = match Self::new() {
255            Ok(up) => up,
256            Err(_) => return, // Graceful degradation if updater can't be initialized
257        };
258
259        // Check if enough time has passed since last update check
260        if !updater.is_check_due() {
261            return;
262        }
263
264        // Perform version check and display notification if update available
265        if let Ok(true) = updater.check_for_latest_release().await
266            && let Some(latest_version) = &updater.latest_version
267        {
268            // Display user-friendly update notification
269            msg_info!(
270                Message::UpdateAvailable {
271                    app_name: updater.name,
272                    latest: latest_version.to_string()
273                },
274                true // Show with extra spacing for visibility
275            )
276        }
277    }
278
279    /// Performs the complete update process: download, verification, and installation.
280    ///
281    /// This method orchestrates the entire update workflow, from downloading the
282    /// latest release to safely replacing the current executable. It implements
283    /// multiple safety mechanisms to ensure the update process is reliable and
284    /// recoverable in case of failures.
285    ///
286    /// ## Update Process Flow
287    ///
288    /// The method follows a carefully designed sequence:
289    ///
290    /// 1. **Pre-flight Validation**: Verifies that download URL is available
291    /// 2. **Asset Download**: Retrieves the release archive from GitHub
292    /// 3. **Local Storage**: Saves archive to temporary location for processing
293    /// 4. **Binary Extraction**: Extracts and validates the new executable
294    /// 5. **Backup Creation**: Creates backup of current executable
295    /// 6. **Atomic Replacement**: Replaces current executable with new version
296    /// 7. **Cleanup**: Removes temporary files and completes the process
297    ///
298    /// ## Safety Mechanisms
299    ///
300    /// ### Backup and Recovery
301    /// - **Current Executable Backup**: Automatically created before replacement
302    /// - **Rollback Capability**: Failed updates can be reverted using backup
303    /// - **Atomic Operations**: Binary replacement is performed atomically
304    /// - **Error Recovery**: Partial failures are cleaned up automatically
305    ///
306    /// ### Validation and Verification
307    /// - **Download Validation**: Ensures complete archive download
308    /// - **Archive Integrity**: Validates archive format and structure
309    /// - **Binary Verification**: Confirms executable is present in archive
310    /// - **Platform Compatibility**: Verifies binary matches current platform
311    ///
312    /// ## Error Handling
313    ///
314    /// The method implements comprehensive error handling:
315    /// - **Network Errors**: Download failures are reported with clear messages
316    /// - **File System Errors**: Permission and disk space issues are handled
317    /// - **Archive Errors**: Corrupted or invalid archives are detected
318    /// - **Backup Failures**: Issues with backup creation abort the process
319    ///
320    /// # Preconditions
321    ///
322    /// This method requires that `check_for_latest_release()` has been called
323    /// successfully and that `self.download_url` contains a valid URL.
324    ///
325    /// # Returns
326    ///
327    /// Returns `Ok(())` on successful update completion, or an error describing
328    /// the specific failure that occurred during the update process.
329    ///
330    /// # Examples
331    ///
332    /// ```rust,no_run
333    /// # async fn f() -> anyhow::Result<()> {
334    /// use kasl::libs::update::Updater;
335    ///
336    /// let mut updater = Updater::new()?;
337    /// if updater.check_for_latest_release().await? {
338    ///     updater.perform_update().await?;
339    ///     println!("Update completed successfully");
340    /// }
341    /// # Ok(())
342    /// # }
343    /// ```
344    ///
345    /// # Error Scenarios
346    ///
347    /// - **No Download URL**: `check_for_latest_release()` hasn't been called successfully
348    /// - **Network Failure**: Unable to download release archive from GitHub
349    /// - **Disk Space**: Insufficient space for temporary files or backup
350    /// - **Permissions**: Cannot write to application directory or create backup
351    /// - **Archive Corruption**: Downloaded archive is corrupted or invalid format
352    /// - **Missing Binary**: Archive doesn't contain expected executable file
353    pub async fn perform_update(&self) -> Result<()> {
354        // Validate that download URL is available from previous version check
355        let download_url = self.download_url.as_ref().ok_or(msg_error_anyhow!(Message::UpdateDownloadUrlNotSet))?;
356
357        // Download the release archive from GitHub
358        let response = self.client.get(download_url).send().await?;
359        let content = response.bytes().await?;
360
361        // Save the downloaded archive to a temporary file for processing
362        let tar_gz_path = env::temp_dir().join(format!("{}.tar.gz", self.name));
363        fs::write(&tar_gz_path, &content)?;
364
365        // Extract the new binary and replace the current executable
366        // This includes backup creation and atomic replacement
367        self.extract_and_replace_binary(&tar_gz_path)?;
368
369        // Clean up the downloaded archive after successful installation
370        fs::remove_file(&tar_gz_path)?;
371
372        Ok(())
373    }
374
375    /// Checks GitHub for the latest release and determines if an update is available.
376    ///
377    /// This method communicates with GitHub's releases API to fetch information about
378    /// the latest available version. It compares version strings to determine if the
379    /// current application version is outdated and populates the updater's state with
380    /// download information if an update is needed.
381    ///
382    /// ## Version Comparison Logic
383    ///
384    /// The method uses string comparison for version precedence:
385    /// 1. **Version Normalization**: Strips 'v' prefix from GitHub tags if present
386    /// 2. **String Comparison**: Uses lexicographic comparison for version ordering
387    /// 3. **Update Detection**: Identifies when remote version is greater than current
388    /// 4. **State Population**: Stores version and download information for later use
389    ///
390    /// ## Release Discovery
391    ///
392    /// The latest tag comes from the `releases/latest` redirect rather than
393    /// `api.github.com` (anonymous API quota is 60 requests/hour per IP and
394    /// starves shared NATs). The download URL is constructed from the tag and
395    /// the platform identifier, matching the release asset naming scheme.
396    ///
397    /// ## State Updates
398    ///
399    /// When a newer version is found, the method updates:
400    /// - **latest_version**: Stores the newer version string for display
401    /// - **download_url**: Sets the URL for downloading the platform-specific binary
402    /// - **Check Timestamp**: Records when this check was performed for throttling
403    ///
404    /// # Returns
405    ///
406    /// Returns `true` if a newer version is available and download URL is found,
407    /// `false` if the current version is up-to-date or no compatible asset exists.
408    ///
409    /// # Errors
410    ///
411    /// - **Network Errors**: API request failures, timeouts, or connectivity issues
412    /// - **API Errors**: GitHub API rate limiting or service unavailability
413    /// - **Parsing Errors**: Invalid JSON response format from GitHub
414    /// - **File System Errors**: Cannot update check timestamp cache file
415    ///
416    /// # Examples
417    ///
418    /// ```rust,no_run
419    /// # async fn f() -> anyhow::Result<()> {
420    /// use kasl::libs::update::Updater;
421    ///
422    /// let mut updater = Updater::new()?;
423    /// if updater.check_for_latest_release().await? {
424    ///     println!("Update available: {} -> {}",
425    ///         updater.version,
426    ///         updater.latest_version.unwrap());
427    /// }
428    /// # Ok(())
429    /// # }
430    /// ```
431    pub async fn check_for_latest_release(&mut self) -> Result<bool> {
432        // Read the latest tag from the release page redirect
433        let tag = self.fetch_latest_tag().await?;
434
435        // Update check timestamp for throttling future checks
436        self.update_last_check_time();
437
438        // Normalize version string by removing 'v' prefix if present
439        let latest_version = tag.trim_start_matches('v').to_string();
440
441        // Compare versions using string comparison (works for semantic versioning)
442        if latest_version > self.version {
443            // Asset names follow the release convention: {name}-{tag}-{platform}.tar.gz
444            self.download_url = Some(format!(
445                "https://github.com/{}/{}/releases/download/{}/{}-{}-{}.tar.gz",
446                self.owner,
447                self.name,
448                tag,
449                self.name,
450                tag,
451                self.get_platform_identifier()
452            ));
453            self.latest_version = Some(latest_version);
454
455            Ok(true) // Update is available
456        } else {
457            Ok(false) // Current version is up-to-date
458        }
459    }
460
461    /// Reads the latest release tag from the `releases/latest` redirect.
462    ///
463    /// GitHub answers this page with a `302` to `.../releases/tag/<tag>`;
464    /// the tag is taken from the `Location` header. Unlike `api.github.com`,
465    /// this endpoint has no anonymous rate limit, so it keeps working for
466    /// every machine behind a shared NAT.
467    async fn fetch_latest_tag(&self) -> Result<String> {
468        // The shared client follows redirects (needed for asset downloads),
469        // so the redirect probe uses its own non-following client.
470        let client = Client::builder().redirect(reqwest::redirect::Policy::none()).build()?;
471        let response = client.get(&self.releases_url).header("User-Agent", &self.name).send().await?;
472
473        let location = response
474            .headers()
475            .get(reqwest::header::LOCATION)
476            .and_then(|value| value.to_str().ok())
477            .ok_or_else(|| msg_error_anyhow!(Message::UpdateLatestTagNotFound(self.releases_url.clone())))?;
478
479        match location.rsplit_once("/releases/tag/") {
480            Some((_, tag)) if !tag.is_empty() => Ok(tag.to_string()),
481            _ => Err(msg_error_anyhow!(Message::UpdateLatestTagNotFound(self.releases_url.clone()))),
482        }
483    }
484
485    /// Extracts the new binary from the downloaded archive and replaces the current executable.
486    ///
487    /// This method performs the most critical part of the update process: safely
488    /// replacing the current executable with the new version. It implements multiple
489    /// safety mechanisms to ensure the operation is atomic and recoverable.
490    ///
491    /// ## Extraction Process
492    ///
493    /// 1. **Archive Opening**: Opens and validates the tar.gz archive
494    /// 2. **Entry Iteration**: Processes each file in the archive
495    /// 3. **Binary Identification**: Finds the main executable file
496    /// 4. **Backup Creation**: Creates backup of current executable
497    /// 5. **Atomic Replacement**: Replaces executable with new version
498    /// 6. **Auxiliary Files**: Extracts other files to appropriate locations
499    ///
500    /// ## Safety Mechanisms
501    ///
502    /// ### Backup Strategy
503    /// - **Automatic Backup**: Current executable is backed up before replacement
504    /// - **Backup Naming**: Uses consistent `.bak` extension for identification
505    /// - **Rollback Support**: Backup enables recovery from failed updates
506    /// - **Cleanup**: Old backups are replaced with new ones
507    ///
508    /// ### Atomic Operations
509    /// - **Rename Operation**: Uses filesystem rename for atomic replacement
510    /// - **Error Recovery**: Partially completed operations are cleaned up
511    /// - **Validation**: Confirms successful extraction before cleanup
512    /// - **Rollback**: Failed operations can be reverted using backup
513    ///
514    /// ## File Handling
515    ///
516    /// ### Main Executable
517    /// - **Identification**: Matches filename with current executable
518    /// - **Backup Creation**: Renames current executable to backup
519    /// - **Replacement**: Extracts new executable to current location
520    /// - **Permissions**: Preserves executable permissions
521    ///
522    /// ### Auxiliary Files
523    /// - **Location**: Extracted to same directory as executable
524    /// - **Overwrite**: Existing files are replaced with new versions
525    /// - **Permissions**: Standard file permissions are applied
526    /// - **Cleanup**: Temporary files are removed after extraction
527    ///
528    /// # Arguments
529    ///
530    /// * `tar_gz_path` - Path to the downloaded release archive
531    ///
532    /// # Returns
533    ///
534    /// Returns `Ok(())` on successful extraction and replacement, or an error
535    /// if any step of the process fails.
536    ///
537    /// # Error Scenarios
538    ///
539    /// - **Archive Errors**: Corrupted or invalid tar.gz format
540    /// - **Missing Binary**: Archive doesn't contain expected executable
541    /// - **File System Errors**: Permission issues or disk space problems
542    /// - **Backup Failures**: Cannot create backup of current executable
543    /// - **Extraction Errors**: Cannot extract files from archive
544    fn extract_and_replace_binary(&self, tar_gz_path: &PathBuf) -> Result<()> {
545        // Open and prepare the archive for extraction
546        let tar_gz = File::open(tar_gz_path)?;
547        let tar = GzDecoder::new(tar_gz);
548        let mut archive = Archive::new(tar);
549        let mut is_updated = false;
550
551        // Determine current executable path and backup location
552        let current_exe = env::current_exe()?;
553        let current_exe_backup = current_exe.with_extension(BACKUP_EXTENSION);
554
555        // Process each entry in the archive
556        for entry_result in archive.entries()? {
557            let mut entry = entry_result?;
558            let entry_path = entry.path()?;
559
560            // Check if this entry is the main executable
561            if entry_path.ends_with(current_exe.file_name().unwrap()) {
562                // Create backup of current executable before replacement
563                fs::rename(&current_exe, &current_exe_backup)?;
564
565                // Extract new executable to current location
566                entry.unpack(&current_exe)?;
567                is_updated = true;
568            } else {
569                // Extract auxiliary files to the executable directory
570                let dest_path = current_exe.parent().unwrap().join(&entry_path);
571                entry.unpack(dest_path)?;
572            }
573        }
574
575        // Verify that the main executable was found and updated
576        if is_updated {
577            Ok(())
578        } else {
579            msg_bail_anyhow!(Message::UpdateBinaryNotFoundInArchive);
580        }
581    }
582
583    /// Constructs the platform-specific identifier used in release asset names.
584    ///
585    /// This method generates a string that identifies the current platform's
586    /// architecture and operating system in the format used by GitHub release
587    /// assets. The identifier follows Rust's target triple format for consistency.
588    ///
589    /// ## Platform Detection
590    ///
591    /// The method uses Rust's built-in constants to detect:
592    /// - **Architecture**: From `env::consts::ARCH` (x86_64, aarch64, etc.)
593    /// - **Operating System**: From `env::consts::OS` (windows, macos, linux)
594    /// - **ABI/Toolchain**: Mapped to appropriate toolchain identifier
595    ///
596    /// ## Identifier Format
597    ///
598    /// The generated identifiers follow this pattern:
599    /// `{architecture}-{vendor}-{os}-{abi}`
600    ///
601    /// ### Architecture Values
602    /// - `x86_64`: 64-bit Intel/AMD processors
603    /// - `aarch64`: 64-bit ARM processors (Apple Silicon, ARM64)
604    ///
605    /// ### Operating System Mapping
606    /// - `windows` → `pc-windows-msvc`: Windows with MSVC toolchain
607    /// - `macos` → `apple-darwin`: macOS with Darwin ABI
608    /// - Other → `unknown-linux-gnu`: Linux with glibc
609    ///
610    /// # Returns
611    ///
612    /// Returns a platform identifier string suitable for matching against
613    /// GitHub release asset names.
614    ///
615    /// # Examples
616    ///
617    /// Generated identifiers:
618    /// - Windows: `"x86_64-pc-windows-msvc"`
619    /// - macOS Intel: `"x86_64-apple-darwin"`
620    /// - macOS Apple Silicon: `"aarch64-apple-darwin"`
621    /// - Linux: `"x86_64-unknown-linux-gnu"`
622    fn get_platform_identifier(&self) -> String {
623        let arch = env::consts::ARCH;
624        let os = match env::consts::OS {
625            "windows" => "pc-windows-msvc",
626            "macos" => "apple-darwin",
627            // Must match the published asset triple; releases ship glibc
628            // builds (the installers hit 404s on the old musl guess).
629            _ => "unknown-linux-gnu",
630        };
631
632        // Construct target triple format: architecture-vendor-os-abi
633        format!("{}-{}", arch, os)
634    }
635
636    /// Updates the timestamp file to record when the last update check was performed.
637    ///
638    /// This method implements the persistence layer for update check throttling,
639    /// ensuring that checks are performed at appropriate intervals without being
640    /// too frequent or too infrequent for user needs.
641    ///
642    /// ## Throttling Implementation
643    ///
644    /// - **Timestamp Format**: Uses RFC 3339 format for precise time recording
645    /// - **File Persistence**: Stores timestamp in application data directory
646    /// - **Error Tolerance**: File write failures are silently ignored
647    /// - **Atomic Update**: Timestamp is updated immediately after API call
648    ///
649    /// ## File Management
650    ///
651    /// - **Location**: Stored in platform-specific application data directory
652    /// - **Format**: Plain text file containing ISO 8601 timestamp
653    /// - **Permissions**: Standard file permissions for user data
654    /// - **Cleanup**: File is automatically managed, no manual cleanup needed
655    ///
656    /// ## Error Handling
657    ///
658    /// File write failures are intentionally ignored because:
659    /// - Update check throttling is a convenience feature, not critical functionality
660    /// - Missing timestamp files default to allowing immediate checks
661    /// - File system errors shouldn't prevent application operation
662    /// - Next successful write will restore normal throttling behavior
663    fn update_last_check_time(&self) {
664        let now = Utc::now().to_rfc3339();
665
666        // Intentionally ignore write errors - throttling is not critical functionality
667        let _ = fs::write(&self.last_check_file, now);
668    }
669
670    /// Determines if sufficient time has passed since the last update check.
671    ///
672    /// This method implements the core logic for update check throttling, ensuring
673    /// that checks are performed at reasonable intervals while respecting both
674    /// user experience and API rate limiting considerations.
675    ///
676    /// ## Throttling Logic
677    ///
678    /// The method implements several decision points:
679    ///
680    /// ### File Existence Check
681    /// - **Missing File**: Indicates first run or file system issue → allow check
682    /// - **Read Errors**: File corruption or permission issues → allow check
683    /// - **Successful Read**: Parse timestamp and evaluate recency
684    ///
685    /// ### Timestamp Parsing
686    /// - **Valid Timestamp**: Parse and compare with current time
687    /// - **Invalid Format**: Corrupted timestamp data → allow check
688    /// - **Parse Errors**: File corruption or format changes → allow check
689    ///
690    /// ### Interval Evaluation
691    /// - **Recent Check**: Within daily interval → deny check
692    /// - **Overdue Check**: Exceeds daily interval → allow check
693    /// - **Future Timestamp**: System clock issues → allow check
694    ///
695    /// ## Error Handling Strategy
696    ///
697    /// The method uses a fail-open approach where any error condition results
698    /// in allowing the check to proceed. This ensures that:
699    /// - File system issues don't prevent updates
700    /// - Timestamp corruption doesn't block checks permanently
701    /// - Clock synchronization problems are handled gracefully
702    /// - Users receive update notifications despite technical issues
703    ///
704    /// # Returns
705    ///
706    /// Returns `true` if a check should be performed (enough time has passed
707    /// or error conditions favor allowing the check), `false` if the check
708    /// should be skipped to respect throttling intervals.
709    ///
710    /// # Examples
711    ///
712    /// ```text
713    /// let updater = Updater::new()?;
714    /// if updater.is_check_due() {
715    ///     // Perform update check
716    /// } else {
717    ///     // Skip check, too recent
718    /// }
719    /// ```
720    fn is_check_due(&self) -> bool {
721        match fs::read_to_string(&self.last_check_file) {
722            Ok(content) => {
723                // Attempt to parse the stored timestamp
724                let last_check = content.parse::<DateTime<Utc>>().unwrap_or_else(|_| {
725                    // If parsing fails, default to a time that will trigger a check
726                    Utc::now() - Duration::days(DAILY_CHECK_INTERVAL + 1)
727                });
728
729                // Check if enough time has passed since the last check
730                Utc::now().signed_duration_since(last_check) > Duration::days(DAILY_CHECK_INTERVAL)
731            }
732            Err(_) => true, // If file doesn't exist or can't be read, always allow check
733        }
734    }
735}