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), 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 if release.prerelease {
120 return Ok(false);
121 }
122 return Ok(latest_version > *current_version);
123 }
124
125 if !release.prerelease {
127 return Ok(latest_version > *current_version);
129 }
130
131 if latest_version.pre != current_version.pre {
134 return Ok(false);
135 }
136
137 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(¤t_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 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 is_zip {
272 return Self::extract_all_from_zip(&tmp_path);
273 }
274
275 Ok(tmp_path.to_path_buf())
276 }
277
278 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, ¤t_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(¤t_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}