Skip to main content

rpfm_server/
updater.rs

1//---------------------------------------------------------------------------//
2// Copyright (c) 2017-2026 Ismael Gutiérrez González. All rights reserved.
3//
4// This file is part of the Rusted PackFile Manager (RPFM) project,
5// which can be found here: https://github.com/Frodo45127/rpfm.
6//
7// This file is licensed under the MIT license, which can be found here:
8// https://github.com/Frodo45127/rpfm/blob/master/LICENSE.
9//---------------------------------------------------------------------------//
10
11//! Self-update checks against GitHub releases.
12//!
13//! On Linux, in-app updates are typically disabled (the distro / Flatpak
14//! manages updates instead). On Windows, the standalone server can pull a
15//! release zip from GitHub, extract it next to the running binary, replace
16//! the executable atomically, and open the changelog so the user actually
17//! reads it.
18//!
19//! The two relevant pieces of public surface are:
20//!
21//! - [`check_updates_rpfm`] — non-destructive: returns an [`APIResponse`]
22//!   describing whether an update is available, what kind, and what version.
23//! - [`update_main_program`] — performs the actual download / extract /
24//!   replace, then opens the changelog.
25//!
26//! Both have `*_with` variants that take an explicit release-fetching
27//! closure; those are the actual implementations and the ones the unit tests
28//! exercise.
29
30use anyhow::{anyhow, Result};
31use itertools::Itertools;
32use self_update::{backends::github::ReleaseList, Download, get_target, cargo_crate_version, Move, update::Release};
33use tempfile::Builder;
34use zip::ZipArchive;
35
36use std::env::current_exe;
37use std::fmt::Display;
38use std::fs::{self, DirBuilder, File};
39use std::io;
40use std::path::Path;
41
42use rpfm_ipc::helpers::*;
43
44use rpfm_lib::utils::files_from_subdir;
45
46use crate::settings::Settings;
47
48const UPDATE_EXTENSION: &str = "zip";
49const REPO_OWNER: &str = "Frodo45127";
50const REPO_NAME: &str = "rpfm";
51
52const UPDATE_FOLDER_PREFIX: &str = "updates";
53
54/// Filename of the changelog inside the release archive. Opened with the
55/// system handler at the end of [`update_main_program_with`].
56pub const CHANGELOG_FILE: &str = "Changelog.txt";
57
58/// Setting value identifying the stable update channel.
59pub const STABLE: &str = "Stable";
60
61/// Setting value identifying the beta update channel.
62pub const BETA: &str = "Beta";
63
64//-------------------------------------------------------------------------------//
65//                              Enums & Structs
66//-------------------------------------------------------------------------------//
67
68/// Marker type used as a namespace for updater-related items.
69///
70/// Currently empty; lives here so future utility functions can hang off
71/// `Updater::*` without breaking the existing function-style API.
72pub struct Updater {}
73
74/// Channels RPFM can pull updates from.
75///
76/// Versions in the third semver component greater than or equal to `99` are
77/// treated as **betas** (e.g. `4.7.99`), versions below `99` as **stable**.
78/// The check logic in [`check_updates_rpfm_with`] uses this to allow opting
79/// out of betas: a beta user with the stable channel selected will see the
80/// most recent stable release as an available "update" even when its
81/// version number is lower.
82#[derive(Debug, PartialEq, Eq, Copy, Clone)]
83pub enum UpdateChannel {
84    /// Only stable releases are considered.
85    Stable,
86    /// Both stable and beta releases are considered, latest first.
87    Beta
88}
89
90//---------------------------------------------------------------------------//
91//                              Backend functions
92//---------------------------------------------------------------------------//
93
94/// Download the latest release for the configured update channel and replace
95/// the running install with it. Opens the changelog at the end.
96///
97/// Errors if the network request fails, no asset is available for the
98/// current architecture, or the in-place file replace fails.
99pub fn update_main_program(settings: &Settings) -> Result<()> {
100    let channel = update_channel(settings);
101    update_main_program_with(|| last_release(channel))
102}
103
104/// Implementation backing [`update_main_program`], with the release source
105/// abstracted as a closure so unit tests can inject a stub release without
106/// hitting the network.
107pub fn update_main_program_with(fetch_release: impl FnOnce() -> Result<Release>) -> Result<()> {
108    let last_release = fetch_release()?;
109
110    // Get the download for our architecture.
111    let asset = last_release.asset_for(get_target(), None).ok_or_else(|| anyhow!("No download available for your architecture."))?;
112    let mut tmp_path = std::env::current_exe().unwrap();
113    tmp_path.pop();
114    let tmp_dir = Builder::new()
115        .prefix(UPDATE_FOLDER_PREFIX)
116        .tempdir_in(tmp_path)?;
117
118    DirBuilder::new().recursive(true).create(&tmp_dir)?;
119
120    // Nested stuff, because this seems to have problems with creating his own files before using them.
121    {
122        let tmp_zip_path = tmp_dir.path().join(&asset.name);
123        let tmp_zip = File::create(&tmp_zip_path)?;
124
125        Download::from_url(&asset.download_url)
126            .set_header(reqwest::header::ACCEPT, "application/octet-stream".parse().unwrap())
127            .download_to(&tmp_zip)?;
128
129        // Due to bugs in the `self_update` crate, we can't use `Extract` for the zip path.
130        extract_zip(&tmp_zip_path, tmp_dir.path()).map_err(|e| anyhow!("There was an error while extracting the update. This means either I uploaded a broken file, or your download was incomplete. In any case, no changes have been done so… try again later: {e}"))?;
131    }
132
133    let mut dest_base_path = current_exe()?;
134    dest_base_path.pop();
135
136    for updated_file in &files_from_subdir(tmp_dir.path(), true)? {
137
138        // Ignore the downloaded ZIP.
139        if let Some(extension) = updated_file.extension() {
140            if let Some(extension) = extension.to_str() {
141                if extension == UPDATE_EXTENSION {
142                    continue;
143                }
144            }
145        }
146
147        let mut tmp_file = updated_file.to_path_buf();
148        tmp_file.set_file_name(format!("{}_replacement_tmp", updated_file.file_name().unwrap().to_str().unwrap()));
149
150        // Fix for files in folders: we have to get the destination path with the folders included.
151        let tmp_file_relative = updated_file.strip_prefix(tmp_dir.path()).unwrap();
152        let dest_file = dest_base_path.join(tmp_file_relative);
153
154        // Make sure the destination folder actually exists, or this will fail.
155        let mut dest_folder = dest_base_path.join(tmp_file_relative);
156        dest_folder.pop();
157        DirBuilder::new().recursive(true).create(&dest_folder)?;
158
159        Move::from_source(updated_file)
160            .replace_using_temp(&tmp_file)
161            .to_dest(&dest_file)?;
162    }
163
164    // Open the changelog because people don't read it.
165    let changelog_path = dest_base_path.join(CHANGELOG_FILE);
166    let _ = open::that(changelog_path);
167
168    Ok(())
169}
170
171/// Extract a zip archive at `source` into `into_dir`.
172///
173/// Replacement for `self_update::Extract` which on Windows fails with
174/// `os error 267` ("The directory name is invalid") on any zip that
175/// contains directory entries — it calls `fs::File::create` on every
176/// entry without checking whether the entry is a directory.
177fn extract_zip(source: &Path, into_dir: &Path) -> Result<()> {
178    let file = File::open(source)?;
179    let mut archive = ZipArchive::new(file)?;
180    for i in 0..archive.len() {
181        let mut entry = archive.by_index(i)?;
182        let Some(rel_path) = entry.enclosed_name() else { continue };
183        let dest = into_dir.join(rel_path);
184
185        if entry.is_dir() {
186            fs::create_dir_all(&dest)?;
187        } else {
188            if let Some(parent) = dest.parent() {
189                fs::create_dir_all(parent)?;
190            }
191            let mut out = File::create(&dest)?;
192            io::copy(&mut entry, &mut out)?;
193        }
194    }
195    Ok(())
196}
197
198/// This function takes care of checking for new RPFM updates.
199///
200/// Also, this has a special behavior: If we have a beta version and we have the stable channel selected,
201/// it'll pick the newest stable release, even if it's older than our beta. That way we can easily opt-out of betas.
202pub fn check_updates_rpfm(settings: &Settings) -> Result<APIResponse> {
203    let channel = update_channel(settings);
204    let current_version = cargo_crate_version!();
205    check_updates_rpfm_with(current_version, channel, || last_release(channel))
206}
207
208/// Inner function that accepts injectable parameters for testability.
209///
210/// `current_version_str` is a semver string like "4.7.99".
211/// `fetch_release` provides the latest release to compare against.
212pub fn check_updates_rpfm_with(current_version_str: &str, update_channel: UpdateChannel, fetch_release: impl FnOnce() -> Result<Release>) -> Result<APIResponse> {
213    let last_release = fetch_release()?;
214
215    let current_version = current_version_str.split('.').map(|x| x.parse::<i32>().unwrap_or(0)).collect::<Vec<i32>>();
216    let last_version = &last_release.version.split('.').map(|x| x.parse::<i32>().unwrap_or(0)).collect::<Vec<i32>>();
217
218    // Before doing anything else, check if we are going back to stable after a beta, and we are currently in a beta version.
219    // In that case, return the last stable as valid.
220    if let UpdateChannel::Stable = update_channel {
221        if current_version[2] >= 99 {
222            return Ok(APIResponse::NewStableUpdate(format!("v{}", last_version.iter().map(|x| x.to_string()).join("."))));
223        }
224    }
225
226    // Get the version numbers from our version and from the latest released version, so we can compare them.
227    let first = (last_version[0], current_version[0]);
228    let second = (last_version[1], current_version[1]);
229    let third = (last_version[2], current_version[2]);
230
231    // If this is triggered, there has been a problem parsing the current/remote version.
232    if first.0 == 0 && second.0 == 0 && third.0 == 0 || first.1 == 0 && second.1 == 0 && third.1 == 0 {
233        Ok(APIResponse::UnknownVersion)
234    }
235
236    // If the current version is different than the last released version...
237    else if last_version != &current_version {
238
239        // If the latest released version is lesser than the current version...
240        // No update. We are using a newer build than the last build released (dev?).
241        if first.0 < first.1 { Ok(APIResponse::NoUpdate) }
242
243        // If the latest released version is greater than the current version...
244        // New major update. No more checks needed.
245        else if first.0 > first.1 {
246            match update_channel {
247                UpdateChannel::Stable => Ok(APIResponse::NewStableUpdate(format!("v{}", last_version.iter().map(|x| x.to_string()).join(".")))),
248                UpdateChannel::Beta => Ok(APIResponse::NewBetaUpdate(format!("v{}", last_version.iter().map(|x| x.to_string()).join(".")))),
249            }
250        }
251
252        // If the latest released version the same than the current version, we check the second, then the third number.
253        // No update. We are using a newer build than the last build released (dev?).
254        else if second.0 < second.1 { Ok(APIResponse::NoUpdate) }
255
256        // New major update. No more checks needed.
257        else if second.0 > second.1 {
258            match update_channel {
259                UpdateChannel::Stable => Ok(APIResponse::NewStableUpdate(format!("v{}", last_version.iter().map(|x| x.to_string()).join(".")))),
260                UpdateChannel::Beta => Ok(APIResponse::NewBetaUpdate(format!("v{}", last_version.iter().map(|x| x.to_string()).join(".")))),
261            }
262        }
263
264        // We check the last number in the versions, and repeat. Scraping the barrel...
265        // No update. We are using a newer build than the last build released (dev?).
266        else if third.0 < third.1 { Ok(APIResponse::NoUpdate) }
267
268        // If the latest released version only has the last number higher, is a hotfix.
269        else if third.0 > third.1 {
270            match update_channel {
271                UpdateChannel::Stable => Ok(APIResponse::NewUpdateHotfix(format!("v{}", last_version.iter().map(|x| x.to_string()).join(".")))),
272                UpdateChannel::Beta => Ok(APIResponse::NewBetaUpdate(format!("v{}", last_version.iter().map(|x| x.to_string()).join(".")))),
273            }
274        }
275
276        // This means both are the same, and the checks will never reach this place thanks to the parent if.
277        else { unreachable!("check_updates") }
278    }
279    else {
280        Ok(APIResponse::NoUpdate)
281    }
282}
283
284/// Fetch the most recent release from GitHub matching `update_channel`.
285///
286/// Returns the first release whose third semver component is `< 99` for
287/// `Stable`, or the absolute latest for `Beta`.
288pub fn last_release(update_channel: UpdateChannel) -> Result<Release> {
289    let releases = ReleaseList::configure()
290        .repo_owner(REPO_OWNER)
291        .repo_name(REPO_NAME)
292        .build()?
293        .fetch()?;
294
295    match releases.iter().find(|release| {
296        match update_channel {
297            UpdateChannel::Stable => release.version.split('.').collect::<Vec<&str>>()[2].parse::<i32>().unwrap_or(0) < 99,
298            UpdateChannel::Beta => true
299        }
300    }) {
301        Some(last_release) => Ok(last_release.clone()),
302        None => Err(anyhow!("Failed to get last release (should never happen)."))
303    }
304}
305
306/// Read the persisted update channel from settings. Defaults to
307/// [`UpdateChannel::Stable`] when the setting is missing or unrecognised.
308pub fn update_channel(settings: &Settings) -> UpdateChannel {
309    match &*settings.string("update_channel") {
310        BETA => UpdateChannel::Beta,
311        _ => UpdateChannel::Stable,
312    }
313}
314
315impl Display for UpdateChannel {
316    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
317        Display::fmt(match &self {
318            UpdateChannel::Stable => STABLE,
319            UpdateChannel::Beta => BETA,
320        }, f)
321    }
322}