feat: bundle type detection at runtime via binary patching (#13209)

* patch binary with bundle type info

* only patch if the updater is included

* fix linux warnings

* patch binary when updaer is configured

* patch binary with bundle type info

only patch if the updater is included

fix linux warnings

patch binary when updaer is configured

* fix formatting

* fix license header

* fix taplo error

* move __TAURI_BUNDLE_TYPE to utils

* export get_current_bundle_type

* macos fix

* cleanup, add api

* update change file

* fix windows

* fmt, fix rust version support

* fix macos

---------

Co-authored-by: Lucas Nogueira <lucas@tauri.app>
This commit is contained in:
kandrelczyk
2025-07-07 17:08:00 +02:00
committed by GitHub
parent 02440b875c
commit 232265c70e
19 changed files with 376 additions and 62 deletions

View File

@@ -0,0 +1,5 @@
---
"@tauri-apps/api": patch:feat
---
Added `getBundleType` to the app module.

View File

@@ -0,0 +1,5 @@
---
tauri-cli: patch:enhance
---
Binaries are patched before bundling to add the type of a bundle they will placed in. This information will be used during update process to select the correct target.

View File

@@ -0,0 +1,5 @@
---
"tauri-utils": patch:feat
---
Added `platform::bundle_type`.

109
.gitignore vendored
View File

@@ -1,54 +1,55 @@
# dependency directories
node_modules/
# Optional npm and yarn cache directory
.npm/
.yarn/
# Output of 'npm pack'
*.tgz
# dotenv environment variables file
.env
# .vscode workspace settings file
.vscode/settings.json
.vscode/launch.json
.vscode/tasks.json
# npm, yarn and bun lock files
package-lock.json
yarn.lock
bun.lockb
# rust compiled folders
target/
# test video for streaming example
streaming_example_test_video.mp4
# examples /gen directory
/examples/**/src-tauri/gen/
/bench/**/src-tauri/gen/
# logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# runtime data
pids
*.pid
*.seed
*.pid.lock
# miscellaneous
/.vs
.DS_Store
.Thumbs.db
*.sublime*
.idea
debug.log
TODO.md
# dependency directories
node_modules/
# Optional npm and yarn cache directory
.npm/
.yarn/
# Output of 'npm pack'
*.tgz
# dotenv environment variables file
.env
# .vscode workspace settings file
.vscode/settings.json
.vscode/launch.json
.vscode/tasks.json
# npm, yarn and bun lock files
package-lock.json
yarn.lock
bun.lockb
# rust compiled folders
target/
# test video for streaming example
streaming_example_test_video.mp4
# examples /gen directory
/examples/**/src-tauri/gen/
/bench/**/src-tauri/gen/
# logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# runtime data
pids
*.pid
*.seed
*.pid.lock
# miscellaneous
/.vs
.DS_Store
.Thumbs.db
*.sublime*
.idea
debug.log
TODO.md
.aider*

14
Cargo.lock generated
View File

@@ -277,7 +277,7 @@ dependencies = [
"figment",
"filetime",
"glob",
"goblin",
"goblin 0.8.2",
"hex",
"log",
"md-5",
@@ -2980,6 +2980,17 @@ dependencies = [
"scroll",
]
[[package]]
name = "goblin"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daa0a64d21a7eb230583b4c5f4e23b7e4e57974f96620f42a7e75e08ae66d745"
dependencies = [
"log",
"plain",
"scroll",
]
[[package]]
name = "group"
version = "0.13.0"
@@ -8459,6 +8470,7 @@ dependencies = [
"dunce",
"flate2",
"glob",
"goblin 0.9.3",
"handlebars",
"heck 0.5.0",
"hex",

View File

@@ -43,6 +43,7 @@ dunce = "1"
url = "2"
uuid = { version = "1", features = ["v4", "v5"] }
regex = "1"
goblin = "0.9"
[target."cfg(target_os = \"windows\")".dependencies]
bitness = "0.4"

View File

@@ -15,6 +15,32 @@ mod windows;
use tauri_utils::{display_path, platform::Target as TargetPlatform};
/// Patch a binary with bundle type information
fn patch_binary(binary: &PathBuf, package_type: &PackageType) -> crate::Result<()> {
match package_type {
#[cfg(target_os = "linux")]
PackageType::AppImage | PackageType::Deb | PackageType::Rpm => {
log::info!(
"Patching binary {:?} for type {}",
binary,
package_type.short_name()
);
linux::patch_binary(binary, package_type)?;
}
PackageType::Nsis | PackageType::WindowsMsi => {
log::info!(
"Patching binary {:?} for type {}",
binary,
package_type.short_name()
);
windows::patch_binary(binary, package_type)?;
}
_ => (),
}
Ok(())
}
pub use self::{
category::AppCategory,
settings::{
@@ -87,6 +113,12 @@ pub fn bundle_project(settings: &Settings) -> crate::Result<Vec<Bundle>> {
}
}
let main_binary = settings
.binaries()
.iter()
.find(|b| b.main())
.expect("Main binary missing in settings");
let mut bundles = Vec::<Bundle>::new();
for package_type in &package_types {
// bundle was already built! e.g. DMG already built .app
@@ -94,6 +126,8 @@ pub fn bundle_project(settings: &Settings) -> crate::Result<Vec<Bundle>> {
continue;
}
patch_binary(&settings.binary_path(main_binary), package_type)?;
let bundle_paths = match package_type {
#[cfg(target_os = "macos")]
PackageType::MacOsBundle => macos::app::bundle_project(settings)?,

View File

@@ -7,3 +7,8 @@ pub mod appimage;
pub mod debian;
pub mod freedesktop;
pub mod rpm;
mod util;
#[cfg(target_os = "linux")]
pub use util::patch_binary;

View File

@@ -0,0 +1,59 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
/// Change value of __TAURI_BUNDLE_TYPE static variable to mark which package type it was bundled in
#[cfg(target_os = "linux")]
pub fn patch_binary(
binary_path: &std::path::PathBuf,
package_type: &crate::PackageType,
) -> crate::Result<()> {
let mut file_data = std::fs::read(binary_path).expect("Could not read binary file.");
let elf = match goblin::Object::parse(&file_data)? {
goblin::Object::Elf(elf) => elf,
_ => return Err(crate::Error::GenericError("Not an ELF file".to_owned())),
};
let offset = find_bundle_type_symbol(elf).ok_or(crate::Error::MissingBundleTypeVar)?;
let offset = offset as usize;
if offset + 3 <= file_data.len() {
let chars = &mut file_data[offset..offset + 3];
match package_type {
crate::PackageType::Deb => chars.copy_from_slice(b"DEB"),
crate::PackageType::Rpm => chars.copy_from_slice(b"RPM"),
crate::PackageType::AppImage => chars.copy_from_slice(b"APP"),
_ => {
return Err(crate::Error::InvalidPackageType(
package_type.short_name().to_owned(),
"linux".to_owned(),
))
}
}
std::fs::write(binary_path, &file_data)
.map_err(|error| crate::Error::BinaryWriteError(error.to_string()))?;
} else {
return Err(crate::Error::BinaryOffsetOutOfRange);
}
Ok(())
}
/// Find address of a symbol in relocations table
#[cfg(target_os = "linux")]
fn find_bundle_type_symbol(elf: goblin::elf::Elf<'_>) -> Option<i64> {
for sym in elf.syms.iter() {
if let Some(name) = elf.strtab.get_at(sym.st_name) {
if name == "__TAURI_BUNDLE_TYPE" {
for reloc in elf.dynrelas.iter() {
if reloc.r_offset == sym.st_value {
return Some(reloc.r_addend.unwrap());
}
}
}
}
}
None
}

View File

@@ -5,6 +5,7 @@
#[cfg(target_os = "windows")]
pub mod msi;
pub mod nsis;
pub mod sign;
@@ -13,3 +14,5 @@ pub use util::{
NSIS_OUTPUT_FOLDER_NAME, NSIS_UPDATER_OUTPUT_FOLDER_NAME, WIX_OUTPUT_FOLDER_NAME,
WIX_UPDATER_OUTPUT_FOLDER_NAME,
};
pub use util::patch_binary;

View File

@@ -6,7 +6,6 @@ use std::{
fs::create_dir_all,
path::{Path, PathBuf},
};
use ureq::ResponseExt;
use crate::utils::http_utils::download;
@@ -84,3 +83,77 @@ pub fn os_bitness<'a>() -> Option<&'a str> {
_ => None,
}
}
pub fn patch_binary(binary_path: &PathBuf, package_type: &crate::PackageType) -> crate::Result<()> {
let file_data = std::fs::read(binary_path)?;
let mut file_data = file_data; // make mutable
let pe = match goblin::Object::parse(&file_data)? {
goblin::Object::PE(pe) => pe,
_ => {
return Err(crate::Error::BinaryParseError(
std::io::Error::new(std::io::ErrorKind::InvalidInput, "binary is not a PE file").into(),
));
}
};
let tauri_bundle_section = pe
.sections
.iter()
.find(|s| s.name().unwrap_or_default() == ".taubndl")
.ok_or(crate::Error::MissingBundleTypeVar)?;
let data_offset = tauri_bundle_section.pointer_to_raw_data as usize;
if data_offset + 8 > file_data.len() {
return Err(crate::Error::BinaryOffsetOutOfRange);
}
let ptr_bytes = &file_data[data_offset..data_offset + 8];
let ptr_value = u64::from_le_bytes(ptr_bytes.try_into().map_err(|_| {
crate::Error::BinaryParseError(
std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid pointer bytes").into(),
)
})?);
let rdata_section = pe
.sections
.iter()
.find(|s| s.name().unwrap_or_default() == ".rdata")
.ok_or_else(|| {
crate::Error::BinaryParseError(
std::io::Error::new(std::io::ErrorKind::InvalidInput, ".rdata section not found").into(),
)
})?;
let rva = ptr_value.checked_sub(pe.image_base as u64).ok_or_else(|| {
crate::Error::BinaryParseError(
std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid RVA offset").into(),
)
})?;
let file_offset = rdata_section.pointer_to_raw_data as usize
+ (rva as usize).saturating_sub(rdata_section.virtual_address as usize);
if file_offset + 3 > file_data.len() {
return Err(crate::Error::BinaryOffsetOutOfRange);
}
// Overwrite the string at that offset
let string_bytes = &mut file_data[file_offset..file_offset + 3];
match package_type {
crate::PackageType::Nsis => string_bytes.copy_from_slice(b"NSS"),
crate::PackageType::WindowsMsi => string_bytes.copy_from_slice(b"MSI"),
_ => {
return Err(crate::Error::InvalidPackageType(
package_type.short_name().to_owned(),
"windows".to_owned(),
));
}
}
std::fs::write(binary_path, &file_data)
.map_err(|e| crate::Error::BinaryWriteError(e.to_string()))?;
Ok(())
}

View File

@@ -64,6 +64,21 @@ pub enum Error {
/// Failed to validate downloaded file hash.
#[error("hash mismatch of downloaded file")]
HashError,
/// Failed to parse binary
#[error("Binary parse error: `{0}`")]
BinaryParseError(#[from] goblin::error::Error),
/// Package type is not supported by target platform
#[error("Wrong package type {0} for platform {1}")]
InvalidPackageType(String, String),
/// Bundle type symbol missing in binary
#[error("__TAURI_BUNDLE_TYPE variable not found in binary. Make sure tauri crate and tauri-cli are up to date")]
MissingBundleTypeVar,
/// Failed to write binary file changed
#[error("Failed to write binary file changes: `{0}`")]
BinaryWriteError(String),
/// Invalid offset while patching binary file
#[error("Invalid offset while patching binary file")]
BinaryOffsetOutOfRange,
/// Unsupported architecture.
#[error("Architecture Error: `{0}`")]
ArchError(String),

View File

@@ -8,7 +8,7 @@ use std::{fmt::Display, path::PathBuf};
use serde::{Deserialize, Serialize};
use crate::{Env, PackageInfo};
use crate::{config::BundleType, Env, PackageInfo};
mod starting_binary;
@@ -345,6 +345,32 @@ fn resource_dir_from<P: AsRef<std::path::Path>>(
res
}
// Variable holding the type of bundle the executable is stored in. This is modified by binary
// patching during build
#[no_mangle]
#[cfg_attr(not(target_vendor = "apple"), link_section = ".taubndl")]
#[cfg_attr(target_vendor = "apple", link_section = "__DATA,taubndl")]
static __TAURI_BUNDLE_TYPE: &str = "UNK";
/// Get the type of the bundle current binary is packaged in.
/// If the bundle type is unknown, it returns [`Option::None`].
pub fn bundle_type() -> Option<BundleType> {
match __TAURI_BUNDLE_TYPE {
"DEB" => Some(BundleType::Deb),
"RPM" => Some(BundleType::Rpm),
"APP" => Some(BundleType::AppImage),
"MSI" => Some(BundleType::Msi),
"NSS" => Some(BundleType::Nsis),
_ => {
if cfg!(target_os = "macos") {
Some(BundleType::App)
} else {
None
}
}
}
}
#[cfg(feature = "build")]
mod build {
use proc_macro2::TokenStream;

View File

@@ -161,6 +161,7 @@ const PLUGINS: &[(&str, &[(&str, bool)])] = &[
("default_window_icon", false),
("set_app_theme", false),
("set_dock_visibility", false),
("bundle_type", true),
],
),
(

View File

@@ -8,6 +8,7 @@ Default permissions for the plugin.
- `allow-name`
- `allow-tauri-version`
- `allow-identifier`
- `allow-bundle-type`
## Permission Table
@@ -73,6 +74,32 @@ Denies the app_show command without any pre-configured scope.
<tr>
<td>
`core:app:allow-bundle-type`
</td>
<td>
Enables the bundle_type command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`core:app:deny-bundle-type`
</td>
<td>
Denies the bundle_type command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`core:app:allow-default-window-icon`
</td>

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use tauri_utils::Theme;
use tauri_utils::{config::BundleType, Theme};
use crate::{
command,
@@ -110,6 +110,11 @@ pub async fn set_dock_visibility<R: Runtime>(
Ok(())
}
#[command(root = "crate")]
pub fn bundle_type() -> Option<BundleType> {
tauri_utils::platform::bundle_type()
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("app")
.invoke_handler(crate::generate_handler![
@@ -125,6 +130,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
default_window_icon,
set_app_theme,
set_dock_visibility,
bundle_type,
])
.build()
}

View File

@@ -1,12 +1,18 @@
<script>
import { invoke } from '@tauri-apps/api/core'
import { getName, getVersion, getTauriVersion } from '@tauri-apps/api/app'
import {
getName,
getVersion,
getTauriVersion,
getBundleType
} from '@tauri-apps/api/app'
let { onMessage } = $props()
let version = $state('1.0.0')
let tauriVersion = $state('1.0.0')
let appName = $state('Unknown')
let bundleType = $state('Unknown')
getName().then((n) => {
appName = n
@@ -17,6 +23,11 @@
getTauriVersion().then((v) => {
tauriVersion = v
})
getBundleType().then((b) => {
if (b) {
bundleType = b
}
})
function contextMenu() {
invoke('plugin:app-menu|popup')
@@ -34,7 +45,9 @@
<pre>
App name: <code>{appName}</code>
App version: <code>{version}</code>
Tauri version: <code>{tauriVersion}</code></pre>
Tauri version: <code>{tauriVersion}</code>
Bundle type: <code>{bundleType}</code>
</pre>
<button class="btn" onclick={contextMenu}>Context menu</button>
</div>

View File

@@ -25,6 +25,24 @@ export type DataStoreIdentifier = [
number
]
/**
* Bundle type of the current application.
*/
export enum BundleType {
/** Windows NSIS */
Nsis = 'nsis',
/** Windows MSI */
Msi = 'msi',
/** Linux Debian package */
Deb = 'deb',
/** Linux RPM */
Rpm = 'rpm',
/** Linux AppImage */
AppImage = 'appimage',
/** macOS app bundle */
App = 'app'
}
/**
* Application metadata and related APIs.
*
@@ -206,6 +224,10 @@ async function setDockVisibility(visible: boolean): Promise<void> {
return invoke('plugin:app|set_dock_visibility', { visible })
}
async function getBundleType(): Promise<BundleType> {
return invoke('plugin:app|bundle_type')
}
export {
getName,
getVersion,
@@ -217,5 +239,6 @@ export {
setTheme,
fetchDataStoreIdentifiers,
removeDataStore,
setDockVisibility
setDockVisibility,
getBundleType
}