Skip to content

Commit ef55666

Browse files
committed
feat: Mac .pkg installer
This adds support for building Mac .pkg installers. This current implementation is intentionally a bit simplistic: it produces one .pkg per package in the user's workspace, without any special branding applied. We currently only allow the app identifier and install location to be configured, but we can consider adding further customization in the future.
1 parent 6572c42 commit ef55666

File tree

8 files changed

+342
-10
lines changed

8 files changed

+342
-10
lines changed
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
//! Code for generating installer.pkg
2+
3+
use std::{collections::BTreeMap, fs};
4+
5+
use axoasset::LocalAsset;
6+
use axoprocess::Cmd;
7+
use camino::Utf8PathBuf;
8+
use serde::Serialize;
9+
use temp_dir::TempDir;
10+
use tracing::info;
11+
12+
use crate::{create_tmp, DistResult};
13+
14+
use super::ExecutableZipFragment;
15+
16+
/// Info about a package installer
17+
#[derive(Debug, Clone, Serialize)]
18+
pub struct PkgInstallerInfo {
19+
/// ExecutableZipFragment for this variant
20+
pub artifact: ExecutableZipFragment,
21+
/// Identifier for the final installer
22+
pub identifier: String,
23+
/// Default install location
24+
pub install_location: String,
25+
/// Final file path of the pkg
26+
pub file_path: Utf8PathBuf,
27+
/// Dir stuff goes to
28+
pub package_dir: Utf8PathBuf,
29+
/// The app version
30+
pub version: String,
31+
/// Executable aliases
32+
pub bin_aliases: BTreeMap<String, Vec<String>>,
33+
}
34+
35+
impl PkgInstallerInfo {
36+
/// Build the pkg installer
37+
pub fn build(&self) -> DistResult<()> {
38+
info!("building a pkg: {}", self.identifier);
39+
40+
// We can't build directly from dist_dir because the
41+
// package installer wants the directory we feed it
42+
// to have the final package layout, which in this case
43+
// is going to be an FHS-ish path installed into a public
44+
// location. So instead we create a new tree with our stuff
45+
// like we want it, and feed that to pkgbuild.
46+
let (_build_dir, build_dir) = create_tmp()?;
47+
let bindir = build_dir.join("bin");
48+
LocalAsset::create_dir_all(&bindir)?;
49+
let libdir = build_dir.join("lib");
50+
LocalAsset::create_dir_all(&libdir)?;
51+
52+
info!("Copying executables");
53+
for exe in &self.artifact.executables {
54+
info!("{} => {:?}", &self.package_dir.join(exe), bindir.join(exe));
55+
LocalAsset::copy_file_to_file(&self.package_dir.join(exe), bindir.join(exe))?;
56+
}
57+
#[cfg(unix)]
58+
for (bin, targets) in &self.bin_aliases {
59+
for target in targets {
60+
std::os::unix::fs::symlink(&bindir.join(bin), &bindir.join(target))?;
61+
}
62+
}
63+
for lib in self
64+
.artifact
65+
.cdylibs
66+
.iter()
67+
.chain(self.artifact.cstaticlibs.iter())
68+
{
69+
LocalAsset::copy_file_to_file(&self.package_dir.join(lib), libdir.join(lib))?;
70+
}
71+
72+
// The path the two pkg files get placed in while building
73+
let pkg_output = TempDir::new()?;
74+
let pkg_output_path = pkg_output.path();
75+
let pkg_path = pkg_output_path.join("package.pkg");
76+
let product_path = pkg_output_path.join("product.pkg");
77+
78+
let mut pkgcmd = Cmd::new("/usr/bin/pkgbuild", "create individual pkg");
79+
pkgcmd.arg("--root").arg(build_dir);
80+
pkgcmd.arg("--identifier").arg(&self.identifier);
81+
pkgcmd.arg("--install-location").arg(&self.install_location);
82+
pkgcmd.arg("--version").arg(&self.version);
83+
pkgcmd.arg(&pkg_path);
84+
// Ensures stdout from the build process doesn't taint the dist-manifest
85+
pkgcmd.stdout_to_stderr();
86+
pkgcmd.run()?;
87+
88+
// OK, we've made a package. Now wrap it in a product pkg.
89+
let mut productcmd = Cmd::new("/usr/bin/productbuild", "create final product .pkg");
90+
productcmd.arg("--package").arg(&pkg_path);
91+
productcmd.arg(&product_path);
92+
productcmd.stdout_to_stderr();
93+
productcmd.run()?;
94+
95+
fs::copy(&product_path, &self.file_path)?;
96+
97+
Ok(())
98+
}
99+
}

