Skip to content

feat(updater): support bundle-specific targets #2624

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 41 commits into
base: v2
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
8aa131a
fallback targets
kandrelczyk Feb 20, 2025
597ae9f
Merge branch 'tauri-apps:v2' into feature/fallback_targets
kandrelczyk Feb 21, 2025
6ae53cf
linux test
kandrelczyk Mar 1, 2025
e2e27ac
Merge branch 'feature/fallback_targets' of github.com:kandrelczyk/plu…
kandrelczyk Mar 1, 2025
ae7a2e3
linux ready
kandrelczyk Mar 1, 2025
dc75b76
RPM installation
kandrelczyk Mar 3, 2025
199a52b
small error fix
kandrelczyk Mar 5, 2025
ede0c68
fix windows build
kandrelczyk Mar 7, 2025
d50947c
windows tests
kandrelczyk Mar 7, 2025
ef95298
Merge branch 'feature/fallback_targets' of github.com:kandrelczyk/plu…
kandrelczyk Mar 7, 2025
012f633
add aider files to .gitignore
kandrelczyk Mar 9, 2025
c2877ec
get bundle type out of patched variable
kandrelczyk Mar 18, 2025
c9d0a6c
windows tests
kandrelczyk Mar 18, 2025
896678a
Merge branch 'feature/fallback_targets' of github.com:kandrelczyk/plu…
kandrelczyk Apr 1, 2025
8cb79a3
patch windows binary
kandrelczyk Apr 6, 2025
b1a8781
format
kandrelczyk Apr 7, 2025
0630002
Merge branch 'feature/fallback_targets' of github.com:kandrelczyk/plu…
kandrelczyk Apr 7, 2025
3a43397
fix bundler
kandrelczyk Apr 10, 2025
68564e0
Merge branch 'v2' of github.com:kandrelczyk/plugins-workspace into fe…
kandrelczyk Apr 10, 2025
24504e4
remove local tauri dependency
kandrelczyk Apr 11, 2025
5d12c97
remove print
kandrelczyk Apr 11, 2025
2137583
rever Cargo.lock
kandrelczyk Apr 12, 2025
b80a295
move __TAURI_BUNDLE_TYPE to tauri::utils
kandrelczyk Apr 12, 2025
f75d32b
get_current_bundle_type
kandrelczyk Apr 14, 2025
1ea7522
Merge remote-tracking branch 'origin/v2' into feature/fallback_targets
lucasfernog Jul 7, 2025
940ed70
update tauri
lucasfernog Jul 7, 2025
201a001
fix macos integration test
lucasfernog Jul 7, 2025
80b07ee
Merge branch 'v2' into feature/fallback_targets
kandrelczyk Jul 8, 2025
513376b
Merge branch 'feature/fallback_targets' of github.com:kandrelczyk/plu…
kandrelczyk Jul 8, 2025
9e9d7bc
fix fallback logic
kandrelczyk Jul 9, 2025
f7c10ea
amend! fallback targets
kandrelczyk Jul 10, 2025
2fdda08
reformat
kandrelczyk Jul 14, 2025
4913dbe
fix tests
kandrelczyk Jul 16, 2025
5eae160
reformat
kandrelczyk Jul 16, 2025
5056198
Merge branch 'v2' into feature/fallback_targets
kandrelczyk Jul 21, 2025
5b4c1c1
bump tari versio
kandrelczyk Jul 22, 2025
6c2f563
fix fallback logic
kandrelczyk Jul 23, 2025
c13e583
restore Cargo.lock
kandrelczyk Jul 24, 2025
043d89f
Merge branch 'v2' of https://github.com/tauri-apps/plugins-workspace …
Legend-Master Jul 25, 2025
0a495cc
Bump tauri and add notes
Legend-Master Jul 25, 2025
e67750c
Rename some staffs
Legend-Master Jul 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,5 @@ pids
*.sublime*
.idea
debug.log
TODO.md
TODO.md
.aider.*
14 changes: 10 additions & 4 deletions plugins/updater/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ pub enum Error {
/// Operating system is not supported.
#[error("Unsupported OS, expected one of `linux`, `darwin` or `windows`.")]
UnsupportedOs,
/// Can't determine which type of installer was used for the app
#[error("Couldn't determinet installation method")]
UnknownInstaller,
/// Failed to determine updater package extract path
#[error("Failed to determine updater package extract path.")]
FailedToDetermineExtractPath,
Expand All @@ -39,9 +42,12 @@ pub enum Error {
/// `reqwest` crate errors.
#[error(transparent)]
Reqwest(#[from] reqwest::Error),
/// The platform was not found on the updater JSON response.
#[error("the platform `{0}` was not found on the response `platforms` object")]
/// The platform was not found in the updater JSON response.
#[error("the platform `{0}` was not found in the response `platforms` object")]
TargetNotFound(String),
/// Neither the platform not the fallback platform was not found in the updater JSON response.
#[error("the platform `{0}` and `{1}` were not found in the response `platforms` object")]
TargetsNotFound(String, String),
/// Download failed
#[error("`{0}`")]
Network(String),
Expand All @@ -67,8 +73,8 @@ pub enum Error {
TempDirNotFound,
#[error("Authentication failed or was cancelled")]
AuthenticationFailed,
#[error("Failed to install .deb package")]
DebInstallFailed,
#[error("Failed to install package")]
PackageInstallFailed,
#[error("invalid updater binary format")]
InvalidUpdaterFormat,
#[error(transparent)]
Expand Down
201 changes: 127 additions & 74 deletions plugins/updater/src/updater.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ use reqwest::{
};
use semver::Version;
use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize};
use tauri::{utils::platform::current_exe, AppHandle, Resource, Runtime};
use tauri::{
utils::platform::current_exe, utils::__TAURI_BUNDLE_TYPE, AppHandle, Resource, Runtime,
};
use time::OffsetDateTime;
use url::Url;

Expand All @@ -37,6 +39,31 @@ use crate::{

const UPDATER_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);

#[derive(Clone)]
pub enum Installer {
AppImage,
Deb,
Rpm,

App,

Msi,
Nsis,
}

impl Installer {
fn suffix(self) -> &'static str {
match self {
Self::AppImage => "appimage",
Self::Deb => "deb",
Self::Rpm => "rpm",
Self::App => "app",
Self::Msi => "msi",
Self::Nsis => "nsis",
}
}
}

#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ReleaseManifestPlatform {
/// Download URL for the platform
Expand Down Expand Up @@ -71,26 +98,39 @@ pub struct RemoteRelease {

impl RemoteRelease {
/// The release's download URL for the given target.
pub fn download_url(&self, target: &str) -> Result<&Url> {
pub fn download_url(&self, target: &str, installer: Option<Installer>) -> Result<&Url> {
let fallback_target = installer.map(|installer| format!("{target}-{}", installer.suffix()));
match self.data {
RemoteReleaseInner::Dynamic(ref platform) => Ok(&platform.url),
RemoteReleaseInner::Static { ref platforms } => platforms
.get(target)
.map_or(Err(Error::TargetNotFound(target.to_string())), |p| {
Ok(&p.url)
}),
RemoteReleaseInner::Static { ref platforms } => platforms.get(target).map_or_else(
|| match fallback_target {
Some(fallback) => platforms.get(&fallback).map_or(
Err(Error::TargetsNotFound(target.to_string(), fallback)),
|p| Ok(&p.url),
),
None => Err(Error::TargetNotFound(target.to_string())),
},
|p| Ok(&p.url),
),
}
}

/// The release's signature for the given target.
pub fn signature(&self, target: &str) -> Result<&String> {
pub fn signature(&self, target: &str, installer: Option<Installer>) -> Result<&String> {
let fallback_target = installer.map(|installer| format!("{target}-{}", installer.suffix()));

match self.data {
RemoteReleaseInner::Dynamic(ref platform) => Ok(&platform.signature),
RemoteReleaseInner::Static { ref platforms } => platforms
.get(target)
.map_or(Err(Error::TargetNotFound(target.to_string())), |platform| {
Ok(&platform.signature)
}),
RemoteReleaseInner::Static { ref platforms } => platforms.get(target).map_or_else(
|| match fallback_target {
Some(fallback) => platforms.get(&fallback).map_or(
Err(Error::TargetsNotFound(target.to_string(), fallback)),
|p| Ok(&p.signature),
),
None => Err(Error::TargetNotFound(target.to_string())),
},
|p| Ok(&p.signature),
),
}
}
}
Expand Down Expand Up @@ -270,7 +310,8 @@ impl UpdaterBuilder {
(target.clone(), target)
} else {
let target = get_updater_target().ok_or(Error::UnsupportedOs)?;
(target.to_string(), format!("{target}-{arch}"))
let json_target = format!("{target}-{arch}");
(target.to_owned(), json_target)
};

let executable_path = self.executable_path.clone().unwrap_or(current_exe()?);
Expand Down Expand Up @@ -327,7 +368,7 @@ pub struct Updater {
proxy: Option<Url>,
endpoints: Vec<Url>,
arch: &'static str,
// The `{{target}}` variable we replace in the endpoint
// The `{{target}}` variable we replace in the endpoint and serach for in the JSON
target: String,
// The value we search if the updater server returns a JSON with the `platforms` object
json_target: String,
Expand All @@ -342,11 +383,21 @@ pub struct Updater {
}

impl Updater {
fn get_updater_installer(&self) -> Result<Option<Installer>> {
match __TAURI_BUNDLE_TYPE {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

__TAURI_BUNDLE_TYPE shouldn't be exported from Tauri, but we should map it to a PackageFormat type directly from tauri instead of doing the check here, it would make it type safe (instead of relying on string matching from tauri-cli)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do we want to do for Mac, iOS and Android? Or in general case when the binary was not patched and we don't know the bundle type? I think the simplest thing to do would be to return PackageFormat::Unknown in case the binary was not patched and use #[cfg(target_os = "macos")] to return PackageFormat::Dmg for Mac.

We have BundleType in tauri::utils::config but if we want to return Unknown I would have to create new type. What would be the best place to put it? tauri::utils::config? some new module?

"DEB_BUNDLE" => Ok(Some(Installer::Deb)),
"RPM_BUNDLE" => Ok(Some(Installer::Rpm)),
"APP_BUNDLE" => Ok(Some(Installer::AppImage)),
"MSI_BUNDLE" => Ok(Some(Installer::Msi)),
"NSS_BUNDLE" => Ok(Some(Installer::Nsis)),
_ => Err(Error::UnknownInstaller),
}
}

pub async fn check(&self) -> Result<Option<Update>> {
// we want JSON only
let mut headers = self.headers.clone();
headers.insert("Accept", HeaderValue::from_str("application/json").unwrap());

// Set SSL certs for linux if they aren't available.
#[cfg(target_os = "linux")]
{
Expand Down Expand Up @@ -464,6 +515,8 @@ impl Updater {
None => release.version > self.current_version,
};

let installer = self.get_updater_installer()?;

let update = if should_update {
Some(Update {
run_on_main_thread: self.run_on_main_thread.clone(),
Expand All @@ -475,9 +528,14 @@ impl Updater {
extract_path: self.extract_path.clone(),
version: release.version.to_string(),
date: release.pub_date,
download_url: release.download_url(&self.json_target)?.to_owned(),
signature: release.signature(&self.json_target)?.to_owned(),
body: release.notes,
download_url: release
.download_url(&self.json_target, installer.clone())?
.to_owned(),
body: release.notes.clone(),
signature: release
.signature(&self.json_target, installer.clone())?
.to_owned(),
installer,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like the installer would return Some even when the corresponding entry does not get used for it's download_url or signature.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this is correct. We use this information later in install_inner to determine installation method. So for example if the current binary was packaged as Deb but we didn't find specific download_url for deb and used the fallback we still assume that it points to deb package and will try to install it as deb. If we know the current binary was packaged as deb it doesn't make sense to try to install the update as AppImage. Only in case we don't know what the current bundle type is will we fallback to AppImage.

raw_json: raw_json.unwrap(),
timeout: None,
proxy: self.proxy.clone(),
Expand Down Expand Up @@ -511,6 +569,8 @@ pub struct Update {
pub date: Option<OffsetDateTime>,
/// Target
pub target: String,
/// Current installer
pub installer: Option<Installer>,
/// Download URL announced
pub download_url: Url,
/// Signature announced
Expand Down Expand Up @@ -843,11 +903,10 @@ impl Update {
/// └── ...
///
fn install_inner(&self, bytes: &[u8]) -> Result<()> {
if self.is_deb_package() {
self.install_deb(bytes)
} else {
// Handle AppImage or other formats
self.install_appimage(bytes)
match self.installer {
Some(Installer::Deb) => self.install_deb(bytes),
Some(Installer::Rpm) => self.install_rpm(bytes),
_ => self.install_appimage(bytes),
}
}

Expand Down Expand Up @@ -924,46 +983,25 @@ impl Update {
Err(Error::TempDirNotOnSameMountPoint)
}

fn is_deb_package(&self) -> bool {
// First check if we're in a typical Debian installation path
let in_system_path = self
.extract_path
.to_str()
.map(|p| p.starts_with("/usr"))
.unwrap_or(false);

if !in_system_path {
return false;
}

// Then verify it's actually a Debian-based system by checking for dpkg
let dpkg_exists = std::path::Path::new("/var/lib/dpkg").exists();
let apt_exists = std::path::Path::new("/etc/apt").exists();

// Additional check for the package in dpkg database
let package_in_dpkg = if let Ok(output) = std::process::Command::new("dpkg")
.args(["-S", &self.extract_path.to_string_lossy()])
.output()
{
output.status.success()
} else {
false
};

// Consider it a deb package only if:
// 1. We're in a system path AND
// 2. We have Debian package management tools AND
// 3. The binary is tracked by dpkg
dpkg_exists && apt_exists && package_in_dpkg
}

fn install_deb(&self, bytes: &[u8]) -> Result<()> {
// First verify the bytes are actually a .deb package
if !infer::archive::is_deb(bytes) {
log::warn!("update is not a valid deb package");
return Err(Error::InvalidUpdaterFormat);
}

self.try_tmp_locations(bytes, "dpkg", "-i")
}

fn install_rpm(&self, bytes: &[u8]) -> Result<()> {
// First verify the bytes are actually a .rpm package
if !infer::archive::is_rpm(bytes) {
return Err(Error::InvalidUpdaterFormat);
}
self.try_tmp_locations(bytes, "rpm", "-U")
}

fn try_tmp_locations(&self, bytes: &[u8], install_cmd: &str, install_arg: &str) -> Result<()> {
// Try different temp directories
let tmp_dir_locations = vec![
Box::new(|| Some(std::env::temp_dir())) as Box<dyn FnOnce() -> Option<PathBuf>>,
Expand All @@ -975,15 +1013,19 @@ impl Update {
for tmp_dir_location in tmp_dir_locations {
if let Some(path) = tmp_dir_location() {
if let Ok(tmp_dir) = tempfile::Builder::new()
.prefix("tauri_deb_update")
.prefix("tauri_rpm_update")
.tempdir_in(path)
{
let deb_path = tmp_dir.path().join("package.deb");
let pkg_path = tmp_dir.path().join("package.rpm");

// Try writing the .deb file
if std::fs::write(&deb_path, bytes).is_ok() {
if std::fs::write(&pkg_path, bytes).is_ok() {
// If write succeeds, proceed with installation
return self.try_install_with_privileges(&deb_path);
return self.try_install_with_privileges(
&pkg_path,
install_cmd,
install_arg,
);
}
// If write fails, continue to next temp location
}
Expand All @@ -994,12 +1036,17 @@ impl Update {
Err(Error::TempDirNotFound)
}

fn try_install_with_privileges(&self, deb_path: &Path) -> Result<()> {
fn try_install_with_privileges(
&self,
pkg_path: &Path,
install_cmd: &str,
install_arg: &str,
) -> Result<()> {
// 1. First try using pkexec (graphical sudo prompt)
if let Ok(status) = std::process::Command::new("pkexec")
.arg("dpkg")
.arg("-i")
.arg(deb_path)
.arg(install_cmd)
.arg(install_arg)
.arg(pkg_path)
.status()
{
if status.success() {
Expand All @@ -1010,24 +1057,24 @@ impl Update {

// 2. Try zenity or kdialog for a graphical sudo experience
if let Ok(password) = self.get_password_graphically() {
if self.install_with_sudo(deb_path, &password)? {
if self.install_with_sudo(pkg_path, &password, install_cmd, install_arg)? {
log::debug!("installed deb with GUI sudo");
return Ok(());
}
}

// 3. Final fallback: terminal sudo
let status = std::process::Command::new("sudo")
.arg("dpkg")
.arg("-i")
.arg(deb_path)
.arg(install_cmd)
.arg(install_arg)
.arg(pkg_path)
.status()?;

if status.success() {
log::debug!("installed deb with sudo");
Ok(())
} else {
Err(Error::DebInstallFailed)
Err(Error::PackageInstallFailed)
}
}

Expand Down Expand Up @@ -1061,15 +1108,21 @@ impl Update {
Err(Error::AuthenticationFailed)
}

fn install_with_sudo(&self, deb_path: &Path, password: &str) -> Result<bool> {
fn install_with_sudo(
&self,
pkg_path: &Path,
password: &str,
install_cmd: &str,
install_arg: &str,
) -> Result<bool> {
use std::io::Write;
use std::process::{Command, Stdio};

let mut child = Command::new("sudo")
.arg("-S") // read password from stdin
.arg("dpkg")
.arg("-i")
.arg(deb_path)
.arg(install_cmd)
.arg(install_arg)
.arg(pkg_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
Expand Down
Loading
Loading