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 != ¤t_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}