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, PathBuf};
41
42use rpfm_ipc::api::ApiError;
43use rpfm_ipc::api::updates::{UpdateComponent, UpdateState, UpdateStatus};
44use rpfm_ipc::helpers::*;
45
46use rpfm_lib::games::{LUA_BRANCH, LUA_REMOTE, LUA_REPO, OLD_AK_BRANCH, OLD_AK_REMOTE, OLD_AK_REPO, TRANSLATIONS_BRANCH, TRANSLATIONS_REMOTE, TRANSLATIONS_REPO};
47use rpfm_lib::integrations::git::{GitIntegration, GitResponse};
48use rpfm_lib::schema::{SCHEMA_BRANCH, SCHEMA_REMOTE, SCHEMA_REPO};
49use rpfm_lib::utils::files_from_subdir;
50
51use rpfm_ipc::settings::{self, lua_autogen_base_path, old_ak_files_path, schemas_path, translations_remote_path, Settings};
52
53const UPDATE_EXTENSION: &str = "zip";
54const REPO_OWNER: &str = "Frodo45127";
55const REPO_NAME: &str = "rpfm";
56
57const UPDATE_FOLDER_PREFIX: &str = "updates";
58
59/// Filename of the changelog inside the release archive. Opened with the
60/// system handler at the end of [`update_main_program_with`].
61pub const CHANGELOG_FILE: &str = "Changelog.txt";
62
63/// Setting value identifying the stable update channel.
64pub const STABLE: &str = "Stable";
65
66/// Setting value identifying the beta update channel.
67pub const BETA: &str = "Beta";
68
69//-------------------------------------------------------------------------------//
70//                              Enums & Structs
71//-------------------------------------------------------------------------------//
72
73/// Marker type used as a namespace for updater-related items.
74///
75/// Currently empty; lives here so future utility functions can hang off
76/// `Updater::*` without breaking the existing function-style API.
77pub struct Updater {}
78
79/// Channels RPFM can pull updates from.
80///
81/// Versions in the third semver component greater than or equal to `99` are
82/// treated as **betas** (e.g. `4.7.99`), versions below `99` as **stable**.
83/// The check logic in [`check_updates_rpfm_with`] uses this to allow opting
84/// out of betas: a beta user with the stable channel selected will see the
85/// most recent stable release as an available "update" even when its
86/// version number is lower.
87#[derive(Debug, PartialEq, Eq, Copy, Clone)]
88pub enum UpdateChannel {
89    /// Only stable releases are considered.
90    Stable,
91    /// Both stable and beta releases are considered, latest first.
92    Beta
93}
94
95//---------------------------------------------------------------------------//
96//                              Backend functions
97//---------------------------------------------------------------------------//
98
99/// Download the latest release for the configured update channel and replace
100/// the running install with it. Opens the changelog at the end.
101///
102/// Errors if the network request fails, no asset is available for the
103/// current architecture, or the in-place file replace fails.
104pub fn update_main_program(settings: &Settings) -> Result<()> {
105    let channel = update_channel(settings);
106    update_main_program_with(|| last_release(channel))
107}
108
109/// Implementation backing [`update_main_program`], with the release source
110/// abstracted as a closure so unit tests can inject a stub release without
111/// hitting the network.
112pub fn update_main_program_with(fetch_release: impl FnOnce() -> Result<Release>) -> Result<()> {
113    let last_release = fetch_release()?;
114
115    // Get the download for our architecture.
116    let asset = last_release.asset_for(get_target(), None).ok_or_else(|| anyhow!("No download available for your architecture."))?;
117    let mut tmp_path = std::env::current_exe().unwrap();
118    tmp_path.pop();
119    let tmp_dir = Builder::new()
120        .prefix(UPDATE_FOLDER_PREFIX)
121        .tempdir_in(tmp_path)?;
122
123    DirBuilder::new().recursive(true).create(&tmp_dir)?;
124
125    // Nested stuff, because this seems to have problems with creating his own files before using them.
126    {
127        let tmp_zip_path = tmp_dir.path().join(&asset.name);
128        let tmp_zip = File::create(&tmp_zip_path)?;
129
130        Download::from_url(&asset.download_url)
131            .set_header(reqwest::header::ACCEPT, "application/octet-stream".parse().unwrap())
132            .download_to(&tmp_zip)?;
133
134        // Due to bugs in the `self_update` crate, we can't use `Extract` for the zip path.
135        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}"))?;
136    }
137
138    let mut dest_base_path = current_exe()?;
139    dest_base_path.pop();
140
141    for updated_file in &files_from_subdir(tmp_dir.path(), true)? {
142
143        // Ignore the downloaded ZIP.
144        if let Some(extension) = updated_file.extension() {
145            if let Some(extension) = extension.to_str() {
146                if extension == UPDATE_EXTENSION {
147                    continue;
148                }
149            }
150        }
151
152        let mut tmp_file = updated_file.to_path_buf();
153        tmp_file.set_file_name(format!("{}_replacement_tmp", updated_file.file_name().unwrap().to_str().unwrap()));
154
155        // Fix for files in folders: we have to get the destination path with the folders included.
156        let tmp_file_relative = updated_file.strip_prefix(tmp_dir.path()).unwrap();
157        let dest_file = dest_base_path.join(tmp_file_relative);
158
159        // Make sure the destination folder actually exists, or this will fail.
160        let mut dest_folder = dest_base_path.join(tmp_file_relative);
161        dest_folder.pop();
162        DirBuilder::new().recursive(true).create(&dest_folder)?;
163
164        Move::from_source(updated_file)
165            .replace_using_temp(&tmp_file)
166            .to_dest(&dest_file)?;
167    }
168
169    // Open the changelog because people don't read it.
170    let changelog_path = dest_base_path.join(CHANGELOG_FILE);
171    let _ = open::that(changelog_path);
172
173    Ok(())
174}
175
176/// Extract a zip archive at `source` into `into_dir`.
177///
178/// Replacement for `self_update::Extract` which on Windows fails with
179/// `os error 267` ("The directory name is invalid") on any zip that
180/// contains directory entries — it calls `fs::File::create` on every
181/// entry without checking whether the entry is a directory.
182fn extract_zip(source: &Path, into_dir: &Path) -> Result<()> {
183    let file = File::open(source)?;
184    let mut archive = ZipArchive::new(file)?;
185    for i in 0..archive.len() {
186        let mut entry = archive.by_index(i)?;
187        let Some(rel_path) = entry.enclosed_name() else { continue };
188        let dest = into_dir.join(rel_path);
189
190        if entry.is_dir() {
191            fs::create_dir_all(&dest)?;
192        } else {
193            if let Some(parent) = dest.parent() {
194                fs::create_dir_all(parent)?;
195            }
196            let mut out = File::create(&dest)?;
197            io::copy(&mut entry, &mut out)?;
198        }
199    }
200    Ok(())
201}
202
203/// This function takes care of checking for new RPFM updates.
204///
205/// Also, this has a special behavior: If we have a beta version and we have the stable channel selected,
206/// it'll pick the newest stable release, even if it's older than our beta. That way we can easily opt-out of betas.
207pub fn check_updates_rpfm(settings: &Settings) -> Result<APIResponse> {
208    let channel = update_channel(settings);
209    let current_version = cargo_crate_version!();
210    check_updates_rpfm_with(current_version, channel, || last_release(channel))
211}
212
213/// Inner function that accepts injectable parameters for testability.
214///
215/// `current_version_str` is a semver string like "4.7.99".
216/// `fetch_release` provides the latest release to compare against.
217pub fn check_updates_rpfm_with(current_version_str: &str, update_channel: UpdateChannel, fetch_release: impl FnOnce() -> Result<Release>) -> Result<APIResponse> {
218    let last_release = fetch_release()?;
219
220    let current_version = current_version_str.split('.').map(|x| x.parse::<i32>().unwrap_or(0)).collect::<Vec<i32>>();
221    let last_version = &last_release.version.split('.').map(|x| x.parse::<i32>().unwrap_or(0)).collect::<Vec<i32>>();
222
223    // Before doing anything else, check if we are going back to stable after a beta, and we are currently in a beta version.
224    // In that case, return the last stable as valid.
225    if let UpdateChannel::Stable = update_channel {
226        if current_version[2] >= 99 {
227            return Ok(APIResponse::NewStableUpdate(format!("v{}", last_version.iter().map(|x| x.to_string()).join("."))));
228        }
229    }
230
231    // Get the version numbers from our version and from the latest released version, so we can compare them.
232    let first = (last_version[0], current_version[0]);
233    let second = (last_version[1], current_version[1]);
234    let third = (last_version[2], current_version[2]);
235
236    // If this is triggered, there has been a problem parsing the current/remote version.
237    if first.0 == 0 && second.0 == 0 && third.0 == 0 || first.1 == 0 && second.1 == 0 && third.1 == 0 {
238        Ok(APIResponse::UnknownVersion)
239    }
240
241    // If the current version is different than the last released version...
242    else if last_version != &current_version {
243
244        // If the latest released version is lesser than the current version...
245        // No update. We are using a newer build than the last build released (dev?).
246        if first.0 < first.1 { Ok(APIResponse::NoUpdate) }
247
248        // If the latest released version is greater than the current version...
249        // New major update. No more checks needed.
250        else if first.0 > first.1 {
251            match update_channel {
252                UpdateChannel::Stable => Ok(APIResponse::NewStableUpdate(format!("v{}", last_version.iter().map(|x| x.to_string()).join(".")))),
253                UpdateChannel::Beta => Ok(APIResponse::NewBetaUpdate(format!("v{}", last_version.iter().map(|x| x.to_string()).join(".")))),
254            }
255        }
256
257        // If the latest released version the same than the current version, we check the second, then the third number.
258        // No update. We are using a newer build than the last build released (dev?).
259        else if second.0 < second.1 { Ok(APIResponse::NoUpdate) }
260
261        // New major update. No more checks needed.
262        else if second.0 > second.1 {
263            match update_channel {
264                UpdateChannel::Stable => Ok(APIResponse::NewStableUpdate(format!("v{}", last_version.iter().map(|x| x.to_string()).join(".")))),
265                UpdateChannel::Beta => Ok(APIResponse::NewBetaUpdate(format!("v{}", last_version.iter().map(|x| x.to_string()).join(".")))),
266            }
267        }
268
269        // We check the last number in the versions, and repeat. Scraping the barrel...
270        // No update. We are using a newer build than the last build released (dev?).
271        else if third.0 < third.1 { Ok(APIResponse::NoUpdate) }
272
273        // If the latest released version only has the last number higher, is a hotfix.
274        else if third.0 > third.1 {
275            match update_channel {
276                UpdateChannel::Stable => Ok(APIResponse::NewUpdateHotfix(format!("v{}", last_version.iter().map(|x| x.to_string()).join(".")))),
277                UpdateChannel::Beta => Ok(APIResponse::NewBetaUpdate(format!("v{}", last_version.iter().map(|x| x.to_string()).join(".")))),
278            }
279        }
280
281        // This means both are the same, and the checks will never reach this place thanks to the parent if.
282        else { unreachable!("check_updates") }
283    }
284    else {
285        Ok(APIResponse::NoUpdate)
286    }
287}
288
289/// Fetch the most recent release from GitHub matching `update_channel`.
290///
291/// Returns the first release whose third semver component is `< 99` for
292/// `Stable`, or the absolute latest for `Beta`.
293pub fn last_release(update_channel: UpdateChannel) -> Result<Release> {
294    let releases = ReleaseList::configure()
295        .repo_owner(REPO_OWNER)
296        .repo_name(REPO_NAME)
297        .build()?
298        .fetch()?;
299
300    match releases.iter().find(|release| {
301        match update_channel {
302            UpdateChannel::Stable => release.version.split('.').collect::<Vec<&str>>()[2].parse::<i32>().unwrap_or(0) < 99,
303            UpdateChannel::Beta => true
304        }
305    }) {
306        Some(last_release) => Ok(last_release.clone()),
307        None => Err(anyhow!("Failed to get last release (should never happen)."))
308    }
309}
310
311/// Read the persisted update channel from settings. Defaults to
312/// [`UpdateChannel::Stable`] when the setting is missing or unrecognised.
313pub fn update_channel(settings: &Settings) -> UpdateChannel {
314    match &*settings.string("update_channel") {
315        BETA => UpdateChannel::Beta,
316        _ => UpdateChannel::Stable,
317    }
318}
319
320impl Display for UpdateChannel {
321    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
322        Display::fmt(match &self {
323            UpdateChannel::Stable => STABLE,
324            UpdateChannel::Beta => BETA,
325        }, f)
326    }
327}
328
329/// Checks if a git repository in the config folder has updates.
330///
331/// # Arguments
332///
333/// * `path_fn` - Returns the local folder of the repository.
334/// * `repo` - URL of the repository.
335/// * `branch` - Branch to check.
336/// * `remote` - Name of the remote.
337pub fn git_check_update(path_fn: fn() -> settings::Result<PathBuf>, repo: &str, branch: &str, remote: &str) -> Result<GitResponse> {
338    Ok(GitIntegration::new(&path_fn()?, repo, branch, remote).check_update()?)
339}
340
341/// Downloads the updates of a git repository in the config folder.
342///
343/// # Arguments
344///
345/// * `path_fn` - Returns the local folder of the repository.
346/// * `repo` - URL of the repository.
347/// * `branch` - Branch to update.
348/// * `remote` - Name of the remote.
349pub fn git_update_repo(path_fn: fn() -> settings::Result<PathBuf>, repo: &str, branch: &str, remote: &str) -> Result<()> {
350    Ok(GitIntegration::new(&path_fn()?, repo, branch, remote).update_repo()?)
351}
352
353/// Checks if something has an update.
354///
355/// # Arguments
356///
357/// * `component` - What to check.
358/// * `settings` - Settings, for the update channel of the program.
359pub fn check_component(component: UpdateComponent, settings: &Settings) -> Result<UpdateStatus> {
360    let git_status = |response: GitResponse| {
361        let (available, state) = match response {
362            GitResponse::NewUpdate => (true, UpdateState::NewUpdate),
363            GitResponse::NoUpdate => (false, UpdateState::NoUpdate),
364            GitResponse::NoLocalFiles => (true, UpdateState::NoLocalFiles),
365            GitResponse::Diverged => (true, UpdateState::Diverged),
366        };
367
368        UpdateStatus { available, state, version: None }
369    };
370
371    Ok(match component {
372        UpdateComponent::Program => {
373            let (available, state, version) = match check_updates_rpfm(settings)? {
374                APIResponse::NewBetaUpdate(version) => (true, UpdateState::NewBetaUpdate, Some(version)),
375                APIResponse::NewStableUpdate(version) => (true, UpdateState::NewStableUpdate, Some(version)),
376                APIResponse::NewUpdateHotfix(version) => (true, UpdateState::NewUpdateHotfix, Some(version)),
377                APIResponse::NoUpdate => (false, UpdateState::NoUpdate, None),
378                APIResponse::UnknownVersion => (false, UpdateState::UnknownVersion, None),
379            };
380
381            UpdateStatus { available, state, version }
382        }
383        UpdateComponent::Schemas => git_status(git_check_update(schemas_path, SCHEMA_REPO, SCHEMA_BRANCH, SCHEMA_REMOTE)?),
384        UpdateComponent::LuaAutogen => git_status(git_check_update(lua_autogen_base_path, LUA_REPO, LUA_BRANCH, LUA_REMOTE)?),
385        UpdateComponent::OldAssemblyKit => git_status(git_check_update(old_ak_files_path, OLD_AK_REPO, OLD_AK_BRANCH, OLD_AK_REMOTE)?),
386        UpdateComponent::Translations => git_status(git_check_update(translations_remote_path, TRANSLATIONS_REPO, TRANSLATIONS_BRANCH, TRANSLATIONS_REMOTE)?),
387    })
388}
389
390/// Downloads the update of something.
391///
392/// # Errors
393///
394/// Fails for the schemas, which are updated elsewhere, or if downloading fails.
395pub fn apply_component(component: UpdateComponent, settings: &Settings) -> Result<()> {
396    match component {
397        UpdateComponent::Program => update_main_program(settings),
398        UpdateComponent::Schemas => Err(ApiError::InvalidParams("Schemas are updated with schema.update, as they need reloading.".to_owned()).into()),
399        UpdateComponent::LuaAutogen => git_update_repo(lua_autogen_base_path, LUA_REPO, LUA_BRANCH, LUA_REMOTE),
400        UpdateComponent::OldAssemblyKit => git_update_repo(old_ak_files_path, OLD_AK_REPO, OLD_AK_BRANCH, OLD_AK_REMOTE),
401        UpdateComponent::Translations => git_update_repo(translations_remote_path, TRANSLATIONS_REPO, TRANSLATIONS_BRANCH, TRANSLATIONS_REMOTE),
402    }
403}