cargo-dist/src/backend/installer/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use std::collections::BTreeMap;
66

77
use camino::Utf8PathBuf;
8+
use macpkg::PkgInstallerInfo;
89
use serde::Serialize;
910

1011
use crate::{
@@ -18,6 +19,7 @@ use self::msi::MsiInstallerInfo;
1819
use self::npm::NpmInstallerInfo;
1920

2021
pub mod homebrew;
22+
pub mod macpkg;
2123
pub mod msi;
2224
pub mod npm;
2325
pub mod powershell;
@@ -37,6 +39,8 @@ pub enum InstallerImpl {
3739
Homebrew(HomebrewInstallerInfo),
3840
/// Windows msi installer
3941
Msi(MsiInstallerInfo),
42+
/// Mac pkg installer
43+
Pkg(PkgInstallerInfo),
4044
}
4145

4246
/// Generic info about an installer

cargo-dist/src/config.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,11 @@ pub struct DistMetadata {
439439
/// Any additional steps that need to be performed before building local artifacts
440440
#[serde(default)]
441441
pub github_build_setup: Option<String>,
442+
443+
/// Configuration specific to Mac .pkg installers
444+
#[serde(skip_serializing_if = "Option::is_none")]
445+
#[serde(default)]
446+
pub mac_pkg_config: Option<MacPkgConfig>,
442447
}
443448

444449
/// values of the form `permission-name: read`
@@ -523,6 +528,7 @@ impl DistMetadata {
523528
package_libraries: _,
524529
install_libraries: _,
525530
github_build_setup: _,
531+
mac_pkg_config: _,
526532
} = self;
527533
if let Some(include) = include {
528534
for include in include {
@@ -618,6 +624,7 @@ impl DistMetadata {
618624
package_libraries,
619625
install_libraries,
620626
github_build_setup,
627+
mac_pkg_config,
621628
} = self;
622629

623630
// Check for global settings on local packages
@@ -799,6 +806,9 @@ impl DistMetadata {
799806
if install_libraries.is_none() {
800807
install_libraries.clone_from(&workspace_config.install_libraries);
801808
}
809+
if mac_pkg_config.is_none() {
810+
mac_pkg_config.clone_from(&workspace_config.mac_pkg_config);
811+
}
802812

803813
// This was historically implemented as extend, but I'm not convinced the
804814
// inconsistency is worth the inconvenience...
@@ -956,6 +966,8 @@ pub enum InstallerStyle {
956966
Homebrew,
957967
/// Generate an msi installer that embeds the binary
958968
Msi,
969+
/// Generate an Apple pkg installer that embeds the binary
970+
Pkg,
959971
}
960972

961973
impl std::fmt::Display for InstallerStyle {
@@ -966,6 +978,7 @@ impl std::fmt::Display for InstallerStyle {
966978
InstallerStyle::Npm => "npm",
967979
InstallerStyle::Homebrew => "homebrew",
968980
InstallerStyle::Msi => "msi",
981+
InstallerStyle::Pkg => "pkg",
969982
};
970983
string.fmt(f)
971984
}
@@ -1675,6 +1688,18 @@ impl std::fmt::Display for ProductionMode {
16751688
}
16761689
}
16771690

1691+
/// Configuration for Mac .pkg installers
1692+
#[derive(Debug, Clone, Deserialize, Serialize)]
1693+
#[serde(rename_all = "kebab-case")]
1694+
pub struct MacPkgConfig {
1695+
/// A unique identifier, in tld.domain.package format
1696+
pub identifier: String,
1697+
/// The location to which the software should be installed.
1698+
/// If not specified, /usr/local will be used.
1699+
#[serde(skip_serializing_if = "Option::is_none")]
1700+
pub install_location: Option<String>,
1701+
}
1702+
16781703
pub(crate) fn parse_metadata_table_or_manifest(
16791704
manifest_path: &Utf8Path,
16801705
dist_manifest_path: Option<&Utf8Path>,

cargo-dist/src/errors.rs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -234,22 +234,22 @@ pub enum DistError {
234234
generate_mode: crate::config::GenerateMode,
235235
},
236236

237-
/// msi with too many packages
237+
/// msi/pkg with too many packages
238238
#[error("{artifact_name} depends on multiple packages, which isn't yet supported")]
239239
#[diagnostic(help("depends on {spec1} and {spec2}"))]
240-
MultiPackageMsi {
241-
/// Name of the msi
240+
MultiPackage {
241+
/// Name of the artifact
242242
artifact_name: String,
243243
/// One of the pacakges
244244
spec1: String,
245245
/// A different package
246246
spec2: String,
247247
},
248248

249-
/// msi with too few packages
249+
/// msi/pkg with too few packages
250250
#[error("{artifact_name} has no binaries")]
251251
#[diagnostic(help("This should be impossible, you did nothing wrong, please file an issue!"))]
252-
NoPackageMsi {
252+
NoPackage {
253253
/// Name of the msi
254254
artifact_name: String,
255255
},
@@ -525,6 +525,16 @@ pub enum DistError {
525525
#[error("We failed to decode the certificate stored in the CODESIGN_CERTIFICATE environment variable.")]
526526
#[diagnostic(help("Is the value of this envirionment variable valid base64?"))]
527527
CertificateDecodeError {},
528+
529+
/// Missing configuration for a .pkg
530+
#[error("A Mac .pkg installer was requested, but the config is missing")]
531+
#[diagnostic(help("Please ensure a dist.mac-pkg-config section is present in your config. For more details see: https://example.com"))]
532+
MacPkgConfigMissing {},
533+
534+
/// User left identifier empty in init
535+
#[error("No bundle identifier was specified")]
536+
#[diagnostic(help("Please either enter a bundle identifier, or disable the Mac .pkg"))]
537+
MacPkgBundleIdentifierMissing {},
528538
}
529539

530540
/// This error indicates we tried to deserialize some YAML with serde_yml

cargo-dist/src/init.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,7 @@ fn get_new_dist_metadata(
477477
package_libraries: None,
478478
install_libraries: None,
479479
github_build_setup: None,
480+
mac_pkg_config: None,
480481
}
481482
};
482483

@@ -664,6 +665,7 @@ fn get_new_dist_metadata(
664665
InstallerStyle::Npm,
665666
InstallerStyle::Homebrew,
666667
InstallerStyle::Msi,
668+
InstallerStyle::Pkg,
667669
]
668670
} else {
669671
eprintln!("{notice} no CI backends enabled, most installers have been hidden");
@@ -692,6 +694,7 @@ fn get_new_dist_metadata(
692694
InstallerStyle::Npm => "npm",
693695
InstallerStyle::Homebrew => "homebrew",
694696
InstallerStyle::Msi => "msi",
697+
InstallerStyle::Pkg => "pkg",
695698
});
696699
}
697700

@@ -779,6 +782,66 @@ fn get_new_dist_metadata(
779782
}
780783
}
781784

785+
// Special handling of the pkg installer
786+
if meta
787+
.installers
788+
.as_deref()
789+
.unwrap_or_default()
790+
.contains(&InstallerStyle::Pkg)
791+
{
792+
let pkg_is_new = !orig_meta
793+
.installers
794+
.as_deref()
795+
.unwrap_or_default()
796+
.contains(&InstallerStyle::Pkg);
797+
798+
if pkg_is_new && orig_meta.mac_pkg_config.is_none() {
799+
let prompt = r#"you've enabled a Mac .pkg installer. This requires a unique bundle ID;
800+
please enter one now. This is in reverse-domain name format.
801+
For more information, consult the Apple documentation:
802+
https://developer.apple.com/library/archive/documentation/CoreFoundation/Conceptual/CFBundles/BundleTypes/BundleTypes.html#//apple_ref/doc/uid/10000123i-CH101-SW1"#;
803+
let default = "".to_string();
804+
805+
let identifier: String = if args.yes {
806+
default
807+
} else {
808+
let res = Input::with_theme(&theme)
809+
.with_prompt(prompt)
810+
.allow_empty(true)
811+
.interact_text()?;
812+
eprintln!();
813+
res
814+
};
815+
let identifier = identifier.trim();
816+
if identifier.is_empty() {
817+
return Err(DistError::MacPkgBundleIdentifierMissing {});
818+
}
819+
820+
let prompt = r#"Please enter the installation prefix this .pkg should use."#;
821+
let prefix = if args.yes {
822+
None
823+
} else {
824+
let res: String = Input::with_theme(&theme)
825+
.with_prompt(prompt)
826+
.allow_empty(true)
827+
.interact_text()?;
828+
eprintln!();
829+
830+
let trimmed = res.trim();
831+
if trimmed.is_empty() {
832+
None
833+
} else {
834+
Some(trimmed.to_owned())
835+
}
836+
};
837+
838+
meta.mac_pkg_config = Some(config::MacPkgConfig {
839+
identifier: identifier.to_owned(),
840+
install_location: prefix,
841+
})
842+
}
843+
}
844+
782845
meta.publish_jobs = if publish_jobs.is_empty() {
783846
None
784847
} else {
@@ -978,6 +1041,7 @@ fn apply_dist_to_metadata(metadata: &mut toml_edit::Item, meta: &DistMetadata) {
9781041
bin_aliases: _,
9791042
system_dependencies: _,
9801043
github_build_setup: _,
1044+
mac_pkg_config: _,
9811045
} = &meta;
9821046

9831047
// Forcibly inline the default install_path if not specified,

cargo-dist/src/lib.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use axoasset::LocalAsset;
1717
use axoprocess::Cmd;
1818
use backend::{
1919
ci::CiInfo,
20-
installer::{self, msi::MsiInstallerInfo, InstallerImpl},
20+
installer::{self, macpkg::PkgInstallerInfo, msi::MsiInstallerInfo, InstallerImpl},
2121
};
2222
use build::generic::{build_generic_target, run_extra_artifacts_build};
2323
use build::{
@@ -312,8 +312,9 @@ fn build_fake(
312312
with_root,
313313
}) => zip_dir(src_path, dest_path, zip_style, with_root.as_deref())?,
314314
BuildStep::GenerateInstaller(installer) => match installer {
315-
// MSI, unlike other installers, isn't safe to generate on any platform
315+
// MSI and pkg, unlike other installers, aren't safe to generate on any platform
316316
InstallerImpl::Msi(msi) => generate_fake_msi(dist_graph, msi, manifest)?,
317+
InstallerImpl::Pkg(pkg) => generate_fake_pkg(dist_graph, pkg, manifest)?,
317318
_ => generate_installer(dist_graph, installer, manifest)?,
318319
},
319320
BuildStep::Checksum(ChecksumImpl {
@@ -367,6 +368,16 @@ fn generate_fake_msi(
367368
Ok(())
368369
}
369370

371+
fn generate_fake_pkg(
372+
_dist: &DistGraph,
373+
pkg: &PkgInstallerInfo,
374+
_manifest: &DistManifest,
375+
) -> DistResult<()> {
376+
LocalAsset::write_new_all("", &pkg.file_path)?;
377+
378+
Ok(())
379+
}
380+
370381
/// Generate a checksum for the src_path to dest_path
371382
fn generate_and_write_checksum(
372383
manifest: &mut DistManifest,
@@ -727,6 +738,7 @@ fn generate_installer(
727738
installer::homebrew::write_homebrew_formula(dist, info, manifest)?
728739
}
729740
InstallerImpl::Msi(info) => info.build(dist)?,
741+
InstallerImpl::Pkg(info) => info.build()?,
730742
}
731743
Ok(())
732744
}

cargo-dist/src/manifest.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,11 @@ fn add_manifest_artifact(
355355
description = Some("install via msi".to_owned());
356356
kind = cargo_dist_schema::ArtifactKind::Installer;
357357
}
358+
ArtifactKind::Installer(InstallerImpl::Pkg(..)) => {
359+
install_hint = None;
360+
description = Some("install via pkg".to_owned());
361+
kind = cargo_dist_schema::ArtifactKind::Installer;
362+
}
358363
ArtifactKind::Checksum(_) => {
359364
install_hint = None;
360365
description = None;

0 commit comments

Comments
 (0)