Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 20 additions & 0 deletions .changes/macos-pkg-installer.md
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions crates/tauri-bundler/src/bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<Bundle>> {
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);
Copy link
Member

Choose a reason for hiding this comment

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

i won't annotate every line but logs like this one are honestly a bit too verbose, at least for the info level, perhaps tracing or debug.


let target_os = settings.target_platform();

Expand Down Expand Up @@ -110,8 +114,11 @@ pub fn bundle_project(settings: &Settings) -> crate::Result<Vec<Bundle>> {
let mut main_binary_signed = false;
let mut bundles = Vec::<Bundle>::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;
}

Expand Down Expand Up @@ -150,6 +157,18 @@ pub fn bundle_project(settings: &Settings) -> crate::Result<Vec<Bundle>> {
}
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)?,
Expand Down
5 changes: 5 additions & 0 deletions crates/tauri-bundler/src/bundle/macos/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,14 @@ pub fn bundle_project(settings: &Settings) -> crate::Result<Vec<PathBuf>> {

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 {
Expand Down
30 changes: 19 additions & 11 deletions crates/tauri-bundler/src/bundle/macos/dmg/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)?;
}
}
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/tauri-bundler/src/bundle/macos/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ pub mod app;
pub mod dmg;
pub mod icon;
pub mod ios;
pub mod pkg;
pub mod sign;
133 changes: 133 additions & 0 deletions crates/tauri-bundler/src/bundle/macos/pkg/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Copyright 2016-2019 Cargo-Bundle developers <https://github.com/burtonageo/cargo-bundle>
// 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<PathBuf>,
pub app: Vec<PathBuf>,
}

/// 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<Bundled> {
// 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)))?;

// Sign PKG if needed
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)?;
}
}
}
}

log::info!(action = "Finished"; "PKG installer at {}", pkg_path.display());

Ok(Bundled {
pkg: vec![pkg_path],
app: app_bundle_paths,
})
}
123 changes: 123 additions & 0 deletions crates/tauri-bundler/src/bundle/macos/sign.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,126 @@ fn find_api_key(folder: PathBuf, file_name: &OsString) -> Option<PathBuf> {
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(())
}

/// 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<P: AsRef<std::path::Path>>(
path: P,
command: &crate::bundle::settings::CustomSignCommandSettings,
) -> crate::Result<std::process::Command> {
use std::path::Path;

let path = path.as_ref();
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);
} 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);
}
}
}

log::info!(action = "Signing"; "Running command from directory: {}", cwd.display());

Ok(cmd)
}
Loading