Skip to main content

launcher/
updater.rs

1use crate::constants::{CODEBERG_API_BASE_URL, CODEBERG_REPO};
2use self_replace::self_replace;
3use semver::Version;
4use serde::Deserialize;
5use std::io::{Read, Write};
6use std::path::{Path, PathBuf};
7use std::sync::mpsc::{self, Receiver, Sender};
8use thiserror::Error;
9
10#[derive(Debug, Error)]
11pub enum UpdaterError {
12    #[error("Failed to parse version: {0}")]
13    VersionParse(#[from] semver::Error),
14    #[error("Network error: {0}")]
15    Network(#[from] ureq::Error),
16    #[error("Serialization error: {0}")]
17    Serialization(#[from] serde_json::Error),
18    #[error("IO error: {0}")]
19    Io(#[from] std::io::Error),
20    #[error("Failed to apply update: {0}")]
21    Apply(String),
22    #[error("Update check failed: {0}")]
23    Check(String),
24    #[error("Download failed: {0}")]
25    Download(String),
26    #[error("Asset not found: {0}")]
27    AssetNotFound(String),
28    #[error("Other error: {0}")]
29    Other(String),
30}
31
32impl From<String> for UpdaterError {
33    fn from(s: String) -> Self {
34        UpdaterError::Other(s)
35    }
36}
37
38impl From<&str> for UpdaterError {
39    fn from(s: &str) -> Self {
40        UpdaterError::Other(s.to_string())
41    }
42}
43
44#[derive(Debug, Deserialize, Clone, PartialEq)]
45pub struct ReleaseAsset {
46    pub name: String,
47    pub browser_download_url: String,
48}
49
50#[derive(Debug, Deserialize, Clone, PartialEq)]
51pub struct ReleaseInfo {
52    pub tag_name: String,
53    pub assets: Vec<ReleaseAsset>,
54    pub body: String,
55    #[serde(default)]
56    pub prerelease: bool,
57}
58
59#[derive(Debug, Clone, PartialEq)]
60pub enum UpdateStatus {
61    Idle,
62    Checking,
63    UpdateAvailable(ReleaseInfo),
64    Downloading(f32), // 0.0 to 1.0
65    ReadyToApply(PathBuf),
66    NoUpdateAvailable,
67    Error(String),
68}
69
70#[derive(Default)]
71pub struct Updater {
72    receiver: Option<Receiver<UpdateStatus>>,
73}
74
75impl Updater {
76    #[must_use]
77    pub fn new() -> Self {
78        Self::default()
79    }
80
81    pub fn poll(&mut self) -> Option<UpdateStatus> {
82        let mut last_status = None;
83        if let Some(rx) = &self.receiver {
84            while let Ok(status) = rx.try_recv() {
85                last_status = Some(status);
86            }
87        }
88        last_status
89    }
90
91    pub fn check_for_updates(&mut self) {
92        let (tx, rx) = mpsc::channel();
93        self.receiver = Some(rx);
94        let tx_thread = tx.clone();
95
96        let _ = tx.send(UpdateStatus::Checking);
97
98        std::thread::spawn(move || match Self::do_check() {
99            Ok(Some(info)) => {
100                let _ = tx_thread.send(UpdateStatus::UpdateAvailable(info));
101            }
102            Ok(None) => {
103                let _ = tx_thread.send(UpdateStatus::NoUpdateAvailable);
104            }
105            Err(e) => {
106                let _ = tx_thread.send(UpdateStatus::Error(e.to_string()));
107            }
108        });
109    }
110
111    pub fn is_update_available(
112        current_version: &Version,
113        release: &ReleaseInfo,
114    ) -> Result<bool, semver::Error> {
115        let latest_version = Version::parse(&release.tag_name)?;
116
117        if current_version.pre.is_empty() {
118            // Stable current: only accept newer stable releases.
119            if release.prerelease {
120                return Ok(false);
121            }
122            return Ok(latest_version > *current_version);
123        }
124
125        // Unstable current: accept any release that is strictly newer.
126        if !release.prerelease {
127            // A newer stable release is always an eligible update.
128            return Ok(latest_version > *current_version);
129        }
130
131        // Both current and candidate are pre-releases.
132        // Never hop between channels: require the same pre-release identifier.
133        if latest_version.pre != current_version.pre {
134            return Ok(false);
135        }
136
137        // semver ignores build metadata in comparisons, so handle it manually:
138        // prefer the candidate if it has a higher semver base or a larger build suffix.
139        if latest_version > *current_version {
140            return Ok(true);
141        }
142        if latest_version == *current_version && latest_version.build > current_version.build {
143            return Ok(true);
144        }
145
146        Ok(false)
147    }
148
149    pub fn do_check() -> Result<Option<ReleaseInfo>, UpdaterError> {
150        if let Err(e) = Self::cleanup_leftovers() {
151            eprintln!("Failed to clean up leftover update files: {e}");
152        }
153        let current_version = semver::Version::parse(env!("CARGO_PKG_VERSION"))?;
154
155        let url = if cfg!(feature = "local") {
156            format!("{CODEBERG_API_BASE_URL}/mock_releases.php")
157        } else {
158            format!("{CODEBERG_API_BASE_URL}/repos/{CODEBERG_REPO}/releases")
159        };
160
161        let client = ureq::Agent::new_with_config(
162            ureq::Agent::config_builder()
163                .user_agent("SupremeSnowboardingLauncher")
164                .timeout_connect(Some(std::time::Duration::from_secs(10)))
165                .timeout_global(Some(std::time::Duration::from_secs(300)))
166                .build(),
167        );
168
169        let response = client.get(&url).call()?;
170
171        if !response.status().is_success() {
172            return Err(UpdaterError::Check(format!("Status {}", response.status())));
173        }
174
175        let releases: Vec<ReleaseInfo> = response.into_body().read_json()?;
176
177        for release in releases {
178            if Self::is_update_available(&current_version, &release)? {
179                return Ok(Some(release));
180            }
181        }
182
183        Ok(None)
184    }
185
186    pub fn start_download(&mut self, info: ReleaseInfo) {
187        let (tx, rx) = mpsc::channel();
188        self.receiver = Some(rx);
189        let tx_thread = tx.clone();
190
191        std::thread::spawn(move || match Self::do_download(&info, tx_thread.clone()) {
192            Ok(path) => {
193                let _ = tx_thread.send(UpdateStatus::ReadyToApply(path));
194            }
195            Err(e) => {
196                let _ = tx_thread.send(UpdateStatus::Error(e.to_string()));
197            }
198        });
199    }
200
201    pub fn do_download(
202        info: &ReleaseInfo,
203        tx: Sender<UpdateStatus>,
204    ) -> Result<PathBuf, UpdaterError> {
205        let asset = info
206            .assets
207            .first()
208            .ok_or_else(|| UpdaterError::AssetNotFound("No assets found in release".to_string()))?;
209        let url = &asset.browser_download_url;
210
211        let client = ureq::Agent::new_with_config(
212            ureq::Agent::config_builder()
213                .user_agent("SupremeSnowboardingLauncher")
214                .timeout_connect(Some(std::time::Duration::from_secs(10)))
215                .timeout_global(Some(std::time::Duration::from_secs(300)))
216                .build(),
217        );
218
219        let response = client.get(url).call()?;
220
221        if !response.status().is_success() {
222            return Err(UpdaterError::Download(format!(
223                "Status {}",
224                response.status()
225            )));
226        }
227
228        let content_length = response
229            .headers()
230            .get("Content-Length")
231            .and_then(|v| v.to_str().ok())
232            .and_then(|s| s.parse::<u64>().ok())
233            .ok_or_else(|| UpdaterError::Download("Failed to get content length".to_string()))?;
234
235        // Determine suffix based on URL
236        let is_zip = url.to_lowercase().ends_with(".zip");
237
238        let mut tmp_file = tempfile::Builder::new()
239            .prefix("update_")
240            .suffix(if is_zip {
241                ".zip"
242            } else {
243                std::env::consts::EXE_SUFFIX
244            })
245            .tempfile()?;
246
247        let mut buffer = [0; 8192];
248        let mut downloaded = 0;
249
250        let mut body = response.into_body();
251        let mut reader = body.as_reader();
252
253        while downloaded < content_length {
254            let n = reader.read(&mut buffer)?;
255            if n == 0 {
256                break;
257            }
258            tmp_file.write_all(&buffer[..n])?;
259            downloaded += n as u64;
260
261            let progress = downloaded as f32 / content_length as f32;
262            let _ = tx.send(UpdateStatus::Downloading(progress));
263        }
264
265        let tmp_path = tmp_file
266            .into_temp_path()
267            .keep()
268            .map_err(|e| UpdaterError::Io(e.error))?;
269
270        // If ZIP, extract all .exe and .dll files
271        if is_zip {
272            return Self::extract_all_from_zip(&tmp_path);
273        }
274
275        Ok(tmp_path.to_path_buf())
276    }
277
278    /// Apply the update by replacing executable and dynamic library files.
279    ///
280    /// # Errors
281    ///
282    /// Returns an error if the replacement fails.
283    pub fn apply_update(path: &Path) -> Result<(), UpdaterError> {
284        let current_exe = std::env::current_exe()?;
285        let target_dir = current_exe
286            .parent()
287            .ok_or_else(|| UpdaterError::Other("Failed to get current directory".into()))?;
288
289        if path.is_dir() {
290            let entries = Self::collect_files_recursive(path)?;
291            let mut errors = Vec::new();
292
293            for source_file in entries {
294                let rel_path = match source_file.strip_prefix(path) {
295                    Ok(p) => p,
296                    Err(_) => continue,
297                };
298
299                let target_file = target_dir.join(rel_path);
300                if let Err(e) = Self::replace_file(&source_file, &target_file) {
301                    errors.push(format!("{}: {e}", rel_path.display()));
302                }
303            }
304
305            let _ = std::fs::remove_dir_all(path);
306
307            if !errors.is_empty() {
308                return Err(UpdaterError::Apply(errors.join("; ")));
309            }
310        } else if path.is_file() {
311            Self::replace_file(path, &current_exe)?;
312            let _ = std::fs::remove_file(path);
313        } else {
314            return Err(UpdaterError::Apply(format!(
315                "Update path does not exist: {}",
316                path.display()
317            )));
318        }
319
320        Ok(())
321    }
322
323    pub fn replace_file(source: &Path, target: &Path) -> Result<(), UpdaterError> {
324        let is_current_exe = std::env::current_exe()
325            .map(|exe| {
326                if let (Ok(c_target), Ok(c_exe)) =
327                    (std::fs::canonicalize(target), std::fs::canonicalize(&exe))
328                {
329                    c_target == c_exe
330                } else {
331                    target == exe
332                }
333            })
334            .unwrap_or(false);
335
336        if is_current_exe {
337            self_replace(source).map_err(|e| UpdaterError::Apply(e.to_string()))?;
338            return Ok(());
339        }
340
341        if let Some(parent) = target.parent() {
342            std::fs::create_dir_all(parent)?;
343        }
344
345        if std::fs::copy(source, target).is_ok() {
346            return Ok(());
347        }
348
349        let parent = target.parent().ok_or_else(|| {
350            UpdaterError::Apply(format!("Invalid target path: {}", target.display()))
351        })?;
352
353        let file_name = target
354            .file_name()
355            .and_then(|s| s.to_str())
356            .unwrap_or("file");
357
358        let mut temp_backup = parent.join(format!(".{file_name}.{}.old", std::process::id()));
359        let mut counter = 0;
360        while temp_backup.exists() {
361            counter += 1;
362            temp_backup = parent.join(format!(".{file_name}.{}_{counter}.old", std::process::id()));
363        }
364
365        std::fs::rename(target, &temp_backup).map_err(|e| {
366            UpdaterError::Apply(format!(
367                "Failed to rename in-use file {} to {}: {e}",
368                target.display(),
369                temp_backup.display()
370            ))
371        })?;
372
373        if let Err(e) = std::fs::copy(source, target) {
374            let _ = std::fs::rename(&temp_backup, target);
375            return Err(UpdaterError::Apply(format!(
376                "Failed to copy new file to {}: {e}",
377                target.display()
378            )));
379        }
380
381        let _ = std::fs::remove_file(&temp_backup);
382        Ok(())
383    }
384
385    fn collect_files_recursive(dir: &Path) -> Result<Vec<PathBuf>, UpdaterError> {
386        let mut files = Vec::new();
387        if dir.is_dir() {
388            for entry in std::fs::read_dir(dir)? {
389                let entry = entry?;
390                let path = entry.path();
391                if path.is_dir() {
392                    files.extend(Self::collect_files_recursive(&path)?);
393                } else if path.is_file() {
394                    files.push(path);
395                }
396            }
397        }
398        Ok(files)
399    }
400
401    fn extract_all_from_zip(zip_path: &Path) -> Result<PathBuf, UpdaterError> {
402        let file = std::fs::File::open(zip_path)?;
403        let mut archive = zip::ZipArchive::new(file)
404            .map_err(|e| UpdaterError::Other(format!("Zip error: {e}")))?;
405
406        let temp_dir = tempfile::Builder::new()
407            .prefix("update_extracted_")
408            .tempdir()?;
409        let temp_dir_path = temp_dir.keep();
410
411        let mut extracted_count = 0;
412
413        for i in 0..archive.len() {
414            let mut entry = archive
415                .by_index(i)
416                .map_err(|e| UpdaterError::Other(format!("Zip error: {e}")))?;
417
418            if entry.is_dir() {
419                continue;
420            }
421
422            let name = entry.name().to_string();
423            let name_lower = name.to_lowercase();
424
425            if name_lower.ends_with(".exe") || name_lower.ends_with(".dll") {
426                let en_path = Path::new(&name);
427                let safe_path = Self::sanitize_zip_path(en_path);
428                let out_path = temp_dir_path.join(&safe_path);
429
430                if let Some(parent) = out_path.parent() {
431                    std::fs::create_dir_all(parent)?;
432                }
433
434                let mut out_file = std::fs::File::create(&out_path)?;
435                std::io::copy(&mut entry, &mut out_file)?;
436                extracted_count += 1;
437            }
438        }
439
440        let _ = std::fs::remove_file(zip_path);
441
442        if extracted_count == 0 {
443            let _ = std::fs::remove_dir_all(&temp_dir_path);
444            return Err(UpdaterError::Download(
445                "ZIP file contained no .exe or .dll files".to_string(),
446            ));
447        }
448
449        Ok(temp_dir_path)
450    }
451
452    fn sanitize_zip_path(path: &Path) -> PathBuf {
453        let mut clean = PathBuf::new();
454        for component in path.components() {
455            if let std::path::Component::Normal(c) = component {
456                clean.push(c);
457            }
458        }
459        clean
460    }
461
462    fn cleanup_leftovers() -> Result<(), UpdaterError> {
463        let current_exe = std::env::current_exe()?;
464        Self::do_cleanup_leftovers(&current_exe)
465    }
466
467    pub fn do_cleanup_leftovers(current_exe: &Path) -> Result<(), UpdaterError> {
468        let current_dir = current_exe
469            .parent()
470            .ok_or_else(|| UpdaterError::Other("Failed to get current directory".into()))?;
471
472        Self::cleanup_dir_leftovers(current_dir)
473    }
474
475    fn cleanup_dir_leftovers(dir: &Path) -> Result<(), UpdaterError> {
476        for entry in std::fs::read_dir(dir)? {
477            let entry = entry?;
478            let path = entry.path();
479            if path.is_dir() {
480                let _ = Self::cleanup_dir_leftovers(&path);
481            } else if path.is_file()
482                && let Some(file_name) = path.file_name().and_then(|s| s.to_str())
483                && file_name.starts_with(".")
484                && (file_name.ends_with(".exe")
485                    || file_name.ends_with(".dll")
486                    || file_name.ends_with(".old"))
487                && let Err(e) = std::fs::remove_file(&path)
488            {
489                eprintln!("Failed to remove leftover file {}: {e}", path.display());
490            }
491        }
492        Ok(())
493    }
494}