summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorMarkus Pettersson <markus.pettersson@mullvad.net>2025-05-21 14:33:50 +0200
committerDavid Lönnhager <david.l@mullvad.net>2025-06-12 12:02:24 +0200
commit84635cf866b1eb18463042c6c60e61e9dcaeba47 (patch)
tree4e812b7841d2115f2ea45a646f17fce8095022eb
parentd6dd0ce2c73c2ed18ed190b418ce401e75f76b8d (diff)
downloadmullvadvpn-offline-mode-old.tar.xz
mullvadvpn-offline-mode-old.zip
Add support for running old installer through loader when offline on Windowsoffline-mode-old
Co-authored-by: Sebastian Holmin <sebastian.holmin@mullvad.net> Co-authored-by: Joakim Hulthe <joakim.hulthe@mullvad.net> Co-authored-by: David Lönnhager <david.l@mullvad.net>
-rw-r--r--Cargo.lock16
-rw-r--r--Cargo.toml1
-rw-r--r--installer-downloader/Cargo.toml1
-rw-r--r--installer-downloader/build.rs2
-rw-r--r--installer-downloader/src/controller.rs284
-rw-r--r--installer-downloader/src/resource.rs21
-rw-r--r--installer-downloader/src/ui_downloader.rs40
-rw-r--r--installer-downloader/tests/controller.rs141
-rw-r--r--installer-downloader/tests/mock.rs93
-rw-r--r--installer-downloader/tests/snapshots/controller__cached_app-2.snap52
-rw-r--r--installer-downloader/tests/snapshots/controller__cached_app.snap39
-rw-r--r--installer-downloader/tests/snapshots/controller__cached_app_verify_fail.snap56
-rw-r--r--installer-downloader/tests/snapshots/controller__failed_directory_creation.snap16
-rw-r--r--installer-downloader/tests/snapshots/controller__failed_fetch_version-2.snap5
-rw-r--r--installer-downloader/tests/snapshots/controller__failed_fetch_version.snap4
-rw-r--r--mullvad-cli/Cargo.toml2
-rw-r--r--mullvad-relay-selector/Cargo.toml2
-rw-r--r--mullvad-update/Cargo.toml2
-rw-r--r--mullvad-update/src/client/api.rs46
-rw-r--r--mullvad-update/src/client/app.rs183
-rw-r--r--mullvad-update/src/client/local.rs79
-rw-r--r--mullvad-update/src/client/mod.rs2
-rw-r--r--mullvad-update/src/client/version_provider.rs14
-rw-r--r--mullvad-update/src/format/mod.rs25
-rw-r--r--mullvad-update/src/version.rs91
-rw-r--r--mullvad-version/src/lib.rs4
26 files changed, 993 insertions, 228 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 6d761edd51..638ccd770c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2096,6 +2096,7 @@ dependencies = [
"log",
"mullvad-paths",
"mullvad-update",
+ "mullvad-version",
"native-windows-gui",
"objc_id",
"rand 0.8.5",
@@ -2183,15 +2184,6 @@ dependencies = [
[[package]]
name = "itertools"
-version = "0.10.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
-dependencies = [
- "either",
-]
-
-[[package]]
-name = "itertools"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
@@ -2716,7 +2708,7 @@ dependencies = [
"clap",
"clap_complete",
"futures",
- "itertools 0.10.5",
+ "itertools 0.14.0",
"mullvad-management-interface",
"mullvad-types",
"mullvad-version",
@@ -2978,7 +2970,7 @@ dependencies = [
"chrono",
"intersection-derive",
"ipnetwork",
- "itertools 0.12.1",
+ "itertools 0.14.0",
"log",
"mullvad-types",
"proptest",
@@ -3054,7 +3046,9 @@ dependencies = [
"ed25519-dalek",
"hex",
"insta",
+ "itertools 0.14.0",
"json-canon",
+ "log",
"mockito",
"mullvad-version",
"rand 0.8.5",
diff --git a/Cargo.toml b/Cargo.toml
index 333772b495..5c573830e2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -119,6 +119,7 @@ clap = { version = "4.4.18", features = ["cargo", "derive"] }
once_cell = "1.16"
serde = "1.0.204"
serde_json = "1.0.122"
+itertools = "0.14"
pnet_packet = "0.35.0"
ipnetwork = "0.20"
diff --git a/installer-downloader/Cargo.toml b/installer-downloader/Cargo.toml
index 16df7fb614..4b55bdd01f 100644
--- a/installer-downloader/Cargo.toml
+++ b/installer-downloader/Cargo.toml
@@ -31,6 +31,7 @@ tokio = { workspace = true, features = ["rt-multi-thread", "fs"] }
talpid-platform-metadata = { path = "../talpid-platform-metadata" }
mullvad-update = { path = "../mullvad-update", features = ["client"] }
+mullvad-version = { path = "../mullvad-version" }
[target.'cfg(target_os = "windows")'.dependencies]
native-windows-gui = { version = "1.0.12", features = ["embed-resource", "frame", "image-decoder", "progress-bar"], default-features = false }
diff --git a/installer-downloader/build.rs b/installer-downloader/build.rs
index 781046c9c0..c3163a8c81 100644
--- a/installer-downloader/build.rs
+++ b/installer-downloader/build.rs
@@ -4,7 +4,7 @@ use std::env;
fn main() -> anyhow::Result<()> {
let target_os = env::var("CARGO_CFG_TARGET_OS").context("Missing 'CARGO_CFG_TARGET_OS")?;
match target_os.as_str() {
- "windows" => win_main(),
+ //"windows" => win_main(),
_ => Ok(()),
}
}
diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs
index cf63154719..aa93586df6 100644
--- a/installer-downloader/src/controller.rs
+++ b/installer-downloader/src/controller.rs
@@ -3,18 +3,20 @@
use crate::{
delegate::{AppDelegate, AppDelegateQueue},
environment::Environment,
- resource,
+ resource::{self, VERIFYING_CACHED},
temp::DirectoryProvider,
ui_downloader::{UiAppDownloader, UiAppDownloaderParameters, UiProgressUpdater},
};
use mullvad_update::{
- api::{HttpVersionInfoProvider, MetaRepositoryPlatform, VersionInfoProvider},
- app::{self, AppDownloader, HttpAppDownloader},
+ api::{HttpVersionInfoProvider, MetaRepositoryPlatform},
+ app::{self, AppCache, AppDownloader, DownloadedInstaller, HttpAppDownloader},
+ local::{AppCacheDir, METADATA_FILENAME},
version::{Version, VersionInfo, VersionParameters},
+ version_provider::VersionInfoProvider,
};
use rand::seq::SliceRandom;
-use std::path::PathBuf;
+use std::{cmp::Ordering, path::PathBuf};
use tokio::{
sync::{mpsc, oneshot},
task::JoinHandle,
@@ -31,6 +33,17 @@ enum TaskMessage {
/// See the [module-level docs](self).
pub struct AppController {}
+struct WorkingDirectory {
+ pub directory: PathBuf,
+}
+
+impl WorkingDirectory {
+ pub async fn new<D: DirectoryProvider>() -> anyhow::Result<WorkingDirectory> {
+ let directory = D::create_download_dir().await?;
+ Ok(Self { directory })
+ }
+}
+
/// Public entry function for registering a [AppDelegate].
///
/// This function uses the Mullvad API to fetch the current releases, a hardcoded public key to
@@ -45,7 +58,13 @@ pub fn initialize_controller<T: AppDelegate + 'static>(delegate: &mut T, environ
let platform = MetaRepositoryPlatform::current().expect("current platform must be supported");
let version_provider = HttpVersionInfoProvider::from(platform);
- AppController::initialize::<_, Downloader<T>, _, DirProvider>(
+ #[cfg(target_os = "windows")]
+ type CacheDir = AppCacheDir;
+
+ #[cfg(target_os = "macos")]
+ type CacheDir = NoopAppCacheDir;
+
+ AppController::initialize::<_, Downloader<T>, CacheDir, DirProvider>(
delegate,
version_provider,
environment,
@@ -57,14 +76,14 @@ impl AppController {
///
/// This function lets the caller provide a version information provider, download client, etc.,
/// which is useful for testing.
- pub fn initialize<D, A, V, DirProvider>(
+ pub fn initialize<D, A, C, DirProvider>(
delegate: &mut D,
- version_provider: V,
+ mut version_provider: impl VersionInfoProvider + Send + 'static,
environment: Environment,
) where
D: AppDelegate + 'static,
- V: VersionInfoProvider + Send + 'static,
A: From<UiAppDownloaderParameters<D>> + AppDownloader + 'static,
+ C: AppCache + 'static,
DirProvider: DirectoryProvider + 'static,
{
delegate.hide_download_progress();
@@ -78,8 +97,57 @@ impl AppController {
let queue = delegate.queue();
let task_tx_clone = task_tx.clone();
tokio::spawn(async move {
- let version_info =
- fetch_app_version_info::<D, V>(queue.clone(), version_provider, environment).await;
+ let working_dir = loop {
+ match WorkingDirectory::new::<DirProvider>().await {
+ Ok(directory) => break directory,
+ Err(err) => {
+ log::error!("Failed to create temporary directory: {err:?}");
+
+ let (retry_tx, mut retry_rx) = mpsc::channel(1);
+
+ queue.queue_main(move |self_| {
+ self_.clear_status_text();
+ self_.hide_download_button();
+ self_.hide_beta_text();
+ self_.hide_stable_text();
+
+ let cancel_tx = retry_tx.clone();
+ self_.on_error_message_cancel(move || {
+ let _ = cancel_tx.try_send(false);
+ });
+ self_.on_error_message_retry(move || {
+ let _ = retry_tx.try_send(true);
+ });
+
+ self_.show_error_message(crate::delegate::ErrorMessage {
+ status_text: resource::CREATE_TEMPDIR_FAILED.to_owned(),
+ cancel_button_text:
+ resource::CREATE_TEMPDIR_FAILED_CANCEL_BUTTON_TEXT.to_owned(),
+ retry_button_text:
+ resource::CREATE_TEMPDIR_FAILED_RETRY_BUTTON_TEXT.to_owned(),
+ });
+ });
+
+ if !retry_rx.recv().await.unwrap() {
+ queue.queue_main(|self_| {
+ self_.quit();
+ });
+ std::future::pending::<()>().await;
+ }
+ }
+ }
+ };
+
+ let metadata_path = working_dir.directory.join(METADATA_FILENAME);
+ version_provider.set_metadata_dump_path(metadata_path);
+
+ let version_info = fetch_app_version_info::<D, C>(
+ queue.clone(),
+ version_provider,
+ &working_dir,
+ environment,
+ )
+ .await;
let version_label = format_latest_version(&version_info.stable);
let has_beta = version_info.beta.is_some();
queue.queue_main(move |self_| {
@@ -90,10 +158,11 @@ impl AppController {
}
});
- ActionMessageHandler::<D, A>::run::<DirProvider>(
+ ActionMessageHandler::<D, A>::run(
queue,
task_tx_clone,
task_rx,
+ working_dir,
version_info,
)
.await;
@@ -126,14 +195,15 @@ impl AppController {
}
/// Background task that fetches app version data.
-async fn fetch_app_version_info<Delegate, VersionProvider>(
+async fn fetch_app_version_info<Delegate, Cache>(
queue: Delegate::Queue,
- version_provider: VersionProvider,
+ version_provider: impl VersionInfoProvider + Send,
+ working_directory: &WorkingDirectory,
Environment { architecture }: Environment,
) -> VersionInfo
where
- Delegate: AppDelegate,
- VersionProvider: VersionInfoProvider + Send,
+ Delegate: AppDelegate + 'static,
+ Cache: AppCache + 'static,
{
loop {
queue.queue_main(|self_| {
@@ -149,42 +219,91 @@ where
lowest_metadata_version: 0,
};
- let err = match version_provider.get_version_info(version_params).await {
- Ok(version_info) => {
- return version_info;
- }
+ let err = match version_provider.get_version_info(&version_params).await {
+ Ok(version_info) => return version_info,
Err(err) => err,
};
log::error!("Failed to get version info: {err:?}");
- enum Action {
+ enum Action<Cache: AppCache> {
Retry,
Cancel,
+ InstallExistingVersion {
+ cached_app_installer: Cache::Installer,
+ },
}
- let (action_tx, mut action_rx) = mpsc::channel(1);
+ let (action_tx, mut action_rx) = mpsc::channel::<Action<Cache>>(1);
- // show error message (needs to happen on the UI (main) thread)
- // send Action when user presses a button to continue
- queue.queue_main(move |self_| {
- self_.hide_download_button();
+ // Check if we've already downloaded an installer.
+ // If so, the user will be given the option to run it.
+ let cache = Cache::new(working_directory.directory.clone(), version_params);
+ // Present the 'first' available installer. In this case, we will always present
+ // the latest app version installer available, as we suspect that this is the app
+ // version the user wants to install. This could be made more dynamic (i.e. a user
+ // interaction instead).
+ let installer = cache.get_metadata().await.ok().and_then(|m| {
+ cache
+ .get_cached_installers(m)
+ .into_iter()
+ .max_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal))
+ });
- let (retry_tx, cancel_tx) = (action_tx.clone(), action_tx);
+ match installer {
+ Some(cached_app_installer) => {
+ queue.queue_main(move |self_| {
+ self_.hide_download_button();
- self_.clear_status_text();
- self_.on_error_message_retry(move || {
- let _ = retry_tx.try_send(Action::Retry);
- });
- self_.on_error_message_cancel(move || {
- let _ = cancel_tx.try_send(Action::Cancel);
- });
- self_.show_error_message(crate::delegate::ErrorMessage {
- status_text: resource::FETCH_VERSION_ERROR_DESC.to_owned(),
- cancel_button_text: resource::FETCH_VERSION_ERROR_CANCEL_BUTTON_TEXT.to_owned(),
- retry_button_text: resource::FETCH_VERSION_ERROR_RETRY_BUTTON_TEXT.to_owned(),
- });
- });
+ let (retry_tx, cancel_tx) = (action_tx.clone(), action_tx);
+
+ self_.clear_status_text();
+ self_.on_error_message_retry(move || {
+ let _ = retry_tx.try_send(Action::Retry);
+ });
+
+ self_.show_error_message(crate::delegate::ErrorMessage {
+ status_text: resource::FETCH_VERSION_ERROR_DESC_WITH_EXISTING_DOWNLOAD
+ .replace("%s", &cached_app_installer.version().to_string()),
+ cancel_button_text: resource::FETCH_VERSION_ERROR_INSTALL_BUTTON_TEXT
+ .to_owned(),
+ retry_button_text: resource::FETCH_VERSION_ERROR_RETRY_BUTTON_TEXT
+ .to_owned(),
+ });
+ self_.on_error_message_cancel(move || {
+ let _ = cancel_tx.try_send(Action::InstallExistingVersion {
+ cached_app_installer: cached_app_installer.clone(),
+ });
+ });
+ });
+ }
+ _ => {
+ log::info!("Couldn't find a downloaded installer");
+ // show error message (needs to happen on the UI (main) thread)
+ // send Action when user presses a button to continue
+ queue.queue_main(move |self_| {
+ self_.hide_download_button();
+
+ let (retry_tx, cancel_tx) = (action_tx.clone(), action_tx);
+
+ self_.clear_status_text();
+ self_.on_error_message_retry(move || {
+ let _ = retry_tx.try_send(Action::Retry);
+ });
+
+ self_.show_error_message(crate::delegate::ErrorMessage {
+ status_text: resource::FETCH_VERSION_ERROR_DESC.to_owned(),
+ cancel_button_text: resource::FETCH_VERSION_ERROR_CANCEL_BUTTON_TEXT
+ .to_owned(),
+ retry_button_text: resource::FETCH_VERSION_ERROR_RETRY_BUTTON_TEXT
+ .to_owned(),
+ });
+ self_.on_error_message_cancel(move || {
+ let _ = cancel_tx.try_send(Action::Cancel);
+ });
+ })
+ }
+ };
// wait for user to press either button
let action = action_rx.recv().await.expect("sender unexpectedly dropped");
@@ -200,6 +319,59 @@ where
self_.quit();
});
}
+ Action::InstallExistingVersion {
+ cached_app_installer: installer,
+ } => {
+ let (action_tx, mut action_rx) = mpsc::channel::<Option<Action<Cache>>>(1);
+ let (retry_tx, cancel_tx) = (action_tx.clone(), action_tx.clone());
+
+ queue.queue_main(|self_| {
+ self_.hide_error_message();
+ self_.clear_download_text();
+ self_.hide_download_button();
+ self_.hide_beta_text();
+ self_.hide_stable_text();
+ self_.show_cancel_button();
+ self_.disable_cancel_button();
+ self_.hide_download_progress();
+ self_.set_status_text(VERIFYING_CACHED);
+
+ let ui_installer = UiAppDownloader::new(&*self_, installer);
+
+ self_.on_error_message_retry(move || {
+ let _ = retry_tx.try_send(Some(Action::Retry));
+ });
+ self_.on_error_message_cancel(move || {
+ let _ = cancel_tx.try_send(Some(Action::Cancel));
+ });
+
+ tokio::spawn(async move {
+ if let Err(err) = app::install_and_upgrade(ui_installer).await {
+ log::error!("install_and_upgrade failed: {err:?}");
+ } else {
+ let _ = action_tx.send(None).await;
+ }
+ });
+ });
+
+ match action_rx.recv().await.unwrap() {
+ None => {
+ // If the verification was successful, wait for the app to shut down instead of looping
+ let () = std::future::pending().await;
+ }
+ Some(Action::Retry) => {
+ log::debug!("Retrying to fetch version info");
+ continue;
+ }
+ Some(Action::Cancel) => {
+ log::debug!("Cancelling fetching version info");
+ queue.queue_main(|self_| {
+ self_.quit();
+ });
+ }
+ Some(Action::InstallExistingVersion { .. }) => unreachable!(),
+ }
+ }
}
}
}
@@ -221,7 +393,7 @@ struct ActionMessageHandler<
version_info: VersionInfo,
active_download: Option<JoinHandle<()>>,
target_version: TargetVersion,
- temp_dir: anyhow::Result<PathBuf>,
+ working_directory: WorkingDirectory,
_marker: std::marker::PhantomData<A>,
}
@@ -230,21 +402,20 @@ impl<D: AppDelegate + 'static, A: From<UiAppDownloaderParameters<D>> + AppDownlo
ActionMessageHandler<D, A>
{
/// Run the [ActionMessageHandler] actor until the end of the program/execution
- async fn run<DP: DirectoryProvider>(
+ async fn run(
queue: D::Queue,
tx: mpsc::Sender<TaskMessage>,
mut rx: mpsc::Receiver<TaskMessage>,
+ working_directory: WorkingDirectory,
version_info: VersionInfo,
) {
- let temp_dir = DP::create_download_dir().await;
-
let mut handler = Self {
queue,
tx,
version_info,
active_download: None,
target_version: TargetVersion::Stable,
- temp_dir,
+ working_directory,
_marker: std::marker::PhantomData,
};
@@ -306,28 +477,7 @@ impl<D: AppDelegate + 'static, A: From<UiAppDownloaderParameters<D>> + AppDownlo
});
});
- // Create temporary dir
- let download_dir = match &self.temp_dir {
- Ok(dir) => dir.clone(),
- Err(error) => {
- log::error!("Failed to create temporary directory: {error:?}");
-
- self.queue.queue_main(move |self_| {
- self_.clear_status_text();
- self_.hide_download_button();
- self_.hide_beta_text();
- self_.hide_stable_text();
-
- self_.show_error_message(crate::delegate::ErrorMessage {
- status_text: resource::DOWNLOAD_FAILED_DESC.to_owned(),
- cancel_button_text: resource::DOWNLOAD_FAILED_CANCEL_BUTTON_TEXT.to_owned(),
- retry_button_text: resource::DOWNLOAD_FAILED_RETRY_BUTTON_TEXT.to_owned(),
- });
- });
- return;
- }
- };
-
+ let download_dir = self.working_directory.directory.clone();
log::debug!("Download directory: {}", download_dir.display());
// Begin download
@@ -366,7 +516,7 @@ impl<D: AppDelegate + 'static, A: From<UiAppDownloaderParameters<D>> + AppDownlo
let ui_downloader = UiAppDownloader::new(self_, downloader);
let _ = tx.send(tokio::spawn(async move {
- if let Err(err) = app::install_and_upgrade(ui_downloader).await {
+ if let Err(err) = app::download_install_and_upgrade(ui_downloader).await {
log::error!("install_and_upgrade failed: {err:?}");
}
}));
diff --git a/installer-downloader/src/resource.rs b/installer-downloader/src/resource.rs
index 393218b5b1..04396546a1 100644
--- a/installer-downloader/src/resource.rs
+++ b/installer-downloader/src/resource.rs
@@ -31,6 +31,15 @@ pub const DOWNLOAD_BUTTON_TEXT: &str = "Download & install";
/// Dimensions of download button (including padding)
pub const DOWNLOAD_BUTTON_SIZE: (usize, usize) = (150, 40);
+/// Failed to create temporary directory
+pub const CREATE_TEMPDIR_FAILED: &str = "Failed to create temporary directory for artifacts. Please check if you have enough space on your hard drive.";
+
+/// Displayed when createing a temporary directory fails (retry button)
+pub const CREATE_TEMPDIR_FAILED_RETRY_BUTTON_TEXT: &str = "Try again";
+
+/// Displayed when createing a temporary directory (cancel button)
+pub const CREATE_TEMPDIR_FAILED_CANCEL_BUTTON_TEXT: &str = "Cancel";
+
/// Cancel button text
pub const CANCEL_BUTTON_TEXT: &str = "Cancel";
@@ -41,7 +50,10 @@ pub const FETCH_VERSION_DESC: &str = "Loading version details...";
pub const LATEST_VERSION_PREFIX: &str = "Version";
/// Displayed while fetching version info from the API failed
-pub const FETCH_VERSION_ERROR_DESC: &str = "Failed to load version details, please try again or make sure you have the latest installer downloader.";
+pub const FETCH_VERSION_ERROR_DESC: &str = "Failed to load version details, please try again or make sure you have the latest installer loader.";
+
+/// Displayed while fetching version info from the API failed
+pub const FETCH_VERSION_ERROR_DESC_WITH_EXISTING_DOWNLOAD: &str = "Failed to fetch new version details, please try again or install the already downloaded version (%s).";
/// Displayed while fetching version info from the API failed (retry button)
pub const FETCH_VERSION_ERROR_RETRY_BUTTON_TEXT: &str = "Try again";
@@ -49,6 +61,10 @@ pub const FETCH_VERSION_ERROR_RETRY_BUTTON_TEXT: &str = "Try again";
/// Displayed while fetching version info from the API failed (cancel button)
pub const FETCH_VERSION_ERROR_CANCEL_BUTTON_TEXT: &str = "Cancel";
+/// Displayed while fetching version info from the API failed,
+/// but there exists a previously downloaded installer (install button)
+pub const FETCH_VERSION_ERROR_INSTALL_BUTTON_TEXT: &str = "Install";
+
/// The first part of "Downloading from \<some url\>... (x%)", displayed during download
pub const DOWNLOADING_DESC_PREFIX: &str = "Downloading from";
@@ -64,6 +80,9 @@ pub const DOWNLOAD_FAILED_RETRY_BUTTON_TEXT: &str = "Try again";
/// Displayed when download fails (cancel button)
pub const DOWNLOAD_FAILED_CANCEL_BUTTON_TEXT: &str = "Cancel";
+/// Displayed verifying a cached app installer.
+pub const VERIFYING_CACHED: &str = "Verifying...";
+
/// Displayed when download fails
pub const VERIFICATION_FAILED_DESC: &str = "Failed to verify download, please try downloading again or contact our support by sending an email to support@mullvadvpn.net with a description of what happened.";
diff --git a/installer-downloader/src/ui_downloader.rs b/installer-downloader/src/ui_downloader.rs
index a31e9e75eb..fdf7737b61 100644
--- a/installer-downloader/src/ui_downloader.rs
+++ b/installer-downloader/src/ui_downloader.rs
@@ -6,13 +6,13 @@ use crate::{
resource,
};
use mullvad_update::{
- app::{self, AppDownloader, AppDownloaderParameters},
+ app::{self, AppDownloader, AppDownloaderParameters, DownloadedInstaller, VerifiedInstaller},
fetch,
};
/// [AppDownloader] that delegates the actual work to some underlying `downloader` and uses it to
/// update a UI.
-pub struct UiAppDownloader<Delegate: AppDelegate, Downloader> {
+pub struct UiAppDownloader<Delegate: AppDelegate + 'static, Downloader> {
downloader: Downloader,
/// Queue used to control the app UI
queue: Delegate::Queue,
@@ -21,9 +21,7 @@ pub struct UiAppDownloader<Delegate: AppDelegate, Downloader> {
/// Parameters for [UiAppDownloader]
pub type UiAppDownloaderParameters<Delegate> = AppDownloaderParameters<UiProgressUpdater<Delegate>>;
-impl<Delegate: AppDelegate, Downloader: AppDownloader + Send + 'static>
- UiAppDownloader<Delegate, Downloader>
-{
+impl<Delegate: AppDelegate, Downloader: Send + 'static> UiAppDownloader<Delegate, Downloader> {
/// Construct a [UiAppDownloader].
pub fn new(delegate: &Delegate, downloader: Downloader) -> Self {
Self {
@@ -36,15 +34,18 @@ impl<Delegate: AppDelegate, Downloader: AppDownloader + Send + 'static>
impl<Delegate: AppDelegate, Downloader: AppDownloader + Send + 'static> AppDownloader
for UiAppDownloader<Delegate, Downloader>
{
- async fn download_executable(&mut self) -> Result<(), app::DownloadError> {
+ async fn download_executable(self) -> Result<impl DownloadedInstaller, app::DownloadError> {
match self.downloader.download_executable().await {
- Ok(()) => {
+ Ok(installer) => {
self.queue.queue_main(move |self_| {
self_.set_download_text(resource::DOWNLOAD_COMPLETE_DESC);
self_.disable_cancel_button();
});
- Ok(())
+ Ok(UiAppDownloader::<Delegate, _> {
+ downloader: installer,
+ queue: self.queue,
+ })
}
Err(err) => {
self.queue.queue_main(move |self_| {
@@ -65,15 +66,22 @@ impl<Delegate: AppDelegate, Downloader: AppDownloader + Send + 'static> AppDownl
}
}
}
+}
- async fn verify(&mut self) -> Result<(), app::DownloadError> {
+impl<Delegate: AppDelegate, Downloader: DownloadedInstaller + Send + 'static> DownloadedInstaller
+ for UiAppDownloader<Delegate, Downloader>
+{
+ async fn verify(self) -> Result<impl VerifiedInstaller, app::DownloadError> {
match self.downloader.verify().await {
- Ok(()) => {
+ Ok(verified) => {
self.queue.queue_main(move |self_| {
self_.set_download_text(resource::VERIFICATION_SUCCEEDED_DESC);
});
- Ok(())
+ Ok(UiAppDownloader::<Delegate, _> {
+ downloader: verified,
+ queue: self.queue,
+ })
}
Err(error) => {
self.queue.queue_main(move |self_| {
@@ -97,7 +105,15 @@ impl<Delegate: AppDelegate, Downloader: AppDownloader + Send + 'static> AppDownl
}
}
- async fn install(&mut self) -> Result<(), app::DownloadError> {
+ fn version(&self) -> &mullvad_version::Version {
+ self.downloader.version()
+ }
+}
+
+impl<Delegate: AppDelegate, I: VerifiedInstaller + Send + 'static> VerifiedInstaller
+ for UiAppDownloader<Delegate, I>
+{
+ async fn install(self) -> Result<(), app::DownloadError> {
match self.downloader.install().await {
Ok(()) => {
self.queue.queue_main(move |self_| {
diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs
index 192eab1b9d..abadba72e2 100644
--- a/installer-downloader/tests/controller.rs
+++ b/installer-downloader/tests/controller.rs
@@ -9,8 +9,9 @@
use insta::assert_yaml_snapshot;
use installer_downloader::controller::AppController;
use mock::{
- FakeAppDelegate, FakeAppDownloaderHappyPath, FakeAppDownloaderVerifyFail,
- FakeDirectoryProvider, FakeVersionInfoProvider, FAKE_ENVIRONMENT,
+ FakeAppCacheEmpty, FakeAppCacheHappyPath, FakeAppCacheVerifyFail, FakeAppDelegate,
+ FakeAppDownloaderHappyPath, FakeAppDownloaderVerifyFail, FakeDirectoryProvider,
+ FakeVersionInfoProvider, FAKE_ENVIRONMENT,
};
use std::{
sync::{atomic::AtomicBool, Arc},
@@ -23,7 +24,12 @@ mod mock;
#[tokio::test(start_paused = true)]
async fn test_fetch_version() {
let mut delegate = FakeAppDelegate::default();
- AppController::initialize::<_, FakeAppDownloaderHappyPath, _, FakeDirectoryProvider<true>>(
+ AppController::initialize::<
+ _,
+ FakeAppDownloaderHappyPath,
+ FakeAppCacheEmpty,
+ FakeDirectoryProvider<true>,
+ >(
&mut delegate,
FakeVersionInfoProvider::default(),
FAKE_ENVIRONMENT,
@@ -52,7 +58,12 @@ async fn test_fetch_version() {
#[tokio::test(start_paused = true)]
async fn test_download() {
let mut delegate = FakeAppDelegate::default();
- AppController::initialize::<_, FakeAppDownloaderHappyPath, _, FakeDirectoryProvider<true>>(
+ AppController::initialize::<
+ _,
+ FakeAppDownloaderHappyPath,
+ FakeAppCacheEmpty,
+ FakeDirectoryProvider<true>,
+ >(
&mut delegate,
FakeVersionInfoProvider::default(),
FAKE_ENVIRONMENT,
@@ -99,10 +110,16 @@ async fn test_download() {
async fn test_failed_fetch_version() {
let mut delegate = FakeAppDelegate::default();
let fail_fetching = Arc::new(AtomicBool::new(true));
- AppController::initialize::<_, FakeAppDownloaderHappyPath, _, FakeDirectoryProvider<true>>(
+ AppController::initialize::<
+ _,
+ FakeAppDownloaderHappyPath,
+ FakeAppCacheEmpty,
+ FakeDirectoryProvider<true>,
+ >(
&mut delegate,
FakeVersionInfoProvider {
fail_fetching: fail_fetching.clone(),
+ dump_metadata_to_file: None,
},
FAKE_ENVIRONMENT,
);
@@ -144,7 +161,12 @@ async fn test_failed_fetch_version() {
#[tokio::test(start_paused = true)]
async fn test_failed_verification() {
let mut delegate = FakeAppDelegate::default();
- AppController::initialize::<_, FakeAppDownloaderVerifyFail, _, FakeDirectoryProvider<true>>(
+ AppController::initialize::<
+ _,
+ FakeAppDownloaderVerifyFail,
+ FakeAppCacheEmpty,
+ FakeDirectoryProvider<true>,
+ >(
&mut delegate,
FakeVersionInfoProvider::default(),
FAKE_ENVIRONMENT,
@@ -182,7 +204,12 @@ async fn test_failed_verification() {
#[tokio::test(start_paused = true)]
async fn test_failed_directory_creation() {
let mut delegate = FakeAppDelegate::default();
- AppController::initialize::<_, FakeAppDownloaderHappyPath, _, FakeDirectoryProvider<false>>(
+ AppController::initialize::<
+ _,
+ FakeAppDownloaderHappyPath,
+ FakeAppCacheEmpty,
+ FakeDirectoryProvider<false>,
+ >(
&mut delegate,
FakeVersionInfoProvider::default(),
FAKE_ENVIRONMENT,
@@ -215,3 +242,103 @@ async fn test_failed_directory_creation() {
// "Download failed"
assert_yaml_snapshot!(delegate.state);
}
+
+/// Test version check failing and using the app cache instead.
+#[tokio::test(start_paused = true)]
+async fn test_cached_app() {
+ let mut delegate = FakeAppDelegate::default();
+ AppController::initialize::<
+ _,
+ FakeAppDownloaderHappyPath,
+ FakeAppCacheHappyPath,
+ FakeDirectoryProvider<true>,
+ >(
+ &mut delegate,
+ FakeVersionInfoProvider {
+ fail_fetching: Arc::new(AtomicBool::new(true)),
+ ..Default::default()
+ },
+ FAKE_ENVIRONMENT,
+ );
+
+ // Wait for the version info
+ tokio::time::sleep(Duration::from_secs(1)).await;
+
+ let queue = delegate.queue.clone();
+ queue.run_callbacks(&mut delegate);
+
+ tokio::time::sleep(Duration::from_secs(1)).await;
+
+ let queue = delegate.queue.clone();
+ queue.run_callbacks(&mut delegate);
+
+ // "Download failed, but cached app available"
+ assert_yaml_snapshot!(delegate.state);
+
+ // Try to install cached version
+ let cb = delegate
+ .error_cancel_callback
+ .take()
+ .expect("no download callback registered");
+ cb();
+
+ // Wait for queued actions to complete
+ tokio::time::sleep(Duration::from_secs(1)).await;
+
+ let queue = delegate.queue.clone();
+ queue.run_callbacks(&mut delegate);
+
+ tokio::time::sleep(Duration::from_secs(1)).await;
+
+ let queue = delegate.queue.clone();
+ queue.run_callbacks(&mut delegate);
+
+ // "Verification succeeded, starting install"
+ assert_yaml_snapshot!(delegate.state);
+}
+
+/// Test version check failing and using the app cache instead, but the cache contains a bad file.
+#[tokio::test(start_paused = true)]
+async fn test_cached_app_verify_fail() {
+ let mut delegate = FakeAppDelegate::default();
+ AppController::initialize::<
+ _,
+ FakeAppDownloaderHappyPath,
+ FakeAppCacheVerifyFail,
+ FakeDirectoryProvider<true>,
+ >(
+ &mut delegate,
+ FakeVersionInfoProvider {
+ fail_fetching: Arc::new(AtomicBool::new(true)),
+ ..Default::default()
+ },
+ FAKE_ENVIRONMENT,
+ );
+
+ // Wait for the version info
+ tokio::time::sleep(Duration::from_secs(1)).await;
+
+ let queue = delegate.queue.clone();
+ queue.run_callbacks(&mut delegate);
+
+ // Try to install cached version
+ let cb = delegate
+ .error_cancel_callback
+ .take()
+ .expect("no download callback registered");
+ cb();
+
+ tokio::time::sleep(Duration::from_secs(1)).await;
+
+ // Wait for queued actions to complete
+ let queue = delegate.queue.clone();
+ queue.run_callbacks(&mut delegate);
+
+ tokio::time::sleep(Duration::from_secs(1)).await;
+
+ let queue = delegate.queue.clone();
+ queue.run_callbacks(&mut delegate);
+
+ // "Download failed"
+ assert_yaml_snapshot!(delegate.state);
+}
diff --git a/installer-downloader/tests/mock.rs b/installer-downloader/tests/mock.rs
index a193f2d9a7..e20e5375f7 100644
--- a/installer-downloader/tests/mock.rs
+++ b/installer-downloader/tests/mock.rs
@@ -6,11 +6,15 @@ use installer_downloader::delegate::{AppDelegate, AppDelegateQueue, ErrorMessage
use installer_downloader::environment::{Architecture, Environment};
use installer_downloader::temp::DirectoryProvider;
use installer_downloader::ui_downloader::UiAppDownloaderParameters;
-use mullvad_update::api::VersionInfoProvider;
-use mullvad_update::app::{AppDownloader, DownloadError};
+use mullvad_update::app::{
+ AppCache, AppDownloader, DownloadError, DownloadedInstaller, VerifiedInstaller,
+};
use mullvad_update::fetch::ProgressUpdater;
+use mullvad_update::format::{Response, SignedResponse};
use mullvad_update::version::{Version, VersionInfo, VersionParameters};
+use mullvad_update::version_provider::VersionInfoProvider;
use std::io;
+use std::marker::PhantomData;
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, LazyLock, Mutex};
@@ -20,6 +24,7 @@ use std::vec::Vec;
#[derive(Default)]
pub struct FakeVersionInfoProvider {
pub fail_fetching: Arc<AtomicBool>,
+ pub dump_metadata_to_file: Option<PathBuf>,
}
pub static FAKE_VERSION: LazyLock<VersionInfo> = LazyLock::new(|| VersionInfo {
@@ -38,12 +43,16 @@ pub const FAKE_ENVIRONMENT: Environment = Environment {
};
impl VersionInfoProvider for FakeVersionInfoProvider {
- async fn get_version_info(&self, _params: VersionParameters) -> anyhow::Result<VersionInfo> {
+ async fn get_version_info(&self, _params: &VersionParameters) -> anyhow::Result<VersionInfo> {
if self.fail_fetching.load(std::sync::atomic::Ordering::SeqCst) {
anyhow::bail!("Failed to fetch version info");
}
Ok(FAKE_VERSION.clone())
}
+
+ fn set_metadata_dump_path(&mut self, path: PathBuf) {
+ self.dump_metadata_to_file = Some(path);
+ }
}
pub struct FakeDirectoryProvider<const SUCCEED: bool> {}
@@ -62,6 +71,15 @@ impl<const SUCCEEDED: bool> DirectoryProvider for FakeDirectoryProvider<SUCCEEDE
/// Downloader for which all steps immediately succeed
pub type FakeAppDownloaderHappyPath = FakeAppDownloader<true, true, true>;
+/// Cache for which all steps immediately succeed
+pub type FakeAppCacheHappyPath = FakeAppCache<true, FakeInstaller<true, true, true>>;
+
+/// Cache for which the verification step fails
+pub type FakeAppCacheVerifyFail = FakeAppCache<true, FakeInstaller<true, false, false>>;
+
+/// A cache that returns nothing.
+pub type FakeAppCacheEmpty = FakeAppCache<false, FakeInstaller<true, true, true>>;
+
/// Downloader for which the verification step fails
pub type FakeAppDownloaderVerifyFail = FakeAppDownloader<true, false, false>;
@@ -87,25 +105,71 @@ pub struct FakeAppDownloader<
params: UiAppDownloaderParameters<FakeAppDelegate>,
}
+#[derive(Default, PartialEq, PartialOrd)]
+pub struct FakeAppCache<const HAS_APP: bool, Installer: DownloadedInstaller + Clone + Default> {
+ _phantom: PhantomData<Installer>,
+}
+
+#[derive(Default, Clone, PartialEq, PartialOrd)]
+pub struct FakeInstaller<
+ const EXE_SUCCEED: bool,
+ const VERIFY_SUCCEED: bool,
+ const LAUNCH_SUCCEED: bool,
+>;
+
impl<const EXE_SUCCEED: bool, const VERIFY_SUCCEED: bool, const LAUNCH_SUCCEED: bool> AppDownloader
for FakeAppDownloader<EXE_SUCCEED, VERIFY_SUCCEED, LAUNCH_SUCCEED>
{
- async fn download_executable(&mut self) -> Result<(), DownloadError> {
+ async fn download_executable(mut self) -> Result<impl DownloadedInstaller, DownloadError> {
self.params.app_progress.set_url(&self.params.app_url);
self.params.app_progress.clear_progress();
if EXE_SUCCEED {
self.params.app_progress.set_progress(1.);
- Ok(())
+ Ok(FakeInstaller::<EXE_SUCCEED, VERIFY_SUCCEED, LAUNCH_SUCCEED>)
} else {
Err(DownloadError::FetchApp(anyhow::anyhow!(
"fetching app failed"
)))
}
}
+}
+
+impl<const HAS_APP: bool, Installer> AppCache for FakeAppCache<HAS_APP, Installer>
+where
+ Installer: DownloadedInstaller + Clone + Default + PartialEq + PartialOrd,
+{
+ type Installer = Installer;
+
+ fn new(_directory: PathBuf, _version_params: VersionParameters) -> Self {
+ Self::default()
+ }
+
+ fn get_cached_installers(self, _metadata: SignedResponse) -> Vec<Self::Installer> {
+ if HAS_APP {
+ vec![Installer::default()]
+ } else {
+ vec![]
+ }
+ }
+ #[allow(clippy::manual_async_fn)]
+ fn get_metadata(
+ &self,
+ ) -> impl std::future::Future<Output = anyhow::Result<SignedResponse>> + Send {
+ async {
+ Ok(SignedResponse {
+ signatures: vec![],
+ signed: Response::default(),
+ })
+ }
+ }
+}
- async fn verify(&mut self) -> Result<(), DownloadError> {
+impl<const EXE_SUCCEED: bool, const VERIFY_SUCCEED: bool, const LAUNCH_SUCCEED: bool>
+ DownloadedInstaller for FakeInstaller<EXE_SUCCEED, VERIFY_SUCCEED, LAUNCH_SUCCEED>
+{
+ async fn verify(self) -> Result<impl VerifiedInstaller, DownloadError> {
if VERIFY_SUCCEED {
- Ok(())
+ Ok(self)
} else {
Err(DownloadError::Verification(anyhow::anyhow!(
"verification failed"
@@ -113,7 +177,20 @@ impl<const EXE_SUCCEED: bool, const VERIFY_SUCCEED: bool, const LAUNCH_SUCCEED:
}
}
- async fn install(&mut self) -> Result<(), DownloadError> {
+ fn version(&self) -> &mullvad_version::Version {
+ &mullvad_version::Version {
+ year: 2042,
+ incremental: 1337,
+ pre_stable: None,
+ dev: None,
+ }
+ }
+}
+
+impl<const EXE_SUCCEED: bool, const VERIFY_SUCCEED: bool, const LAUNCH_SUCCEED: bool>
+ VerifiedInstaller for FakeInstaller<EXE_SUCCEED, VERIFY_SUCCEED, LAUNCH_SUCCEED>
+{
+ async fn install(self) -> Result<(), DownloadError> {
if LAUNCH_SUCCEED {
Ok(())
} else {
diff --git a/installer-downloader/tests/snapshots/controller__cached_app-2.snap b/installer-downloader/tests/snapshots/controller__cached_app-2.snap
new file mode 100644
index 0000000000..237dd40717
--- /dev/null
+++ b/installer-downloader/tests/snapshots/controller__cached_app-2.snap
@@ -0,0 +1,52 @@
+---
+source: installer-downloader/tests/controller.rs
+expression: delegate.state
+---
+status_text: Verifying...
+download_text: Verification successful. Starting install...
+download_button_visible: false
+cancel_button_visible: true
+cancel_button_enabled: false
+download_button_enabled: false
+download_progress: 0
+download_progress_visible: false
+beta_text_visible: false
+stable_text_visible: false
+error_message_visible: false
+error_message:
+ status_text: "Failed to fetch new version details, please try again or install the already downloaded version (2042.1337)."
+ cancel_button_text: Install
+ retry_button_text: Try again
+quit: true
+call_log:
+ - hide_download_progress
+ - show_download_button
+ - disable_download_button
+ - hide_cancel_button
+ - hide_beta_text
+ - hide_stable_text
+ - on_download
+ - on_cancel
+ - on_beta_link
+ - on_stable_link
+ - show_download_button
+ - "set_status_text: Loading version details..."
+ - hide_error_message
+ - hide_download_button
+ - clear_status_text
+ - on_error_message_retry
+ - "show_error_message: Failed to fetch new version details, please try again or install the already downloaded version (2042.1337).. retry: Try again. cancel: Install"
+ - on_error_message_cancel
+ - hide_error_message
+ - clear_download_text
+ - hide_download_button
+ - hide_beta_text
+ - hide_stable_text
+ - show_cancel_button
+ - disable_cancel_button
+ - hide_download_progress
+ - "set_status_text: Verifying..."
+ - on_error_message_retry
+ - on_error_message_cancel
+ - "set_download_text: Verification successful. Starting install..."
+ - quit
diff --git a/installer-downloader/tests/snapshots/controller__cached_app.snap b/installer-downloader/tests/snapshots/controller__cached_app.snap
new file mode 100644
index 0000000000..cc62027c2c
--- /dev/null
+++ b/installer-downloader/tests/snapshots/controller__cached_app.snap
@@ -0,0 +1,39 @@
+---
+source: installer-downloader/tests/controller.rs
+expression: delegate.state
+---
+status_text: ""
+download_text: ""
+download_button_visible: false
+cancel_button_visible: false
+cancel_button_enabled: false
+download_button_enabled: false
+download_progress: 0
+download_progress_visible: false
+beta_text_visible: false
+stable_text_visible: false
+error_message_visible: true
+error_message:
+ status_text: "Failed to fetch new version details, please try again or install the already downloaded version (2042.1337)."
+ cancel_button_text: Install
+ retry_button_text: Try again
+quit: false
+call_log:
+ - hide_download_progress
+ - show_download_button
+ - disable_download_button
+ - hide_cancel_button
+ - hide_beta_text
+ - hide_stable_text
+ - on_download
+ - on_cancel
+ - on_beta_link
+ - on_stable_link
+ - show_download_button
+ - "set_status_text: Loading version details..."
+ - hide_error_message
+ - hide_download_button
+ - clear_status_text
+ - on_error_message_retry
+ - "show_error_message: Failed to fetch new version details, please try again or install the already downloaded version (2042.1337).. retry: Try again. cancel: Install"
+ - on_error_message_cancel
diff --git a/installer-downloader/tests/snapshots/controller__cached_app_verify_fail.snap b/installer-downloader/tests/snapshots/controller__cached_app_verify_fail.snap
new file mode 100644
index 0000000000..f03fac0b04
--- /dev/null
+++ b/installer-downloader/tests/snapshots/controller__cached_app_verify_fail.snap
@@ -0,0 +1,56 @@
+---
+source: installer-downloader/tests/controller.rs
+expression: delegate.state
+---
+status_text: ""
+download_text: ""
+download_button_visible: false
+cancel_button_visible: false
+cancel_button_enabled: false
+download_button_enabled: false
+download_progress: 0
+download_progress_visible: false
+beta_text_visible: false
+stable_text_visible: false
+error_message_visible: true
+error_message:
+ status_text: "Failed to verify download, please try downloading again or contact our support by sending an email to support@mullvadvpn.net with a description of what happened."
+ cancel_button_text: Cancel
+ retry_button_text: Try again
+quit: false
+call_log:
+ - hide_download_progress
+ - show_download_button
+ - disable_download_button
+ - hide_cancel_button
+ - hide_beta_text
+ - hide_stable_text
+ - on_download
+ - on_cancel
+ - on_beta_link
+ - on_stable_link
+ - show_download_button
+ - "set_status_text: Loading version details..."
+ - hide_error_message
+ - hide_download_button
+ - clear_status_text
+ - on_error_message_retry
+ - "show_error_message: Failed to fetch new version details, please try again or install the already downloaded version (2042.1337).. retry: Try again. cancel: Install"
+ - on_error_message_cancel
+ - hide_error_message
+ - clear_download_text
+ - hide_download_button
+ - hide_beta_text
+ - hide_stable_text
+ - show_cancel_button
+ - disable_cancel_button
+ - hide_download_progress
+ - "set_status_text: Verifying..."
+ - on_error_message_retry
+ - on_error_message_cancel
+ - clear_status_text
+ - clear_download_text
+ - hide_download_progress
+ - hide_download_button
+ - hide_cancel_button
+ - "show_error_message: Failed to verify download, please try downloading again or contact our support by sending an email to support@mullvadvpn.net with a description of what happened.. retry: Try again. cancel: Cancel"
diff --git a/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap b/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap
index ae534f550e..80777d3d8e 100644
--- a/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap
+++ b/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap
@@ -7,14 +7,14 @@ download_text: ""
download_button_visible: false
cancel_button_visible: false
cancel_button_enabled: false
-download_button_enabled: true
+download_button_enabled: false
download_progress: 0
download_progress_visible: false
beta_text_visible: false
stable_text_visible: false
error_message_visible: true
error_message:
- status_text: "Download failed, please check your internet connection or if you have enough space on your hard drive and try downloading again."
+ status_text: Failed to create temporary directory for artifacts. Please check if you have enough space on your hard drive.
cancel_button_text: Cancel
retry_button_text: Try again
quit: false
@@ -29,16 +29,10 @@ call_log:
- on_cancel
- on_beta_link
- on_stable_link
- - show_download_button
- - "set_status_text: Loading version details..."
- - hide_error_message
- - "set_status_text: Version: 2025.1"
- - enable_download_button
- - hide_error_message
- - on_error_message_retry
- - on_error_message_cancel
- clear_status_text
- hide_download_button
- hide_beta_text
- hide_stable_text
- - "show_error_message: Download failed, please check your internet connection or if you have enough space on your hard drive and try downloading again.. retry: Try again. cancel: Cancel"
+ - on_error_message_cancel
+ - on_error_message_retry
+ - "show_error_message: Failed to create temporary directory for artifacts. Please check if you have enough space on your hard drive.. retry: Try again. cancel: Cancel"
diff --git a/installer-downloader/tests/snapshots/controller__failed_fetch_version-2.snap b/installer-downloader/tests/snapshots/controller__failed_fetch_version-2.snap
index d9cdcf7d43..b71778ec39 100644
--- a/installer-downloader/tests/snapshots/controller__failed_fetch_version-2.snap
+++ b/installer-downloader/tests/snapshots/controller__failed_fetch_version-2.snap
@@ -1,6 +1,7 @@
---
source: installer-downloader/tests/controller.rs
expression: delegate.state
+snapshot_kind: text
---
status_text: "Version: 2025.1"
download_text: ""
@@ -14,7 +15,7 @@ beta_text_visible: false
stable_text_visible: false
error_message_visible: false
error_message:
- status_text: "Failed to load version details, please try again or make sure you have the latest installer downloader."
+ status_text: "Failed to load version details, please try again or make sure you have the latest installer loader."
cancel_button_text: Cancel
retry_button_text: Try again
quit: false
@@ -35,8 +36,8 @@ call_log:
- hide_download_button
- clear_status_text
- on_error_message_retry
+ - "show_error_message: Failed to load version details, please try again or make sure you have the latest installer loader.. retry: Try again. cancel: Cancel"
- on_error_message_cancel
- - "show_error_message: Failed to load version details, please try again or make sure you have the latest installer downloader.. retry: Try again. cancel: Cancel"
- show_download_button
- "set_status_text: Loading version details..."
- hide_error_message
diff --git a/installer-downloader/tests/snapshots/controller__failed_fetch_version.snap b/installer-downloader/tests/snapshots/controller__failed_fetch_version.snap
index 8bb4a0ceea..7f550925b1 100644
--- a/installer-downloader/tests/snapshots/controller__failed_fetch_version.snap
+++ b/installer-downloader/tests/snapshots/controller__failed_fetch_version.snap
@@ -14,7 +14,7 @@ beta_text_visible: false
stable_text_visible: false
error_message_visible: true
error_message:
- status_text: "Failed to load version details, please try again or make sure you have the latest installer downloader."
+ status_text: "Failed to load version details, please try again or make sure you have the latest installer loader."
cancel_button_text: Cancel
retry_button_text: Try again
quit: false
@@ -35,5 +35,5 @@ call_log:
- hide_download_button
- clear_status_text
- on_error_message_retry
+ - "show_error_message: Failed to load version details, please try again or make sure you have the latest installer loader.. retry: Try again. cancel: Cancel"
- on_error_message_cancel
- - "show_error_message: Failed to load version details, please try again or make sure you have the latest installer downloader.. retry: Try again. cancel: Cancel"
diff --git a/mullvad-cli/Cargo.toml b/mullvad-cli/Cargo.toml
index 70e7a63e1a..83911c2261 100644
--- a/mullvad-cli/Cargo.toml
+++ b/mullvad-cli/Cargo.toml
@@ -20,7 +20,7 @@ chrono = { workspace = true }
clap = { workspace = true }
thiserror = { workspace = true }
futures = { workspace = true }
-itertools = "0.10"
+itertools = { workspace = true }
natord = "1.0.9"
mullvad-types = { path = "../mullvad-types", features = ["clap"] }
diff --git a/mullvad-relay-selector/Cargo.toml b/mullvad-relay-selector/Cargo.toml
index c113d401ba..dc3da3a5d6 100644
--- a/mullvad-relay-selector/Cargo.toml
+++ b/mullvad-relay-selector/Cargo.toml
@@ -14,7 +14,7 @@ workspace = true
chrono = { workspace = true }
thiserror = { workspace = true }
ipnetwork = { workspace = true }
-itertools = "0.12"
+itertools = { workspace = true }
log = { workspace = true }
rand = "0.8.5"
serde_json = { workspace = true }
diff --git a/mullvad-update/Cargo.toml b/mullvad-update/Cargo.toml
index 020391b635..201e0ea3b5 100644
--- a/mullvad-update/Cargo.toml
+++ b/mullvad-update/Cargo.toml
@@ -24,6 +24,8 @@ hex = { version = "0.4" }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
zeroize = { version = "1.8", features = ["zeroize_derive"] }
+log = { workspace = true }
+itertools = { workspace = true }
reqwest = { version = "0.12.9", default-features = false, features = ["rustls-tls"], optional = true }
sha2 = { workspace = true, optional = true }
diff --git a/mullvad-update/src/client/api.rs b/mullvad-update/src/client/api.rs
index d4806325e5..b037e9b1cf 100644
--- a/mullvad-update/src/client/api.rs
+++ b/mullvad-update/src/client/api.rs
@@ -1,12 +1,17 @@
//! This module implements fetching of information about app versions
+use std::path::PathBuf;
+
use anyhow::Context;
+use tokio::fs;
#[cfg(test)]
use vec1::Vec1;
use crate::format;
use crate::version::{VersionInfo, VersionParameters};
+use super::version_provider::VersionInfoProvider;
+
/// Available platforms in the default metadata repository
#[derive(Debug, Clone, Copy)]
pub enum MetaRepositoryPlatform {
@@ -47,27 +52,24 @@ impl MetaRepositoryPlatform {
}
}
-/// See [module-level](self) docs.
-pub trait VersionInfoProvider {
- /// Return info about the stable version
- fn get_version_info(
- &self,
- params: VersionParameters,
- ) -> impl std::future::Future<Output = anyhow::Result<VersionInfo>> + Send;
-}
-
/// Obtain version data using a GET request
pub struct HttpVersionInfoProvider {
/// Endpoint for GET request
url: String,
/// Accepted root certificate. Defaults are used unless specified
pinned_certificate: Option<reqwest::Certificate>,
+ /// If set, the response metadata will be serialized and written to this path
+ dump_to_path: Option<PathBuf>,
}
impl VersionInfoProvider for HttpVersionInfoProvider {
- async fn get_version_info(&self, params: VersionParameters) -> anyhow::Result<VersionInfo> {
+ async fn get_version_info(&self, params: &VersionParameters) -> anyhow::Result<VersionInfo> {
let response = self.get_versions(params.lowest_metadata_version).await?;
- VersionInfo::try_from_response(&params, response.signed)
+ VersionInfo::try_from_response(params, response.signed)
+ }
+
+ fn set_metadata_dump_path(&mut self, path: PathBuf) {
+ self.dump_to_path = Some(path);
}
}
@@ -79,6 +81,7 @@ impl From<MetaRepositoryPlatform> for HttpVersionInfoProvider {
HttpVersionInfoProvider {
url: platform.url(),
pinned_certificate: Some(crate::defaults::PINNED_CERTIFICATE.clone()),
+ dump_to_path: None,
}
}
}
@@ -136,7 +139,13 @@ impl HttpVersionInfoProvider {
deserialize_fn: impl FnOnce(&[u8]) -> anyhow::Result<format::SignedResponse>,
) -> anyhow::Result<format::SignedResponse> {
let raw_json = Self::get(&self.url, self.pinned_certificate.clone()).await?;
- deserialize_fn(&raw_json)
+ let signed_response = deserialize_fn(&raw_json)?;
+ if let Some(path) = &self.dump_to_path {
+ fs::write(path, raw_json)
+ .await
+ .context("Failed to save cache")?;
+ }
+ Ok(signed_response)
}
/// Perform a simple GET request, with a size limit, and return it as bytes
@@ -191,10 +200,12 @@ impl HttpVersionInfoProvider {
#[cfg(test)]
mod test {
+ use async_tempfile::TempDir;
use insta::assert_yaml_snapshot;
use vec1::vec1;
use super::*;
+ use crate::{format::SignedResponse, local::METADATA_FILENAME};
// These tests rely on `insta` for snapshot testing. If they fail due to snapshot assertions,
// then most likely the snapshots need to be updated. The most convenient way to review
@@ -220,10 +231,14 @@ mod test {
let url = format!("{}/version", server.url());
+ let temp_dump_dir = TempDir::new().await.unwrap();
+ let temp_dump = temp_dump_dir.join(METADATA_FILENAME);
+
// Construct query and provider
let info_provider = HttpVersionInfoProvider {
url,
pinned_certificate: None,
+ dump_to_path: Some(temp_dump.clone()),
};
let info = info_provider
@@ -234,6 +249,13 @@ mod test {
// Expect: Our query should yield some version response
assert_yaml_snapshot!(info);
+ // Expect: Dumped data should exist and look the same
+ let cached_data = fs::read(temp_dump).await.expect("expected dumped info");
+ let cached_info =
+ SignedResponse::deserialize_and_verify_with_keys(&verifying_keys, &cached_data, 0)
+ .unwrap();
+ assert_eq!(cached_info, info);
+
Ok(())
}
}
diff --git a/mullvad-update/src/client/app.rs b/mullvad-update/src/client/app.rs
index 90dc4314e2..6145227407 100644
--- a/mullvad-update/src/client/app.rs
+++ b/mullvad-update/src/client/app.rs
@@ -2,13 +2,21 @@
//! This module implements the flow of downloading and verifying the app.
-use std::{ffi::OsString, future::Future, path::PathBuf, time::Duration};
+use std::{
+ ffi::OsString,
+ future::Future,
+ path::{Path, PathBuf},
+ time::Duration,
+};
+use anyhow::{bail, Context};
use tokio::{process::Command, time::timeout};
use crate::{
fetch::{self, ProgressUpdater},
+ format::SignedResponse,
verify::{AppVerifier, Sha256Verifier},
+ version::VersionParameters,
};
#[derive(Debug, thiserror::Error)]
@@ -39,25 +47,53 @@ pub struct AppDownloaderParameters<AppProgress> {
}
/// See the [module-level documentation](self).
-pub trait AppDownloader: Send {
+pub trait AppDownloader: Send + 'static {
/// Download the app binary.
- fn download_executable(&mut self) -> impl Future<Output = Result<(), DownloadError>> + Send;
+ fn download_executable(
+ self,
+ ) -> impl Future<Output = Result<impl DownloadedInstaller, DownloadError>> + Send;
+}
+
+/// A cache where we can find past [DownloadedInstaller]s
+pub trait AppCache: Send {
+ type Installer: DownloadedInstaller + Clone + PartialOrd;
+
+ fn new(directory: PathBuf, version_params: VersionParameters) -> Self;
+ fn get_metadata(&self) -> impl Future<Output = anyhow::Result<SignedResponse>> + Send;
+ fn get_cached_installers(self, metadata: SignedResponse) -> Vec<Self::Installer>;
+}
+pub trait DownloadedInstaller: Send + 'static {
/// Verify the app signature.
- fn verify(&mut self) -> impl Future<Output = Result<(), DownloadError>> + Send;
+ fn verify(self) -> impl Future<Output = Result<impl VerifiedInstaller, DownloadError>> + Send;
+ fn version(&self) -> &mullvad_version::Version;
+}
+
+pub trait VerifiedInstaller: Send {
/// Execute installer.
- fn install(&mut self) -> impl Future<Output = Result<(), DownloadError>> + Send;
+ fn install(self) -> impl Future<Output = Result<(), DownloadError>> + Send;
}
/// How long to wait for the installer to exit before returning
const INSTALLER_STARTUP_TIMEOUT: Duration = Duration::from_millis(500);
-/// Download the app and signature, and verify the app's signature
-pub async fn install_and_upgrade(mut downloader: impl AppDownloader) -> Result<(), DownloadError> {
- downloader.download_executable().await?;
- downloader.verify().await?;
- downloader.install().await
+/// Download the app and signature, and verify the installer's signature
+pub async fn download_install_and_upgrade(
+ downloader: impl AppDownloader,
+) -> Result<(), DownloadError> {
+ downloader
+ .download_executable()
+ .await?
+ .verify()
+ .await?
+ .install()
+ .await
+}
+
+/// Verify and run the installer.
+pub async fn install_and_upgrade(installer: impl DownloadedInstaller) -> Result<(), DownloadError> {
+ installer.verify().await?.install().await
}
#[derive(Clone)]
@@ -79,40 +115,79 @@ impl<AppProgress: ProgressUpdater> From<AppDownloaderParameters<AppProgress>>
}
}
+#[derive(Clone)]
+pub struct InstallerFile<const VERIFIED: bool> {
+ path: PathBuf,
+ pub app_version: mullvad_version::Version,
+ pub app_size: usize,
+ pub app_sha256: [u8; 32],
+}
+
+// TODO: Explain
+impl<const VERIFIED: bool> PartialOrd for InstallerFile<VERIFIED> {
+ fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+ self.app_version.partial_cmp(&other.app_version)
+ }
+}
+
+// TODO: Explain
+impl<const VERIFIED: bool> PartialEq for InstallerFile<VERIFIED> {
+ fn eq(&self, other: &Self) -> bool {
+ self.app_version == other.app_version
+ }
+}
+
impl<AppProgress: ProgressUpdater> AppDownloader for HttpAppDownloader<AppProgress> {
- async fn download_executable(&mut self) -> Result<(), DownloadError> {
- let bin_path = self.bin_path();
+ async fn download_executable(mut self) -> Result<impl DownloadedInstaller, DownloadError> {
+ let bin_path = bin_path(&self.params.cache_dir, &self.params.app_version);
fetch::get_to_file(
- bin_path,
+ &bin_path,
&self.params.app_url,
&mut self.params.app_progress,
fetch::SizeHint::Exact(self.params.app_size),
)
.await
- .map_err(DownloadError::FetchApp)
- }
+ .map_err(DownloadError::FetchApp)?;
- async fn verify(&mut self) -> Result<(), DownloadError> {
- let bin_path = self.bin_path();
- let hash = self.hash_sha256();
+ Ok(InstallerFile::<false> {
+ path: bin_path,
+ app_version: self.params.app_version,
+ app_size: self.params.app_size,
+ app_sha256: self.params.app_sha256,
+ })
+ }
+}
- match Sha256Verifier::verify(&bin_path, *hash)
+impl DownloadedInstaller for InstallerFile<false> {
+ async fn verify(self) -> Result<impl VerifiedInstaller, DownloadError> {
+ match Sha256Verifier::verify(&self.path, self.app_sha256)
.await
.map_err(DownloadError::Verification)
{
// Verification succeeded
- Ok(()) => Ok(()),
+ Ok(()) => Ok(InstallerFile::<true> {
+ path: self.path,
+ app_version: self.app_version,
+ app_size: self.app_size,
+ app_sha256: self.app_sha256,
+ }),
// Verification failed
Err(err) => {
// Attempt to clean up
- let _ = tokio::fs::remove_file(bin_path).await;
+ let _ = tokio::fs::remove_file(&self.path).await;
Err(err)
}
}
}
- async fn install(&mut self) -> Result<(), DownloadError> {
- let launch_path = self.launch_path();
+ fn version(&self) -> &mullvad_version::Version {
+ &self.app_version
+ }
+}
+
+impl VerifiedInstaller for InstallerFile<true> {
+ async fn install(self) -> Result<(), DownloadError> {
+ let launch_path = &self.launch_path();
// Launch process
let mut cmd = Command::new(launch_path);
@@ -133,21 +208,61 @@ impl<AppProgress: ProgressUpdater> AppDownloader for HttpAppDownloader<AppProgre
}
}
-impl<AppProgress> HttpAppDownloader<AppProgress> {
- fn bin_path(&self) -> PathBuf {
- #[cfg(windows)]
- let bin_filename = format!("mullvad-{}.exe", self.params.app_version);
+fn bin_path(cache_dir: &Path, app_version: &mullvad_version::Version) -> PathBuf {
+ #[cfg(windows)]
+ let bin_filename = format!("mullvad-{}.exe", app_version);
- #[cfg(target_os = "macos")]
- let bin_filename = format!("mullvad-{}.pkg", self.params.app_version);
+ #[cfg(target_os = "macos")]
+ let bin_filename = format!("mullvad-{}.pkg", app_version);
- self.params.cache_dir.join(bin_filename)
+ cache_dir.join(bin_filename)
+}
+
+impl InstallerFile<false> {
+ /// Create an unverified [InstallerFile] from a cache_dir and some metadata.
+ pub fn try_from_version(
+ cache_dir: &Path,
+ version: crate::version::Version,
+ ) -> anyhow::Result<Self> {
+ let path = bin_path(cache_dir, &version.version);
+ if !path.exists() {
+ bail!("Installer file does not exist at path: {}", path.display());
+ }
+ Ok(Self {
+ path,
+ app_version: version.version,
+ app_size: version.size,
+ app_sha256: version.sha256,
+ })
}
+ pub fn try_from_installer(
+ cache_dir: &Path,
+ app_version: mullvad_version::Version,
+ installer: crate::format::Installer,
+ ) -> anyhow::Result<Self> {
+ let path = bin_path(cache_dir, &app_version);
+ if !path.exists() {
+ bail!("Installer file does not exist at path: {}", path.display());
+ }
+ let app_sha256 = hex::decode(installer.sha256)
+ .context("Invalid checksum hex")?
+ .try_into()
+ .map_err(|_| anyhow::anyhow!("Invalid checksum length"))?;
+ Ok(Self {
+ path,
+ app_version,
+ app_size: installer.size,
+ app_sha256,
+ })
+ }
+}
+
+impl<const VERIFIED: bool> InstallerFile<VERIFIED> {
fn launch_path(&self) -> PathBuf {
#[cfg(target_os = "windows")]
{
- self.bin_path()
+ self.path.clone()
}
#[cfg(target_os = "macos")]
@@ -166,11 +281,7 @@ impl<AppProgress> HttpAppDownloader<AppProgress> {
#[cfg(target_os = "macos")]
{
- vec![self.bin_path().into()]
+ vec![self.path.clone().into_os_string()]
}
}
-
- fn hash_sha256(&self) -> &[u8; 32] {
- &self.params.app_sha256
- }
}
diff --git a/mullvad-update/src/client/local.rs b/mullvad-update/src/client/local.rs
new file mode 100644
index 0000000000..160e995ec3
--- /dev/null
+++ b/mullvad-update/src/client/local.rs
@@ -0,0 +1,79 @@
+//! This module implements fetching of information about app versions from disk.
+
+use anyhow::{bail, Context};
+use std::{path::PathBuf, vec};
+use tokio::fs;
+
+use crate::{format::SignedResponse, version::VersionParameters};
+
+use super::app::{AppCache, InstallerFile};
+
+pub struct AppCacheDir {
+ /// Path to directory containing the metadata file and the downloaded installer.
+ pub directory: PathBuf,
+ pub version_params: VersionParameters,
+}
+
+pub const METADATA_FILENAME: &str = "metadata.json";
+
+impl AppCache for AppCacheDir {
+ type Installer = InstallerFile<false>;
+
+ fn new(directory: PathBuf, version_params: VersionParameters) -> Self {
+ Self {
+ directory,
+ version_params,
+ }
+ }
+
+ async fn get_metadata(&self) -> anyhow::Result<crate::format::SignedResponse> {
+ let metadata_file = self.directory.join(METADATA_FILENAME);
+ let raw_json = fs::read(metadata_file)
+ .await
+ .context("Failed to read metadata.json")?;
+ let response = SignedResponse::deserialize_and_verify(
+ &raw_json,
+ self.version_params.lowest_metadata_version,
+ )
+ .context("Failed to deserialize or verify metadata.json")?;
+ Ok(response)
+ }
+
+ /// Get an iterator of cached installers for the current architecture
+ fn get_cached_installers(self, metadata: SignedResponse) -> Vec<Self::Installer> {
+ let releases = metadata.get_releases();
+ releases
+ .into_iter()
+ // Map releases to their version and its installer for the current architecture
+ .flat_map(move |release| {
+ release
+ .installers
+ .into_iter()
+ .find(|installer| installer.architecture == self.version_params.architecture)
+ .map(|installer| (release.version, installer))
+ })
+ // Map to an `InstallerFile`, and filter out installers not present in cache
+ .filter_map(move |(version, installer)| {
+ InstallerFile::<false>::try_from_installer(&self.directory, version, installer).ok()
+ }).collect()
+ }
+}
+
+/// App cacher that does not return anything
+pub struct NoopAppCacheDir;
+
+impl AppCache for NoopAppCacheDir {
+ type Installer = InstallerFile<false>;
+
+ fn new(_directory: PathBuf, _version_params: VersionParameters) -> Self {
+ NoopAppCacheDir
+ }
+
+ fn get_cached_installers(self, _metadata: SignedResponse) -> Vec<Self::Installer> {
+ vec![]
+ }
+
+ async fn get_metadata(&self) -> anyhow::Result<SignedResponse> {
+ bail!("NoopAppCacheDir can not present any metadata")
+ }
+}
diff --git a/mullvad-update/src/client/mod.rs b/mullvad-update/src/client/mod.rs
index 4d8a4cc67a..d0ea4c6ac0 100644
--- a/mullvad-update/src/client/mod.rs
+++ b/mullvad-update/src/client/mod.rs
@@ -1,4 +1,6 @@
pub mod api;
pub mod app;
pub mod fetch;
+pub mod local;
pub mod verify;
+pub mod version_provider;
diff --git a/mullvad-update/src/client/version_provider.rs b/mullvad-update/src/client/version_provider.rs
new file mode 100644
index 0000000000..d79bbf15a4
--- /dev/null
+++ b/mullvad-update/src/client/version_provider.rs
@@ -0,0 +1,14 @@
+use std::path::PathBuf;
+
+use crate::version::{VersionInfo, VersionParameters};
+
+/// See [module-level](self) docs.
+pub trait VersionInfoProvider {
+ /// Return info about the stable version
+ fn get_version_info(
+ &self,
+ params: &VersionParameters,
+ ) -> impl std::future::Future<Output = anyhow::Result<VersionInfo>> + Send;
+
+ fn set_metadata_dump_path(&mut self, path: PathBuf);
+}
diff --git a/mullvad-update/src/format/mod.rs b/mullvad-update/src/format/mod.rs
index 1e2b88bd5f..4b64950fe5 100644
--- a/mullvad-update/src/format/mod.rs
+++ b/mullvad-update/src/format/mod.rs
@@ -24,6 +24,7 @@ pub mod serializer;
/// This type does not implement [serde::Deserialize] to prevent accidental deserialization without
/// signature verification.
#[derive(Debug, Serialize)]
+#[cfg_attr(test, derive(PartialEq))]
pub struct SignedResponse {
/// Signatures of the canonicalized JSON of `signed`
pub signatures: Vec<ResponseSignature>,
@@ -31,10 +32,16 @@ pub struct SignedResponse {
pub signed: Response,
}
+impl SignedResponse {
+ pub fn get_releases(self) -> Vec<Release> {
+ self.signed.releases
+ }
+}
+
/// Helper type that leaves the signed data untouched
/// Note that deserializing doesn't verify anything
#[derive(Deserialize, Serialize)]
-#[cfg_attr(test, derive(Debug))]
+#[cfg_attr(test, derive(Debug, PartialEq))]
struct PartialSignedResponse {
/// Signatures of the canonicalized JSON of `signed`
pub signatures: Vec<ResponseSignature>,
@@ -45,7 +52,7 @@ struct PartialSignedResponse {
/// Signed JSON response, not including the signature
#[derive(Default, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
-#[cfg_attr(test, derive(Clone))]
+#[cfg_attr(test, derive(Clone, PartialEq))]
pub struct Response {
/// Version counter
pub metadata_version: usize,
@@ -71,6 +78,18 @@ pub struct Release {
pub rollout: f32,
}
+impl PartialEq for Release {
+ fn eq(&self, other: &Self) -> bool {
+ self.version.eq(&other.version)
+ }
+}
+
+impl PartialOrd for Release {
+ fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+ self.version.partial_cmp(&other.version)
+ }
+}
+
/// A full rollout includes all users
fn complete_rollout() -> f32 {
1.
@@ -82,6 +101,7 @@ fn is_complete_rollout(b: impl std::borrow::Borrow<f32>) -> bool {
/// App installer
#[derive(Debug, Deserialize, Serialize, Clone)]
+#[cfg_attr(test, derive(PartialEq))]
pub struct Installer {
/// Installer architecture
pub architecture: Architecture,
@@ -114,6 +134,7 @@ impl Display for Architecture {
/// JSON response signature
#[derive(Debug, Deserialize, Serialize)]
+#[cfg_attr(test, derive(PartialEq))]
#[serde(tag = "keytype")]
#[serde(rename_all = "lowercase")]
pub enum ResponseSignature {
diff --git a/mullvad-update/src/version.rs b/mullvad-update/src/version.rs
index 4b94e66b42..1b3a861784 100644
--- a/mullvad-update/src/version.rs
+++ b/mullvad-update/src/version.rs
@@ -7,9 +7,10 @@
use std::cmp::Ordering;
use anyhow::Context;
+use itertools::Itertools;
use mullvad_version::PreStableType;
-use crate::format;
+use crate::format::{self, Installer};
/// Query type for [VersionInfo]
#[derive(Debug)]
@@ -77,71 +78,57 @@ impl VersionInfo {
params: &VersionParameters,
response: format::Response,
) -> anyhow::Result<Self> {
- let mut releases = response.releases;
-
- // Sort releases by version
- releases.sort_by(|a, b| a.version.partial_cmp(&b.version).unwrap_or(Ordering::Equal));
-
// Fail if there are duplicate versions.
- // Check this before anything else so that it's rejected indepentently of `params`.
- // Important! This must occur after sorting
- if let Some(dup_version) = Self::find_duplicate_version(&releases) {
- anyhow::bail!("API response contains at least one duplicated version: {dup_version}");
+ // Check this before anything else so that it's rejected independently of `params`.
+ if !response.releases.iter().map(|r| &r.version).all_unique() {
+ anyhow::bail!("API response contains multiple release for the same version");
}
- // Filter releases based on rollout and architecture
- let releases: Vec<_> = releases
- .into_iter()
- // Filter out releases that are not rolled out to us
- .filter(|release| release.rollout >= params.rollout)
- // Include only installers for the requested architecture
- .flat_map(|release| {
- release
- .installers
- .into_iter()
- .filter(|installer| params.architecture == installer.architecture)
- // Map each artifact to a [IntermediateVersion]
- .map(move |installer| {
- IntermediateVersion {
- version: release.version.clone(),
- changelog: release.changelog.clone(),
- installer,
- }
+ // Filter releases based on rollout, architecture and dev versions.
+ let available_versions: Vec<Version> = response.releases
+ .into_iter()
+ // Filter out releases that are not rolled out to us
+ .filter(|release| release.rollout >= params.rollout)
+ // Filter out dev versions
+ .filter(|release| !release.version.is_dev())
+ .flat_map(|format::Release { version, changelog, installers, .. }| {
+ installers
+ .into_iter()
+ // Find installer for the requested architecture (assumed to be unique)
+ .find(|installer| params.architecture == installer.architecture)
+ // Map each artifact to a [IntermediateVersion]
+ .map(|Installer { urls, size, sha256,.. }| {
+ anyhow::Ok(Version {
+ version,
+ size,
+ urls,
+ changelog,
+ sha256: hex::decode(sha256)
+ .context("Invalid checksum hex")?
+ .try_into()
+ .map_err(|_| anyhow::anyhow!("Invalid checksum length"))?,
})
- })
- .collect();
+ })
+ }).try_collect()?;
// Find latest stable version
- let stable = releases
+ let stable = available_versions
.iter()
- .rfind(|release| release.version.pre_stable.is_none() && !release.version.is_dev());
- let Some(stable) = stable.cloned() else {
- anyhow::bail!("No stable version found");
- };
+ .filter(|release| release.version.pre_stable.is_none())
+ .max_by(|a, b| a.version.partial_cmp(&b.version).unwrap_or(Ordering::Equal))
+ .context("No stable version found")?
+ .clone();
// Find the latest beta version
- let beta = releases
+ let beta = available_versions
.iter()
- // Find most recent beta version
- .rfind(|release| matches!(release.version.pre_stable, Some(PreStableType::Beta(_))) && !release.version.is_dev())
+ .filter(|release| matches!(release.version.pre_stable, Some(PreStableType::Beta(_))))
// If the latest beta version is older than latest stable, dispose of it
.filter(|release| release.version > stable.version)
+ .max_by(|a, b| a.version.partial_cmp(&b.version).unwrap_or(Ordering::Equal))
.cloned();
- Ok(Self {
- stable: Version::try_from(stable)?,
- beta: beta.map(Version::try_from).transpose()?,
- })
- }
-
- /// Returns the first duplicated version found in `releases`.
- /// `None` is returned if there are no duplicates.
- /// NOTE: `releases` MUST be sorted on the version number
- fn find_duplicate_version(releases: &[format::Release]) -> Option<&mullvad_version::Version> {
- releases
- .windows(2)
- .find(|pair| pair[0].version == pair[1].version)
- .map(|pair| &pair[0].version)
+ Ok(Self { stable, beta })
}
}
diff --git a/mullvad-version/src/lib.rs b/mullvad-version/src/lib.rs
index 6dd411f1be..f97a730422 100644
--- a/mullvad-version/src/lib.rs
+++ b/mullvad-version/src/lib.rs
@@ -8,7 +8,7 @@ use regex_lite::Regex;
/// The Mullvad VPN app product version
pub const VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/product-version.txt"));
-#[derive(Debug, Clone, PartialEq)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Version {
pub year: u32,
pub incremental: u32,
@@ -18,7 +18,7 @@ pub struct Version {
pub dev: Option<String>,
}
-#[derive(Debug, Clone, Eq, PartialEq)]
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub enum PreStableType {
Alpha(u32),
Beta(u32),