feat: Support custom CFBundleVersion for iOS and macOS (#13030)

* feat: Add bundleVersion for iOS and macOS

* feat: Use bundleVersion for CFBundleVersion

* feat: Synchronize bundleVersion with Info.plist

* cleanup

* fix bundle version, enhance prerelease/buildnumber support and checks

* fix change file

* tauri-bundler to change file

* expand doc

---------

Co-authored-by: Lucas Nogueira <lucas@tauri.app>
This commit is contained in:
Matthew Richardson 2025-04-12 13:28:48 +01:00 committed by GitHub
parent 628f4a97e4
commit 0aa48fb9e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 200 additions and 40 deletions

View File

@ -0,0 +1,8 @@
---
"tauri-utils": minor:feat
"@tauri-apps/cli": minor:feat
"tauri-cli": minor:feat
"tauri-bundler": minor:feat
---
Added `bundleVersion` to iOS and macOS configuration to support specifying a `CFBundleVersion`.

View File

@ -19,8 +19,8 @@ pub use self::{
category::AppCategory,
settings::{
AppImageSettings, BundleBinary, BundleSettings, CustomSignCommandSettings, DebianSettings,
DmgSettings, MacOsSettings, PackageSettings, PackageType, Position, RpmSettings, Settings,
SettingsBuilder, Size, UpdaterSettings,
DmgSettings, IosSettings, MacOsSettings, PackageSettings, PackageType, Position, RpmSettings,
Settings, SettingsBuilder, Size, UpdaterSettings,
},
};
#[cfg(target_os = "macos")]

View File

@ -191,12 +191,6 @@ fn create_info_plist(
bundle_icon_file: Option<PathBuf>,
settings: &Settings,
) -> crate::Result<()> {
let format = time::format_description::parse("[year][month][day].[hour][minute][second]")
.map_err(time::error::Error::from)?;
let build_number = time::OffsetDateTime::now_utc()
.format(&format)
.map_err(time::error::Error::from)?;
let mut plist = plist::Dictionary::new();
plist.insert("CFBundleDevelopmentRegion".into(), "English".into());
plist.insert("CFBundleDisplayName".into(), settings.product_name().into());
@ -226,7 +220,15 @@ fn create_info_plist(
"CFBundleShortVersionString".into(),
settings.version_string().into(),
);
plist.insert("CFBundleVersion".into(), build_number.into());
plist.insert(
"CFBundleVersion".into(),
settings
.macos()
.bundle_version
.as_deref()
.unwrap_or_else(|| settings.version_string())
.into(),
);
plist.insert("CSResourcesFileMapped".into(), true.into());
if let Some(category) = settings.app_category() {
plist.insert(

View File

@ -178,7 +178,11 @@ fn generate_info_plist(
writeln!(
file,
" <key>CFBundleVersion</key>\n <string>{}</string>",
settings.version_string()
settings
.ios()
.bundle_version
.as_deref()
.unwrap_or_else(|| settings.version_string())
)?;
writeln!(
file,

View File

@ -306,6 +306,13 @@ pub struct DmgSettings {
pub application_folder_position: Position,
}
/// The iOS bundle settings.
#[derive(Clone, Debug, Default)]
pub struct IosSettings {
/// The version of the build that identifies an iteration of the bundle.
pub bundle_version: Option<String>,
}
/// The macOS bundle settings.
#[derive(Clone, Debug, Default)]
pub struct MacOsSettings {
@ -323,6 +330,8 @@ pub struct MacOsSettings {
/// List of custom files to add to the application bundle.
/// Maps the path in the Contents directory in the app to the path of the file to include (relative to the current working directory).
pub files: HashMap<PathBuf, PathBuf>,
/// The version of the build that identifies an iteration of the bundle.
pub bundle_version: Option<String>,
/// A version string indicating the minimum MacOS version that the bundled app supports (e.g. `"10.11"`).
/// If you are using this config field, you may also want have your `build.rs` script emit `cargo:rustc-env=MACOSX_DEPLOYMENT_TARGET=10.11`.
pub minimum_system_version: Option<String>,
@ -643,6 +652,8 @@ pub struct BundleSettings {
pub rpm: RpmSettings,
/// DMG-specific settings.
pub dmg: DmgSettings,
/// iOS-specific settings.
pub ios: IosSettings,
/// MacOS-specific settings.
pub macos: MacOsSettings,
/// Updater configuration.
@ -1190,6 +1201,11 @@ impl Settings {
&self.bundle_settings.dmg
}
/// Returns the iOS settings.
pub fn ios(&self) -> &IosSettings {
&self.bundle_settings.ios
}
/// Returns the MacOS settings.
pub fn macos(&self) -> &MacOsSettings {
&self.bundle_settings.macos

View File

@ -31,7 +31,7 @@
]
},
"version": {
"description": "App version. It is a semver version number or a path to a `package.json` file containing the `version` field. If removed the version number from `Cargo.toml` is used.\n\n By default version 1.0 is used on Android.",
"description": "App version. It is a semver version number or a path to a `package.json` file containing the `version` field.\n\n If removed the version number from `Cargo.toml` is used.\n It's recommended to manage the app versioning in the Tauri config.\n\n ## Platform-specific\n\n - **macOS**: Translates to the bundle's CFBundleShortVersionString property and is used as the default CFBundleVersion.\n You can set an specific bundle version using [`bundle > macOS > bundleVersion`](MacConfig::bundle_version).\n - **iOS**: Translates to the bundle's CFBundleShortVersionString property and is used as the default CFBundleVersion.\n You can set an specific bundle version using [`bundle > iOS > bundleVersion`](IosConfig::bundle_version).\n The `tauri ios build` CLI command has a `--build-number <number>` option that lets you append a build number to the app version.\n - **Android**: By default version 1.0 is used. You can set a version code using [`bundle > android > versionCode`](AndroidConfig::version_code).\n\n By default version 1.0 is used on Android.",
"type": [
"string",
"null"
@ -3297,6 +3297,13 @@
"type": "string"
}
},
"bundleVersion": {
"description": "The version of the build that identifies an iteration of the bundle.\n\n Translates to the bundle's CFBundleVersion property.",
"type": [
"string",
"null"
]
},
"minimumSystemVersion": {
"description": "A version string indicating the minimum macOS X version that the bundled application supports. Defaults to `10.13`.\n\n Setting it to `null` completely removes the `LSMinimumSystemVersion` field on the bundle's `Info.plist`\n and the `MACOSX_DEPLOYMENT_TARGET` environment variable.\n\n An empty string is considered an invalid value so the default value is used.",
"default": "10.13",
@ -3498,6 +3505,13 @@
"null"
]
},
"bundleVersion": {
"description": "The version of the build that identifies an iteration of the bundle.\n\n Translates to the bundle's CFBundleVersion property.",
"type": [
"string",
"null"
]
},
"minimumSystemVersion": {
"description": "A version string indicating the minimum iOS version that the bundled application supports. Defaults to `13.0`.\n\n Maps to the IPHONEOS_DEPLOYMENT_TARGET value.",
"default": "13.0",

View File

@ -22,7 +22,8 @@ use notify_debouncer_full::new_debouncer;
use serde::{Deserialize, Deserializer};
use tauri_bundler::{
AppCategory, AppImageSettings, BundleBinary, BundleSettings, DebianSettings, DmgSettings,
MacOsSettings, PackageSettings, Position, RpmSettings, Size, UpdaterSettings, WindowsSettings,
IosSettings, MacOsSettings, PackageSettings, Position, RpmSettings, Size, UpdaterSettings,
WindowsSettings,
};
use tauri_utils::config::{parse::is_configuration_file, DeepLinkProtocol, Updater};
@ -1016,24 +1017,26 @@ impl RustAppSettings {
.workspace
.and_then(|v| v.package);
let version = config.version.clone().unwrap_or_else(|| {
cargo_package_settings
.version
.clone()
.expect("Cargo manifest must have the `package.version` field")
.resolve("version", || {
ws_package_settings
.as_ref()
.and_then(|p| p.version.clone())
.ok_or_else(|| anyhow::anyhow!("Couldn't inherit value for `version` from workspace"))
})
.expect("Cargo project does not have a version")
});
let package_settings = PackageSettings {
product_name: config
.product_name
.clone()
.unwrap_or_else(|| cargo_package_settings.name.clone()),
version: config.version.clone().unwrap_or_else(|| {
cargo_package_settings
.version
.clone()
.expect("Cargo manifest must have the `package.version` field")
.resolve("version", || {
ws_package_settings
.as_ref()
.and_then(|p| p.version.clone())
.ok_or_else(|| anyhow::anyhow!("Couldn't inherit value for `version` from workspace"))
})
.expect("Cargo project does not have a version")
}),
version: version.clone(),
description: cargo_package_settings
.description
.clone()
@ -1418,9 +1421,13 @@ fn tauri_config_to_bundle_settings(
y: config.macos.dmg.application_folder_position.y,
},
},
ios: IosSettings {
bundle_version: config.ios.bundle_version,
},
macos: MacOsSettings {
frameworks: config.macos.frameworks,
files: config.macos.files,
bundle_version: config.macos.bundle_version,
minimum_system_version: config.macos.minimum_system_version,
exception_domain: config.macos.exception_domain,
signing_identity,

View File

@ -142,7 +142,7 @@ pub fn exec(
// Generate Xcode project
Target::Ios => {
let (config, metadata) =
super::ios::get_config(&app, tauri_config_, None, &Default::default());
super::ios::get_config(&app, tauri_config_, None, &Default::default())?;
map.insert("apple", &config);
super::ios::project::gen(
tauri_config_,

View File

@ -36,6 +36,7 @@ use std::{
env::{set_current_dir, var, var_os},
fs,
path::PathBuf,
str::FromStr,
};
#[derive(Debug, Clone, Parser)]
@ -166,7 +167,7 @@ pub fn command(options: Options, noise_level: NoiseLevel) -> Result<()> {
tauri_config_,
build_options.features.as_ref(),
&Default::default(),
);
)?;
(interface, config)
};
@ -182,9 +183,36 @@ pub fn command(options: Options, noise_level: NoiseLevel) -> Result<()> {
inject_resources(&config, tauri_config.lock().unwrap().as_ref().unwrap())?;
let mut plist = plist::Dictionary::new();
let version = interface.app_settings().get_package_settings().version;
plist.insert("CFBundleShortVersionString".into(), version.clone().into());
plist.insert("CFBundleVersion".into(), version.into());
{
let tauri_config_guard = tauri_config.lock().unwrap();
let tauri_config_ = tauri_config_guard.as_ref().unwrap();
let app_version = tauri_config_
.version
.clone()
.unwrap_or_else(|| interface.app_settings().get_package_settings().version);
let mut version = semver::Version::from_str(&app_version)
.with_context(|| format!("failed to parse {app_version:?} as a semver string"))?;
if !version.pre.is_empty() {
log::info!(
"CFBundleShortVersionString cannot have prerelease identifier; stripping {}",
version.pre.as_str()
);
version.pre = semver::Prerelease::EMPTY;
}
if !version.build.is_empty() {
log::info!(
"CFBundleShortVersionString cannot have build number; stripping {}",
version.build.as_str()
);
version.build = semver::BuildMetadata::EMPTY;
}
plist.insert(
"CFBundleShortVersionString".into(),
version.to_string().into(),
);
};
let info_plist_path = config
.project_dir()

View File

@ -168,7 +168,7 @@ fn run_command(options: Options, noise_level: NoiseLevel) -> Result<()> {
tauri_config_,
dev_options.features.as_ref(),
&Default::default(),
);
)?;
(interface, config)
};

View File

@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use anyhow::Context;
use cargo_mobile2::{
apple::{
config::{
@ -39,6 +40,7 @@ use std::{
env::{set_var, var_os},
fs::create_dir_all,
path::PathBuf,
str::FromStr,
thread::sleep,
time::Duration,
};
@ -112,7 +114,7 @@ pub fn get_config(
tauri_config: &TauriConfig,
features: Option<&Vec<String>>,
cli_options: &CliOptions,
) -> (AppleConfig, AppleMetadata) {
) -> Result<(AppleConfig, AppleMetadata)> {
let mut ios_options = cli_options.clone();
if let Some(features) = features {
ios_options
@ -121,6 +123,41 @@ pub fn get_config(
.extend_from_slice(features);
}
let bundle_version = if let Some(bundle_version) = tauri_config
.bundle
.ios
.bundle_version
.clone()
.or_else(|| tauri_config.version.clone())
{
let mut version = semver::Version::from_str(&bundle_version)
.with_context(|| format!("failed to parse {bundle_version:?} as a semver string"))?;
if !version.pre.is_empty() {
if let Some((_prerelease_tag, number)) = version.pre.as_str().to_string().split_once('.') {
version.pre = semver::Prerelease::EMPTY;
if version.build.is_empty() {
version.build = semver::BuildMetadata::new(number)
.with_context(|| format!("bundle version {number:?} prerelease is invalid"))?;
} else {
anyhow::bail!("bundle version {bundle_version:?} is invalid, it cannot have both prerelease and build metadata");
}
}
}
let maybe_build_number = if version.build.is_empty() {
"".to_string()
} else {
format!(".{}", version.build.as_str())
};
Some(format!(
"{}.{}.{}{}",
version.major, version.minor, version.patch, maybe_build_number
))
} else {
None
};
let raw = RawAppleConfig {
development_team: std::env::var(APPLE_DEVELOPMENT_TEAM_ENV_VAR_NAME)
.ok()
@ -140,12 +177,11 @@ pub fn get_config(
}
}),
ios_features: ios_options.features.clone(),
bundle_version: tauri_config.version.clone(),
bundle_version_short: tauri_config.version.clone(),
bundle_version,
ios_version: Some(tauri_config.bundle.ios.minimum_system_version.clone()),
..Default::default()
};
let config = AppleConfig::from_raw(app.clone(), Some(raw)).unwrap();
let config = AppleConfig::from_raw(app.clone(), Some(raw))?;
let tauri_dir = tauri_dir();
@ -194,7 +230,7 @@ pub fn get_config(
set_var("TAURI_IOS_PROJECT_PATH", config.project_dir());
set_var("TAURI_IOS_APP_NAME", config.app().name());
(config, metadata)
Ok((config, metadata))
}
fn connected_device_prompt<'a>(env: &'_ Env, target: Option<&str>) -> Result<Device<'a>> {

View File

@ -97,7 +97,7 @@ pub fn command(options: Options) -> Result<()> {
tauri_config_,
None,
&cli_options,
);
)?;
(config, metadata, cli_options)
};
ensure_init(

View File

@ -313,8 +313,15 @@ pub fn context_codegen(data: ContextData) -> EmbeddedAssetsResult<TokenStream> {
plist.insert("CFBundleName".into(), product_name.clone().into());
}
if let Some(version) = &config.version {
let bundle_version = &config.bundle.macos.bundle_version;
plist.insert("CFBundleShortVersionString".into(), version.clone().into());
plist.insert("CFBundleVersion".into(), version.clone().into());
plist.insert(
"CFBundleVersion".into(),
bundle_version
.clone()
.unwrap_or_else(|| version.clone())
.into(),
);
}
}

View File

@ -31,7 +31,7 @@
]
},
"version": {
"description": "App version. It is a semver version number or a path to a `package.json` file containing the `version` field. If removed the version number from `Cargo.toml` is used.\n\n By default version 1.0 is used on Android.",
"description": "App version. It is a semver version number or a path to a `package.json` file containing the `version` field.\n\n If removed the version number from `Cargo.toml` is used.\n It's recommended to manage the app versioning in the Tauri config.\n\n ## Platform-specific\n\n - **macOS**: Translates to the bundle's CFBundleShortVersionString property and is used as the default CFBundleVersion.\n You can set an specific bundle version using [`bundle > macOS > bundleVersion`](MacConfig::bundle_version).\n - **iOS**: Translates to the bundle's CFBundleShortVersionString property and is used as the default CFBundleVersion.\n You can set an specific bundle version using [`bundle > iOS > bundleVersion`](IosConfig::bundle_version).\n The `tauri ios build` CLI command has a `--build-number <number>` option that lets you append a build number to the app version.\n - **Android**: By default version 1.0 is used. You can set a version code using [`bundle > android > versionCode`](AndroidConfig::version_code).\n\n By default version 1.0 is used on Android.",
"type": [
"string",
"null"
@ -3297,6 +3297,13 @@
"type": "string"
}
},
"bundleVersion": {
"description": "The version of the build that identifies an iteration of the bundle.\n\n Translates to the bundle's CFBundleVersion property.",
"type": [
"string",
"null"
]
},
"minimumSystemVersion": {
"description": "A version string indicating the minimum macOS X version that the bundled application supports. Defaults to `10.13`.\n\n Setting it to `null` completely removes the `LSMinimumSystemVersion` field on the bundle's `Info.plist`\n and the `MACOSX_DEPLOYMENT_TARGET` environment variable.\n\n An empty string is considered an invalid value so the default value is used.",
"default": "10.13",
@ -3498,6 +3505,13 @@
"null"
]
},
"bundleVersion": {
"description": "The version of the build that identifies an iteration of the bundle.\n\n Translates to the bundle's CFBundleVersion property.",
"type": [
"string",
"null"
]
},
"minimumSystemVersion": {
"description": "A version string indicating the minimum iOS version that the bundled application supports. Defaults to `13.0`.\n\n Maps to the IPHONEOS_DEPLOYMENT_TARGET value.",
"default": "13.0",

View File

@ -612,6 +612,11 @@ pub struct MacConfig {
/// The files to include in the application relative to the Contents directory.
#[serde(default)]
pub files: HashMap<PathBuf, PathBuf>,
/// The version of the build that identifies an iteration of the bundle.
///
/// Translates to the bundle's CFBundleVersion property.
#[serde(alias = "bundle-version")]
pub bundle_version: Option<String>,
/// A version string indicating the minimum macOS X version that the bundled application supports. Defaults to `10.13`.
///
/// Setting it to `null` completely removes the `LSMinimumSystemVersion` field on the bundle's `Info.plist`
@ -651,6 +656,7 @@ impl Default for MacConfig {
Self {
frameworks: None,
files: HashMap::new(),
bundle_version: None,
minimum_system_version: macos_minimum_system_version(),
exception_domain: None,
signing_identity: None,
@ -2527,6 +2533,11 @@ pub struct IosConfig {
/// The `APPLE_DEVELOPMENT_TEAM` environment variable can be set to overwrite it.
#[serde(alias = "development-team")]
pub development_team: Option<String>,
/// The version of the build that identifies an iteration of the bundle.
///
/// Translates to the bundle's CFBundleVersion property.
#[serde(alias = "bundle-version")]
pub bundle_version: Option<String>,
/// A version string indicating the minimum iOS version that the bundled application supports. Defaults to `13.0`.
///
/// Maps to the IPHONEOS_DEPLOYMENT_TARGET value.
@ -2543,6 +2554,7 @@ impl Default for IosConfig {
template: None,
frameworks: None,
development_team: None,
bundle_version: None,
minimum_system_version: ios_minimum_system_version(),
}
}
@ -2846,7 +2858,19 @@ pub struct Config {
/// App main binary filename. Defaults to the name of your cargo crate.
#[serde(alias = "main-binary-name")]
pub main_binary_name: Option<String>,
/// App version. It is a semver version number or a path to a `package.json` file containing the `version` field. If removed the version number from `Cargo.toml` is used.
/// App version. It is a semver version number or a path to a `package.json` file containing the `version` field.
///
/// If removed the version number from `Cargo.toml` is used.
/// It's recommended to manage the app versioning in the Tauri config.
///
/// ## Platform-specific
///
/// - **macOS**: Translates to the bundle's CFBundleShortVersionString property and is used as the default CFBundleVersion.
/// You can set an specific bundle version using [`bundle > macOS > bundleVersion`](MacConfig::bundle_version).
/// - **iOS**: Translates to the bundle's CFBundleShortVersionString property and is used as the default CFBundleVersion.
/// You can set an specific bundle version using [`bundle > iOS > bundleVersion`](IosConfig::bundle_version).
/// The `tauri ios build` CLI command has a `--build-number <number>` option that lets you append a build number to the app version.
/// - **Android**: By default version 1.0 is used. You can set a version code using [`bundle > android > versionCode`](AndroidConfig::version_code).
///
/// By default version 1.0 is used on Android.
#[serde(deserialize_with = "version_deserializer", default)]