From de548532da5918f274e8e56e3d484ea97fce1390 Mon Sep 17 00:00:00 2001 From: Pierre Hugo Date: Fri, 5 Dec 2025 17:21:37 +0200 Subject: [PATCH 1/6] Add macOS PKG installer support to tauri-bundler Implements support for creating macOS PKG installers using pkgbuild and productbuild. Creates component packages from .app bundles and combines them into distribution packages using a user-provided distribution.xml file from the project root. Changes: - Add PackageType::Pkg enum variant and register it for macOS - Create bundle/macos/pkg module implementing two-level PKG structure - Wire PKG bundler into main bundle dispatcher - Require distribution.xml in project root for PKG customization --- crates/tauri-bundler/src/bundle.rs | 19 +++ crates/tauri-bundler/src/bundle/macos/mod.rs | 1 + .../tauri-bundler/src/bundle/macos/pkg/mod.rs | 117 ++++++++++++++++++ crates/tauri-bundler/src/bundle/settings.rs | 11 +- crates/tauri-cli/src/bundle.rs | 8 ++ 5 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 crates/tauri-bundler/src/bundle/macos/pkg/mod.rs diff --git a/crates/tauri-bundler/src/bundle.rs b/crates/tauri-bundler/src/bundle.rs index 654ad5647401..6eca9573f531 100644 --- a/crates/tauri-bundler/src/bundle.rs +++ b/crates/tauri-bundler/src/bundle.rs @@ -70,11 +70,15 @@ pub struct Bundle { /// Returns the list of paths where the bundles can be found. pub fn bundle_project(settings: &Settings) -> crate::Result> { let mut package_types = settings.package_types()?; + log::info!("Initial package types: {:?}", package_types); + if package_types.is_empty() { + log::warn!("Package types is empty, returning early"); return Ok(Vec::new()); } package_types.sort_by_key(|a| a.priority()); + log::info!("Sorted package types: {:?}", package_types); let target_os = settings.target_platform(); @@ -110,8 +114,11 @@ pub fn bundle_project(settings: &Settings) -> crate::Result> { let mut main_binary_signed = false; let mut bundles = Vec::::new(); for package_type in &package_types { + log::info!("Processing package type: {:?}", package_type); + // bundle was already built! e.g. DMG already built .app if bundles.iter().any(|b| b.package_type == *package_type) { + log::info!("Skipping {:?}, already built", package_type); continue; } @@ -150,6 +157,18 @@ pub fn bundle_project(settings: &Settings) -> crate::Result> { } bundled.dmg } + // pkg is dependent of MacOsBundle, we send our bundles to prevent rebuilding + #[cfg(target_os = "macos")] + PackageType::Pkg => { + let bundled = macos::pkg::bundle_project(settings, &bundles)?; + if !bundled.app.is_empty() { + bundles.push(Bundle { + package_type: PackageType::MacOsBundle, + bundle_paths: bundled.app, + }); + } + bundled.pkg + } #[cfg(target_os = "windows")] PackageType::WindowsMsi => windows::msi::bundle_project(settings, false)?, diff --git a/crates/tauri-bundler/src/bundle/macos/mod.rs b/crates/tauri-bundler/src/bundle/macos/mod.rs index 26b998d734bc..2629224f8b08 100644 --- a/crates/tauri-bundler/src/bundle/macos/mod.rs +++ b/crates/tauri-bundler/src/bundle/macos/mod.rs @@ -7,4 +7,5 @@ pub mod app; pub mod dmg; pub mod icon; pub mod ios; +pub mod pkg; pub mod sign; diff --git a/crates/tauri-bundler/src/bundle/macos/pkg/mod.rs b/crates/tauri-bundler/src/bundle/macos/pkg/mod.rs new file mode 100644 index 000000000000..205f69650fc0 --- /dev/null +++ b/crates/tauri-bundler/src/bundle/macos/pkg/mod.rs @@ -0,0 +1,117 @@ +// Copyright 2016-2019 Cargo-Bundle developers +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use super::app; +use crate::{ + bundle::{settings::Arch, Bundle}, + utils::CommandExt, + PackageType, Settings, +}; + +use std::{ + fs, + path::PathBuf, + process::Command, +}; + +pub struct Bundled { + pub pkg: Vec, + pub app: Vec, +} + +/// Bundles the project into a macOS PKG installer. +/// Returns a vector of PathBuf that shows where the PKG was created. +pub fn bundle_project(settings: &Settings, bundles: &[Bundle]) -> crate::Result { + // generate the .app bundle if needed + let app_bundle_paths = if !bundles + .iter() + .any(|bundle| bundle.package_type == PackageType::MacOsBundle) + { + app::bundle_project(settings)? + } else { + Vec::new() + }; + + // get the target path + let output_path = settings.project_out_directory().join("bundle/macos"); + let pkg_output_path = output_path.parent().unwrap().join("pkg"); + + fs::create_dir_all(&pkg_output_path)?; + + let package_base_name = format!( + "{}_{}_{}", + settings.product_name(), + settings.version_string(), + match settings.binary_arch() { + Arch::X86_64 => "x64", + Arch::AArch64 => "aarch64", + Arch::Universal => "universal", + target => { + return Err(crate::Error::ArchError(format!( + "Unsupported architecture: {target:?}" + ))); + } + } + ); + + let pkg_name = format!("{}.pkg", &package_base_name); + let pkg_path = pkg_output_path.join(&pkg_name); + + let product_name = settings.product_name(); + let bundle_file_name = format!("{product_name}.app"); + let app_bundle_path = output_path.join(&bundle_file_name); + + log::info!(action = "Bundling"; "{} ({})", pkg_name, pkg_path.display()); + + // Step 1: Create a component package using pkgbuild + // This packages the .app bundle into a component package + let component_pkg_path = pkg_output_path.join("component.pkg"); + + let mut pkgbuild_cmd = Command::new("pkgbuild"); + pkgbuild_cmd + .arg("--component") + .arg(&app_bundle_path) + .arg("--install-location") + .arg("/Applications") + .arg(&component_pkg_path); + + log::info!(action = "Running"; "pkgbuild (component package)"); + pkgbuild_cmd + .output_ok() + .map_err(|e| crate::Error::ShellScriptError(format!("pkgbuild failed: {}", e)))?; + + // Step 2: Read distribution.xml from project root + // User must provide this file for PKG bundling + let distribution_xml_path = std::env::current_dir()?.join("distribution.xml"); + if !distribution_xml_path.exists() { + return Err(crate::Error::GenericError( + "distribution.xml not found in project root. PKG bundling requires a distribution.xml file.".to_string() + )); + } + + log::info!(action = "Using"; "distribution.xml from {}", distribution_xml_path.display()); + + // Step 3: Create the distribution package using productbuild + // This combines the component package(s) into a final installer + let mut productbuild_cmd = Command::new("productbuild"); + productbuild_cmd + .arg("--distribution") + .arg(&distribution_xml_path) + .arg("--package-path") + .arg(&pkg_output_path) + .arg(&pkg_path); + + log::info!(action = "Running"; "productbuild (distribution package)"); + productbuild_cmd + .output_ok() + .map_err(|e| crate::Error::ShellScriptError(format!("productbuild failed: {}", e)))?; + + log::info!(action = "Finished"; "PKG installer at {}", pkg_path.display()); + + Ok(Bundled { + pkg: vec![pkg_path], + app: app_bundle_paths, + }) +} diff --git a/crates/tauri-bundler/src/bundle/settings.rs b/crates/tauri-bundler/src/bundle/settings.rs index 62f7813d2cdd..df34f738b0e2 100644 --- a/crates/tauri-bundler/src/bundle/settings.rs +++ b/crates/tauri-bundler/src/bundle/settings.rs @@ -40,6 +40,8 @@ pub enum PackageType { AppImage, /// The macOS DMG bundle (.dmg). Dmg, + /// The macOS PKG installer (.pkg). + Pkg, /// The Updater bundle. Updater, } @@ -60,7 +62,7 @@ impl From for PackageType { impl PackageType { /// Maps a short name to a PackageType. - /// Possible values are "deb", "ios", "msi", "app", "rpm", "appimage", "dmg", "updater". + /// Possible values are "deb", "ios", "msi", "app", "rpm", "appimage", "dmg", "pkg", "updater". pub fn from_short_name(name: &str) -> Option { // Other types we may eventually want to support: apk. match name { @@ -72,6 +74,7 @@ impl PackageType { "rpm" => Some(PackageType::Rpm), "appimage" => Some(PackageType::AppImage), "dmg" => Some(PackageType::Dmg), + "pkg" => Some(PackageType::Pkg), "updater" => Some(PackageType::Updater), _ => None, } @@ -89,6 +92,7 @@ impl PackageType { PackageType::Rpm => "rpm", PackageType::AppImage => "appimage", PackageType::Dmg => "dmg", + PackageType::Pkg => "pkg", PackageType::Updater => "updater", } } @@ -114,6 +118,7 @@ impl PackageType { PackageType::Rpm => 0, PackageType::AppImage => 0, PackageType::Dmg => 1, + PackageType::Pkg => 1, PackageType::Updater => 2, } } @@ -134,6 +139,8 @@ const ALL_PACKAGE_TYPES: &[PackageType] = &[ PackageType::Rpm, #[cfg(target_os = "macos")] PackageType::Dmg, + #[cfg(target_os = "macos")] + PackageType::Pkg, #[cfg(target_os = "linux")] PackageType::AppImage, PackageType::Updater, @@ -1046,7 +1053,7 @@ impl Settings { let target_os = self.target_platform(); let platform_types = match target_os { - TargetPlatform::MacOS => vec![PackageType::MacOsBundle, PackageType::Dmg], + TargetPlatform::MacOS => vec![PackageType::MacOsBundle, PackageType::Dmg, PackageType::Pkg], TargetPlatform::Ios => vec![PackageType::IosBundle], TargetPlatform::Linux => vec![PackageType::Deb, PackageType::Rpm, PackageType::AppImage], TargetPlatform::Windows => vec![PackageType::WindowsMsi, PackageType::Nsis], diff --git a/crates/tauri-cli/src/bundle.rs b/crates/tauri-cli/src/bundle.rs index 890386cd71fc..95a0671bde74 100644 --- a/crates/tauri-cli/src/bundle.rs +++ b/crates/tauri-cli/src/bundle.rs @@ -186,13 +186,17 @@ pub fn bundle( .collect() }; + log::debug!("Package types to bundle: {:?}", package_types); + if package_types.is_empty() { + log::warn!("No package types specified, exiting bundle command"); return Ok(()); } // if we have a package to bundle, let's run the `before_bundle_command`. if !package_types.is_empty() { if let Some(before_bundle) = config.build.before_bundle_command.clone() { + log::debug!("Running beforeBundleCommand"); helpers::run_hook( "beforeBundleCommand", before_bundle, @@ -202,6 +206,7 @@ pub fn bundle( } } + log::debug!("Getting bundler settings"); let mut settings = app_settings .get_bundler_settings(options.clone().into(), config, out_dir, package_types) .with_context(|| "failed to build bundler settings")?; @@ -213,8 +218,11 @@ pub fn bundle( _ => log::Level::Trace, }); + log::debug!("Calling tauri_bundler::bundle_project"); let bundles = tauri_bundler::bundle_project(&settings).map_err(Box::new)?; + log::debug!("Bundle project completed, bundles: {:?}", bundles); + sign_updaters(settings, bundles, ci)?; Ok(()) From a166f61d8d388907fa16a1cb51c9a00fe659c9c7 Mon Sep 17 00:00:00 2001 From: Pierre Hugo Date: Fri, 5 Dec 2025 17:21:37 +0200 Subject: [PATCH 2/6] Add native macOS PKG signing support Implements PKG installer signing using productsign. Signs the final distribution package with the identity specified in macos.signingIdentity configuration or APPLE_CERTIFICATE environment variable. Changes: - Add sign_pkg() function to macos/sign.rs using productsign - Call sign_pkg() after productbuild in PKG bundler - Respects --no-sign flag and signing identity configuration --- .../tauri-bundler/src/bundle/macos/pkg/mod.rs | 8 +++++ crates/tauri-bundler/src/bundle/macos/sign.rs | 34 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/crates/tauri-bundler/src/bundle/macos/pkg/mod.rs b/crates/tauri-bundler/src/bundle/macos/pkg/mod.rs index 205f69650fc0..0a0ee231fa8d 100644 --- a/crates/tauri-bundler/src/bundle/macos/pkg/mod.rs +++ b/crates/tauri-bundler/src/bundle/macos/pkg/mod.rs @@ -108,6 +108,14 @@ pub fn bundle_project(settings: &Settings, bundles: &[Bundle]) -> crate::Result< .output_ok() .map_err(|e| crate::Error::ShellScriptError(format!("productbuild failed: {}", e)))?; + // Sign PKG if needed + let identity = settings.macos().signing_identity.as_deref(); + if !settings.no_sign() && identity != Some("-") { + if let Some(identity) = identity { + super::sign::sign_pkg(&pkg_path, identity, settings)?; + } + } + log::info!(action = "Finished"; "PKG installer at {}", pkg_path.display()); Ok(Bundled { diff --git a/crates/tauri-bundler/src/bundle/macos/sign.rs b/crates/tauri-bundler/src/bundle/macos/sign.rs index 8d8bf6c2b5ea..d14c81daff7b 100644 --- a/crates/tauri-bundler/src/bundle/macos/sign.rs +++ b/crates/tauri-bundler/src/bundle/macos/sign.rs @@ -167,3 +167,37 @@ fn find_api_key(folder: PathBuf, file_name: &OsString) -> Option { None } } + +/// Sign a PKG installer using productsign +pub fn sign_pkg( + pkg_path: &std::path::Path, + identity: &str, + settings: &Settings, +) -> crate::Result<()> { + use std::process::Command; + use crate::utils::CommandExt; + + log::info!(action = "Signing"; "PKG with identity \"{}\"", identity); + + // Create a temporary path for the signed package + let signed_pkg_path = pkg_path.with_extension("signed.pkg"); + + // Run productsign to sign the package + let mut cmd = Command::new("productsign"); + cmd + .arg("--sign") + .arg(identity) + .arg(pkg_path) + .arg(&signed_pkg_path); + + cmd.output_ok().map_err(|e| { + crate::Error::GenericError(format!("Failed to sign PKG with productsign: {}", e)) + })?; + + // Replace the unsigned package with the signed one + std::fs::rename(&signed_pkg_path, pkg_path)?; + + log::info!(action = "Signed"; "PKG at {}", pkg_path.display()); + + Ok(()) +} From 35a4fa4f5b9940d8c9b07deba146312b2e002b7d Mon Sep 17 00:00:00 2001 From: Pierre Hugo Date: Fri, 5 Dec 2025 17:21:37 +0200 Subject: [PATCH 3/6] Add custom signing command support for macOS bundles Adds support for custom signing commands to allow users to integrate external signing tools (like HSM-based solutions) for .app bundles, .pkg installers, and .dmg disk images. Custom commands are checked before native signing, allowing users to completely override the signing process. The %1 placeholder is replaced with the path to the file being signed. Configuration fields added to MacOsSettings: - app_sign_command: Custom command for signing .app bundles - pkg_sign_command: Custom command for signing .pkg installers - dmg_sign_command: Custom command for signing .dmg disk images Changes: - Add custom sign command fields to MacOsSettings - Implement sign_app_custom, sign_pkg_custom, sign_dmg_custom functions - Update app.rs, pkg/mod.rs, and dmg/mod.rs to check for custom commands - Reuse Windows-style %1 placeholder substitution pattern --- crates/tauri-bundler/src/bundle/macos/app.rs | 5 ++ .../tauri-bundler/src/bundle/macos/dmg/mod.rs | 30 ++++--- .../tauri-bundler/src/bundle/macos/pkg/mod.rs | 16 +++- crates/tauri-bundler/src/bundle/macos/sign.rs | 81 +++++++++++++++++++ crates/tauri-bundler/src/bundle/settings.rs | 32 ++++++++ 5 files changed, 149 insertions(+), 15 deletions(-) diff --git a/crates/tauri-bundler/src/bundle/macos/app.rs b/crates/tauri-bundler/src/bundle/macos/app.rs index 703973c2b75a..818c68e2ec9d 100644 --- a/crates/tauri-bundler/src/bundle/macos/app.rs +++ b/crates/tauri-bundler/src/bundle/macos/app.rs @@ -106,9 +106,14 @@ pub fn bundle_project(settings: &Settings) -> crate::Result> { if settings.no_sign() { log::warn!("Skipping signing due to --no-sign flag.",); + } else if let Some(app_sign_command) = &settings.macos().app_sign_command { + // Use custom signing command for the .app bundle + // The custom command is responsible for deep signing the contents of the .app + super::sign::sign_app_custom(&app_bundle_path, app_sign_command)?; } else if let Some(keychain) = super::sign::keychain(settings.macos().signing_identity.as_deref())? { + // Use native codesign // Sign frameworks and sidecar binaries first, per apple, signing must be done inside out // https://developer.apple.com/forums/thread/701514 sign_paths.push(SignTarget { diff --git a/crates/tauri-bundler/src/bundle/macos/dmg/mod.rs b/crates/tauri-bundler/src/bundle/macos/dmg/mod.rs index 2c756faeef8a..fb0a6b757d23 100644 --- a/crates/tauri-bundler/src/bundle/macos/dmg/mod.rs +++ b/crates/tauri-bundler/src/bundle/macos/dmg/mod.rs @@ -192,17 +192,25 @@ pub fn bundle_project(settings: &Settings, bundles: &[Bundle]) -> crate::Result< // Sign DMG if needed // skipping self-signing DMGs https://github.com/tauri-apps/tauri/issues/12288 - let identity = settings.macos().signing_identity.as_deref(); - if !settings.no_sign() && identity != Some("-") { - if let Some(keychain) = super::sign::keychain(identity)? { - super::sign::sign( - &keychain, - vec![super::sign::SignTarget { - path: dmg_path.clone(), - is_an_executable: false, - }], - settings, - )?; + if !settings.no_sign() { + if let Some(dmg_sign_command) = &settings.macos().dmg_sign_command { + // Use custom signing command + super::sign::sign_dmg_custom(&dmg_path, dmg_sign_command)?; + } else { + // Use native codesign + let identity = settings.macos().signing_identity.as_deref(); + if identity != Some("-") { + if let Some(keychain) = super::sign::keychain(identity)? { + super::sign::sign( + &keychain, + vec![super::sign::SignTarget { + path: dmg_path.clone(), + is_an_executable: false, + }], + settings, + )?; + } + } } } diff --git a/crates/tauri-bundler/src/bundle/macos/pkg/mod.rs b/crates/tauri-bundler/src/bundle/macos/pkg/mod.rs index 0a0ee231fa8d..d92ec502a806 100644 --- a/crates/tauri-bundler/src/bundle/macos/pkg/mod.rs +++ b/crates/tauri-bundler/src/bundle/macos/pkg/mod.rs @@ -109,10 +109,18 @@ pub fn bundle_project(settings: &Settings, bundles: &[Bundle]) -> crate::Result< .map_err(|e| crate::Error::ShellScriptError(format!("productbuild failed: {}", e)))?; // Sign PKG if needed - let identity = settings.macos().signing_identity.as_deref(); - if !settings.no_sign() && identity != Some("-") { - if let Some(identity) = identity { - super::sign::sign_pkg(&pkg_path, identity, settings)?; + if !settings.no_sign() { + if let Some(pkg_sign_command) = &settings.macos().pkg_sign_command { + // Use custom signing command + super::sign::sign_pkg_custom(&pkg_path, pkg_sign_command)?; + } else { + // Use native productsign + let identity = settings.macos().signing_identity.as_deref(); + if identity != Some("-") { + if let Some(identity) = identity { + super::sign::sign_pkg(&pkg_path, identity, settings)?; + } + } } } diff --git a/crates/tauri-bundler/src/bundle/macos/sign.rs b/crates/tauri-bundler/src/bundle/macos/sign.rs index d14c81daff7b..81e3df2a6dbe 100644 --- a/crates/tauri-bundler/src/bundle/macos/sign.rs +++ b/crates/tauri-bundler/src/bundle/macos/sign.rs @@ -201,3 +201,84 @@ pub fn sign_pkg( Ok(()) } + +/// Sign a PKG installer using a custom command +pub fn sign_pkg_custom( + pkg_path: &std::path::Path, + command: &crate::bundle::settings::CustomSignCommandSettings, +) -> crate::Result<()> { + use crate::utils::CommandExt; + + log::info!(action = "Signing"; "PKG with custom command"); + + let mut cmd = sign_command_custom(pkg_path, command)?; + let output = cmd.output_ok()?; + + let stdout = String::from_utf8_lossy(output.stdout.as_slice()).into_owned(); + log::info!(action = "Signing"; "Output of signing command:\n{}", stdout.trim()); + + Ok(()) +} + +/// Sign a DMG disk image using a custom command +pub fn sign_dmg_custom( + dmg_path: &std::path::Path, + command: &crate::bundle::settings::CustomSignCommandSettings, +) -> crate::Result<()> { + use crate::utils::CommandExt; + + log::info!(action = "Signing"; "DMG with custom command"); + + let mut cmd = sign_command_custom(dmg_path, command)?; + let output = cmd.output_ok()?; + + let stdout = String::from_utf8_lossy(output.stdout.as_slice()).into_owned(); + log::info!(action = "Signing"; "Output of signing command:\n{}", stdout.trim()); + + Ok(()) +} + +/// Sign an app bundle using a custom command +pub fn sign_app_custom( + app_path: &std::path::Path, + command: &crate::bundle::settings::CustomSignCommandSettings, +) -> crate::Result<()> { + use crate::utils::CommandExt; + + log::info!(action = "Signing"; ".app bundle with custom command"); + + let mut cmd = sign_command_custom(app_path, command)?; + let output = cmd.output_ok()?; + + let stdout = String::from_utf8_lossy(output.stdout.as_slice()).into_owned(); + log::info!(action = "Signing"; "Output of signing command:\n{}", stdout.trim()); + + Ok(()) +} + +/// Build a custom signing command with %1 placeholder substitution +fn sign_command_custom>( + path: P, + command: &crate::bundle::settings::CustomSignCommandSettings, +) -> crate::Result { + use std::path::Path; + + let path = path.as_ref(); + let cwd = std::env::current_dir()?; + + let mut cmd = std::process::Command::new(&command.cmd); + for arg in &command.args { + if arg == "%1" { + cmd.arg(path); + } else { + let arg_path = Path::new(arg); + // turn relative paths into absolute paths + if arg_path.exists() && arg_path.is_relative() { + cmd.arg(cwd.join(arg_path)); + } else { + cmd.arg(arg); + } + } + } + Ok(cmd) +} diff --git a/crates/tauri-bundler/src/bundle/settings.rs b/crates/tauri-bundler/src/bundle/settings.rs index df34f738b0e2..b61bc6e5a707 100644 --- a/crates/tauri-bundler/src/bundle/settings.rs +++ b/crates/tauri-bundler/src/bundle/settings.rs @@ -371,6 +371,38 @@ pub struct MacOsSettings { pub entitlements: Option, /// Path to the Info.plist file or raw plist value to merge with the bundle Info.plist. pub info_plist: Option, + /// Specify a custom command to sign the .app bundle. + /// This command needs to have a `%1` in it which is just a placeholder for the .app bundle path. + /// The custom command is responsible for signing everything inside the bundle. + /// + /// Example: + /// ```text + /// custom-sign --app %1 --output-dir signed/ + /// ``` + /// + /// If this is set, it will be used instead of the native codesign process. + /// By Default we use `codesign` which can be found only on macOS. + pub app_sign_command: Option, + /// Specify a custom command to sign the .pkg installer. + /// This command needs to have a `%1` in it which is just a placeholder for the PKG path. + /// + /// Example: + /// ```text + /// custom-sign --pkg %1 --output-dir signed/ + /// ``` + /// + /// By Default we use `productsign` which can be found only on macOS. + pub pkg_sign_command: Option, + /// Specify a custom command to sign the .dmg disk image. + /// This command needs to have a `%1` in it which is just a placeholder for the DMG path. + /// + /// Example: + /// ```text + /// custom-sign --dmg %1 --output-dir signed/ + /// ``` + /// + /// By Default we use `codesign` which can be found only on macOS. + pub dmg_sign_command: Option, } /// Entitlements for macOS code signing. From 36aa05523d58c57669ce1fad8d87940b57e0942b Mon Sep 17 00:00:00 2001 From: Pierre Hugo Date: Fri, 5 Dec 2025 17:21:37 +0200 Subject: [PATCH 4/6] Add custom signing command support for macOS bundles Enables developers to specify custom signing commands for .app bundles, .pkg installers, and .dmg disk images on macOS. This is useful for organizations that need to use proprietary signing infrastructure instead of the native codesign/productsign tools. Configuration example in tauri.conf.json: ``` { "bundle": { "macOS": { "appSignCommand": { "cmd": "./shims/sign_app.sh", "args": ["%1"] }, "pkgSignCommand": { "cmd": "./shims/sign_pkg.sh", "args": ["%1"] } } } } ``` The %1 placeholder in args is replaced with the path to the artifact being signed. --- crates/tauri-cli/config.schema.json | 33 +++++++++++++++++++ crates/tauri-cli/src/interface/rust.rs | 3 ++ .../schemas/config.schema.json | 33 +++++++++++++++++++ crates/tauri-utils/src/config.rs | 22 +++++++++++++ examples/api/src-tauri/tauri.conf.json | 10 ++++++ 5 files changed, 101 insertions(+) diff --git a/crates/tauri-cli/config.schema.json b/crates/tauri-cli/config.schema.json index 1d19c20eaede..c9734e758ca8 100644 --- a/crates/tauri-cli/config.schema.json +++ b/crates/tauri-cli/config.schema.json @@ -3626,6 +3626,39 @@ "null" ] }, + "appSignCommand": { + "description": "Specify a custom command to sign the .app bundle.\n This command needs to have a `%1` in args which is just a placeholder for the .app bundle path.\n\n The custom command is responsible for deep signing the contents of the .app.\n If this is set, it will be used instead of the native codesign process.", + "anyOf": [ + { + "$ref": "#/definitions/CustomSignCommandConfig" + }, + { + "type": "null" + } + ] + }, + "pkgSignCommand": { + "description": "Specify a custom command to sign the .pkg installer.\n This command needs to have a `%1` in args which is just a placeholder for the .pkg path.\n\n By Default we use `productsign` which can be found only on macOS.", + "anyOf": [ + { + "$ref": "#/definitions/CustomSignCommandConfig" + }, + { + "type": "null" + } + ] + }, + "dmgSignCommand": { + "description": "Specify a custom command to sign the .dmg disk image.\n This command needs to have a `%1` in args which is just a placeholder for the .dmg path.\n\n By Default we use `codesign` which can be found only on macOS.", + "anyOf": [ + { + "$ref": "#/definitions/CustomSignCommandConfig" + }, + { + "type": "null" + } + ] + }, "dmg": { "description": "DMG-specific settings.", "default": { diff --git a/crates/tauri-cli/src/interface/rust.rs b/crates/tauri-cli/src/interface/rust.rs index 0f7d2fd02973..2671fa4f6516 100644 --- a/crates/tauri-cli/src/interface/rust.rs +++ b/crates/tauri-cli/src/interface/rust.rs @@ -1582,6 +1582,9 @@ fn tauri_config_to_bundle_settings( crate::helpers::plist::merge_plist(src_plists)?, )) }, + app_sign_command: config.macos.app_sign_command.map(custom_sign_settings), + pkg_sign_command: config.macos.pkg_sign_command.map(custom_sign_settings), + dmg_sign_command: config.macos.dmg_sign_command.map(custom_sign_settings), }, windows: WindowsSettings { timestamp_url: config.windows.timestamp_url, diff --git a/crates/tauri-schema-generator/schemas/config.schema.json b/crates/tauri-schema-generator/schemas/config.schema.json index 1d19c20eaede..c9734e758ca8 100644 --- a/crates/tauri-schema-generator/schemas/config.schema.json +++ b/crates/tauri-schema-generator/schemas/config.schema.json @@ -3626,6 +3626,39 @@ "null" ] }, + "appSignCommand": { + "description": "Specify a custom command to sign the .app bundle.\n This command needs to have a `%1` in args which is just a placeholder for the .app bundle path.\n\n The custom command is responsible for deep signing the contents of the .app.\n If this is set, it will be used instead of the native codesign process.", + "anyOf": [ + { + "$ref": "#/definitions/CustomSignCommandConfig" + }, + { + "type": "null" + } + ] + }, + "pkgSignCommand": { + "description": "Specify a custom command to sign the .pkg installer.\n This command needs to have a `%1` in args which is just a placeholder for the .pkg path.\n\n By Default we use `productsign` which can be found only on macOS.", + "anyOf": [ + { + "$ref": "#/definitions/CustomSignCommandConfig" + }, + { + "type": "null" + } + ] + }, + "dmgSignCommand": { + "description": "Specify a custom command to sign the .dmg disk image.\n This command needs to have a `%1` in args which is just a placeholder for the .dmg path.\n\n By Default we use `codesign` which can be found only on macOS.", + "anyOf": [ + { + "$ref": "#/definitions/CustomSignCommandConfig" + }, + { + "type": "null" + } + ] + }, "dmg": { "description": "DMG-specific settings.", "default": { diff --git a/crates/tauri-utils/src/config.rs b/crates/tauri-utils/src/config.rs index 37c77abff8ac..83a6b7048597 100644 --- a/crates/tauri-utils/src/config.rs +++ b/crates/tauri-utils/src/config.rs @@ -664,6 +664,25 @@ pub struct MacConfig { /// Note that Tauri also looks for a `Info.plist` file in the same directory as the Tauri configuration file. #[serde(alias = "info-plist")] pub info_plist: Option, + /// Specify a custom command to sign the .app bundle. + /// This command needs to have a `%1` in args which is just a placeholder for the .app bundle path. + /// + /// The custom command is responsible for deep signing the contents of the .app. + /// If this is set, it will be used instead of the native codesign process. + #[serde(alias = "app-sign-command")] + pub app_sign_command: Option, + /// Specify a custom command to sign the .pkg installer. + /// This command needs to have a `%1` in args which is just a placeholder for the .pkg path. + /// + /// By Default we use `productsign` which can be found only on macOS. + #[serde(alias = "pkg-sign-command")] + pub pkg_sign_command: Option, + /// Specify a custom command to sign the .dmg disk image. + /// This command needs to have a `%1` in args which is just a placeholder for the .dmg path. + /// + /// By Default we use `codesign` which can be found only on macOS. + #[serde(alias = "dmg-sign-command")] + pub dmg_sign_command: Option, /// DMG-specific settings. #[serde(default)] pub dmg: DmgConfig, @@ -683,6 +702,9 @@ impl Default for MacConfig { provider_short_name: None, entitlements: None, info_plist: None, + app_sign_command: None, + pkg_sign_command: None, + dmg_sign_command: None, dmg: Default::default(), } } diff --git a/examples/api/src-tauri/tauri.conf.json b/examples/api/src-tauri/tauri.conf.json index 936e73af4acb..c84fc71f6526 100644 --- a/examples/api/src-tauri/tauri.conf.json +++ b/examples/api/src-tauri/tauri.conf.json @@ -83,6 +83,16 @@ "../../.icons/icon.icns", "../../.icons/icon.ico" ], + "macOS": { + "appSignCommand": { + "cmd": "./shims/sign_app_mock.sh", + "args": ["%1"] + }, + "pkgSignCommand": { + "cmd": "./shims/sign_pkg_mock.sh", + "args": ["%1"] + } + }, "windows": { "wix": { "language": { From e3c0cbd4873d6e0d66691c9befb83e5e06df1e15 Mon Sep 17 00:00:00 2001 From: Pierre Hugo Date: Fri, 5 Dec 2025 17:21:38 +0200 Subject: [PATCH 5/6] Run custom commands in directory tauri build was run Run custom commands in the same directory as the tauri build command was run, allowing developers to use relative paths as expected. --- crates/tauri-bundler/src/bundle/macos/sign.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/tauri-bundler/src/bundle/macos/sign.rs b/crates/tauri-bundler/src/bundle/macos/sign.rs index 81e3df2a6dbe..f338b6cafc3b 100644 --- a/crates/tauri-bundler/src/bundle/macos/sign.rs +++ b/crates/tauri-bundler/src/bundle/macos/sign.rs @@ -267,6 +267,11 @@ fn sign_command_custom>( let cwd = std::env::current_dir()?; let mut cmd = std::process::Command::new(&command.cmd); + + // Set the current working directory to where the command is expected to run from + // This allows relative paths in the cmd field to work correctly + cmd.current_dir(&cwd); + for arg in &command.args { if arg == "%1" { cmd.arg(path); @@ -280,5 +285,8 @@ fn sign_command_custom>( } } } + + log::info!(action = "Signing"; "Running command from directory: {}", cwd.display()); + Ok(cmd) } From 6066199db60ad2898b3280a8e7541db005725b2f Mon Sep 17 00:00:00 2001 From: Pierre Hugo Date: Fri, 5 Dec 2025 17:26:54 +0200 Subject: [PATCH 6/6] Add .changes entry --- .changes/macos-pkg-installer.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .changes/macos-pkg-installer.md diff --git a/.changes/macos-pkg-installer.md b/.changes/macos-pkg-installer.md new file mode 100644 index 000000000000..bc4cac6ecdd8 --- /dev/null +++ b/.changes/macos-pkg-installer.md @@ -0,0 +1,20 @@ +--- +tauri-bundler: minor:feat +tauri-utils: minor:feat +tauri-cli: minor:feat +--- + +Add macOS PKG installer support with custom signing commands. + +Implements support for creating macOS PKG installers using pkgbuild and productbuild, with native signing via productsign and support for custom signing commands (useful for HSM-based signing solutions). + +Features: +- Create PKG installers from .app bundles using distribution.xml from project root +- Native PKG signing with productsign using signingIdentity or APPLE_CERTIFICATE +- Custom signing command support for .app bundles, .pkg installers, and .dmg disk images +- Custom commands use %1 placeholder for artifact path and run in build directory for relative path support + +Configuration fields added to MacOsSettings: +- `appSignCommand`: Custom command for signing .app bundles +- `pkgSignCommand`: Custom command for signing .pkg installers +- `dmgSignCommand`: Custom command for signing .dmg disk images