Skip to content
Merged
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
3 changes: 1 addition & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ a title bar. It serves as an example of an application that
can be bundled with cargo-bundle, as well as a test-case for
cargo-bundle's support for bundling crate examples.
"""

[dependencies]
anyhow = "1.0.102"
ar = "0.9.0"
Expand All @@ -41,6 +40,7 @@ reqwest = { version = "0.13.2", features = [
"blocking",
"native-tls",
], default-features = false }
tempfile = "3.27.0"
serde = "1.0.228"
serde_derive = "1.0.228"
serde_json = "1.0.149"
Expand All @@ -53,7 +53,6 @@ uuid = { version = "1.22.0", features = ["v5"] }
walkdir = "2.5.0"

[dev-dependencies]
tempfile = "3.27.0"
winit = "0.30.13"

[[example]]
Expand Down
165 changes: 165 additions & 0 deletions src/bundle/dmg_bundle.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// A macOS DMG (disk image) bundle is a compressed disk image that contains the
// application bundle and a symlink to /Applications so the user can simply
// drag-and-drop to install.
//
// The layout inside the mounted volume is:
//
// <AppName>.dmg (read-only compressed UDZO image)
// <AppName>.app # the application bundle
// Applications -> /Applications # drag-and-drop install target
//
// Building requires macOS because the `hdiutil` command is used to create and
// convert the disk image.

use super::common;
use crate::Settings;
use crate::bundle::osx_bundle;
use anyhow::Context;
use std::fs;
use std::path::PathBuf;
use std::process::Command;

pub fn bundle_project(settings: &Settings) -> crate::Result<Vec<PathBuf>> {
const BUNDLE_MINIMUM: u64 = 52_428_800;

let dmg_name = format!("{}.dmg", settings.bundle_name());
common::print_bundling(&dmg_name)?;

let base_dir = settings.project_out_directory().join("bundle/dmg");
fs::create_dir_all(&base_dir)
.with_context(|| format!("Failed to create bundle directory {base_dir:?}"))?;

// Build the .app bundle into the DMG staging directory.
let app_bundle_name = format!("{}.app", settings.bundle_name());
let app_bundle_path = base_dir.join(&app_bundle_name);
if app_bundle_path.exists() {
fs::remove_dir_all(&app_bundle_path)
.with_context(|| format!("Failed to remove existing {app_bundle_name}"))?;
}

osx_bundle::bundle_project_at(settings, &base_dir)
.with_context(|| "Failed to create app bundle for DMG")?;

let dmg_path = base_dir.join(&dmg_name);
if dmg_path.exists() {
fs::remove_file(&dmg_path)
.with_context(|| format!("Failed to remove existing {dmg_name}"))?;
}

// Determine the size of the app bundle and add a generous overhead.
let bundle_size = dir_size(&app_bundle_path)?;
let image_size_bytes = bundle_size + BUNDLE_MINIMUM; // at least 50 MiB

let temp_dir = tempfile::tempdir()
.with_context(|| "Failed to create temporary directory for DMG staging")?;

let staging_dmg = temp_dir.path().join("staging.dmg");

// Create a writable HFS+ disk image large enough for the bundle.
let status = Command::new("hdiutil")
.args([
"create",
staging_dmg.to_str().unwrap(),
"-ov",
"-fs",
"HFS+",
"-size",
&image_size_bytes.to_string(),
"-volname",
settings.bundle_name(),
])
.status()
.with_context(|| "Failed to run hdiutil create (macOS only)")?;

if !status.success() {
anyhow::bail!("hdiutil create failed");
}

// Mount the writable image.
let output = Command::new("hdiutil")
.args([
"attach",
staging_dmg.to_str().unwrap(),
"-nobrowse",
"-noverify",
"-noautoopen",
"-noautofsck",
])
.output()
.with_context(|| "Failed to mount staging DMG")?;

if !output.status.success() {
anyhow::bail!("hdiutil attach failed");
}

let mount_point = parse_mount_point(&output.stdout)
.with_context(|| "Could not determine DMG mount point from hdiutil output")?;

// Copy the app bundle and create the /Applications symlink inside the image.
let copy_result = (|| -> crate::Result<()> {
common::copy_dir(&app_bundle_path, &mount_point.join(&app_bundle_name))?;
#[cfg(unix)]
std::os::unix::fs::symlink("/Applications", mount_point.join("Applications"))
.with_context(|| "Failed to create /Applications symlink")?;
Ok(())
})();

// Always unmount, even if copying failed.
let _ = Command::new("hdiutil")
.args(["detach", mount_point.to_str().unwrap()])
.status();

copy_result?;

// Convert the writable image to a read-only compressed UDZO image.
let status = Command::new("hdiutil")
.args([
"convert",
staging_dmg.to_str().unwrap(),
"-ov",
"-format",
"UDZO",
"-imagekey",
"zlib-level=9",
"-o",
dmg_path.to_str().unwrap(),
])
.status()
.with_context(|| "Failed to run hdiutil convert")?;

if !status.success() {
anyhow::bail!("hdiutil convert failed");
}

Ok(vec![dmg_path])
}

/// Walk a directory and sum the sizes of all contained files.
fn dir_size(dir: &std::path::Path) -> crate::Result<u64> {
let mut total: u64 = 0;
for entry in walkdir::WalkDir::new(dir) {
let entry = entry?;
if entry.file_type().is_file() {
total += entry.metadata()?.len();
}
}
Ok(total)
}

fn parse_mount_point(stdout: &[u8]) -> crate::Result<PathBuf> {
let text = std::str::from_utf8(stdout)?;

// hdiutil attach prints a tab-separated line whose last field is the mount
// point, e.g.: /dev/disk2s1 Apple_HFS /Volumes/MyApp
for line in text.lines().rev() {
let parts: Vec<&str> = line.split('\t').collect();
if let Some(path) = parts.last() {
let path = path.trim();
if path.starts_with("/Volumes/") {
return Ok(PathBuf::from(path));
}
}
}

anyhow::bail!("Could not find a /Volumes/… mount point in hdiutil output")
}
2 changes: 2 additions & 0 deletions src/bundle/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod category;
mod common;
mod dmg_bundle;
mod ios_bundle;
mod linux;
mod msi_bundle;
Expand All @@ -18,6 +19,7 @@ pub fn bundle_project(settings: Settings) -> crate::Result<Vec<PathBuf>> {
for package_type in settings.package_types()? {
paths.append(&mut match package_type {
PackageType::OsxBundle => osx_bundle::bundle_project(&settings)?,
PackageType::OsxDmg => dmg_bundle::bundle_project(&settings)?,
PackageType::IosBundle => ios_bundle::bundle_project(&settings)?,
PackageType::WindowsMsi => msi_bundle::bundle_project(&settings)?,
PackageType::WxsMsi => wxsmsi_bundle::bundle_project(&settings)?,
Expand Down
41 changes: 36 additions & 5 deletions src/bundle/osx_bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,15 @@ use std::io::{self, BufWriter};
use std::path::{Path, PathBuf};

pub fn bundle_project(settings: &Settings) -> crate::Result<Vec<PathBuf>> {
let base_dir = settings.project_out_directory().join("bundle/osx");
let path = bundle_project_at(settings, &base_dir)?;
Ok(vec![path])
}

pub fn bundle_project_at(settings: &Settings, output_dir: &Path) -> crate::Result<PathBuf> {
let app_bundle_name = format!("{}.app", settings.bundle_name());
common::print_bundling(&app_bundle_name)?;
let app_bundle_path = settings
.project_out_directory()
.join("bundle/osx")
.join(&app_bundle_name);
let app_bundle_path = output_dir.join(&app_bundle_name);
if app_bundle_path.exists() {
fs::remove_dir_all(&app_bundle_path)
.with_context(|| format!("Failed to remove old {app_bundle_name}"))?;
Expand Down Expand Up @@ -70,11 +73,14 @@ pub fn bundle_project(settings: &Settings) -> crate::Result<Vec<PathBuf>> {
copy_binary_to_bundle(&bundle_directory, settings)
.with_context(|| format!("Failed to copy binary from {:?}", settings.binary_path()))?;

write_localizations(&resources_dir, settings)
.with_context(|| "Failed to write localisation files")?;

if copied > 0 {
add_rpath(&bundle_directory, settings)?;
}

Ok(vec![app_bundle_path])
Ok(app_bundle_path)
}

#[allow(dead_code)]
Expand Down Expand Up @@ -500,6 +506,31 @@ fn create_icns_file(
anyhow::bail!("No usable icon files found.");
}

/// Writes `*.lproj/InfoPlist.strings` localisation files into `resources_dir`
/// for every locale present in the settings' `osx_localizations` map.
fn write_localizations(resources_dir: &Path, settings: &Settings) -> crate::Result<()> {
let Some(localizations) = settings.osx_localizations() else {
return Ok(());
};

for (locale, strings) in localizations {
let lproj_dir = resources_dir.join(locale).with_extension("lproj");
fs::create_dir_all(&lproj_dir)
.with_context(|| format!("Failed to create {lproj_dir:?}"))?;

let strings_path = lproj_dir.join("InfoPlist.strings");
let file = &mut common::create_file(&strings_path)?;
for (key, value) in strings {
// Escape embedded double-quotes in the value.
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
writeln!(file, "{key} = \"{escaped}\";")?;
}
file.flush()?;
}

Ok(())
}

/// Converts an image::DynamicImage into an icns::Image.
fn make_icns_image(img: image::DynamicImage) -> io::Result<icns::Image> {
let pixel_format = match img.color() {
Expand Down
55 changes: 53 additions & 2 deletions src/bundle/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use target_build_utils::TargetInfo;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PackageType {
OsxBundle,
OsxDmg,
IosBundle,
WindowsMsi,
WxsMsi,
Expand Down Expand Up @@ -63,6 +64,7 @@ impl PackageType {
"msi" => Some(PackageType::WindowsMsi),
"wxsmsi" => Some(PackageType::WxsMsi),
"osx" => Some(PackageType::OsxBundle),
"dmg" => Some(PackageType::OsxDmg),
"rpm" => Some(PackageType::Rpm),
"appimage" => Some(PackageType::AppImage),
_ => None,
Expand All @@ -76,13 +78,16 @@ impl PackageType {
PackageType::WindowsMsi => "msi",
PackageType::WxsMsi => "wxsmsi",
PackageType::OsxBundle => "osx",
PackageType::OsxDmg => "dmg",
PackageType::Rpm => "rpm",
PackageType::AppImage => "appimage",
}
}

pub const fn all() -> &'static [&'static str] {
&["deb", "ios", "msi", "wxsmsi", "osx", "rpm", "appimage"]
&[
"deb", "ios", "msi", "wxsmsi", "osx", "dmg", "rpm", "appimage",
]
}
}

Expand Down Expand Up @@ -115,6 +120,7 @@ struct BundleSettings {
osx_minimum_system_version: Option<String>,
osx_url_schemes: Option<Vec<String>>,
osx_info_plist_exts: Option<Vec<String>>,
osx_localizations: Option<HashMap<String, HashMap<String, String>>>,
// Bundles for other binaries/examples:
bin: Option<HashMap<String, BundleSettings>>,
example: Option<HashMap<String, BundleSettings>>,
Expand Down Expand Up @@ -357,7 +363,7 @@ impl Settings {
std::env::consts::OS
};
match target_os {
"macos" => Ok(vec![PackageType::OsxBundle]),
"macos" => Ok(vec![PackageType::OsxBundle, PackageType::OsxDmg]),
"ios" => Ok(vec![PackageType::IosBundle]),
"linux" => Ok(vec![PackageType::Deb, PackageType::AppImage]), // TODO: Do Rpm too, once it's implemented.
"windows" => Ok(vec![PackageType::WindowsMsi]),
Expand Down Expand Up @@ -561,6 +567,14 @@ impl Settings {
None => ResourcePaths::new(&[], false),
}
}

/// Returns a map of locale codes to key-value localisation strings for
/// macOS `*.lproj/InfoPlist.strings` files. The outer key is a locale
/// code such as `"fr"` or `"de"` and the inner map contains plist string
/// keys such as `"CFBundleDisplayName"` mapped to their translated value.
pub fn osx_localizations(&self) -> Option<&HashMap<String, HashMap<String, String>>> {
self.bundle_settings.osx_localizations.as_ref()
}
}

fn bundle_settings_from_table(
Expand Down Expand Up @@ -709,4 +723,41 @@ mod tests {
let baz: &BundleSettings = examples.get("baz").unwrap();
assert_eq!(baz.name, Some("Baz Example".to_string()));
}

#[test]
fn dmg_round_trip() {
use super::PackageType;

// Each new short name should parse back to the correct variant.
assert_eq!(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should we be testing the other PackageTypes similarly?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Probably... not, it's just a fluff test, I kind of wanted to just fold that into the display implementation rather than the .to_short_name helper, would that be okay?

PackageType::from_short_name("dmg"),
Some(PackageType::OsxDmg)
);

// And Display / short_name should survive the round-trip.
assert_eq!(PackageType::OsxDmg.short_name(), "dmg");
assert_eq!(PackageType::OsxDmg.to_string(), "dmg");
}

#[test]
fn all_package_types_are_listed() {
use super::PackageType;
let all = PackageType::all();
assert!(all.contains(&"dmg"), "dmg missing from PackageType::all()");
}

#[test]
fn osx_localizations_parses_from_toml() {
let toml_str = r#"
[osx_localizations.fr]
CFBundleDisplayName = "Mon Application"

[osx_localizations.de]
CFBundleDisplayName = "Meine Anwendung"
"#;
let bundle: BundleSettings = toml::from_str(toml_str).unwrap();
let locs = bundle.osx_localizations.unwrap();
assert_eq!(locs["fr"]["CFBundleDisplayName"], "Mon Application");
assert_eq!(locs["de"]["CFBundleDisplayName"], "Meine Anwendung");
}
}
Loading