diff --git a/Cargo.toml b/Cargo.toml index d73c1f8..ac4a82f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" @@ -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" @@ -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]] diff --git a/src/bundle/dmg_bundle.rs b/src/bundle/dmg_bundle.rs new file mode 100644 index 0000000..d729c7f --- /dev/null +++ b/src/bundle/dmg_bundle.rs @@ -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: +// +// .dmg (read-only compressed UDZO image) +// .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> { + 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 { + 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 { + 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") +} diff --git a/src/bundle/mod.rs b/src/bundle/mod.rs index 001d09a..c1e14d5 100644 --- a/src/bundle/mod.rs +++ b/src/bundle/mod.rs @@ -1,5 +1,6 @@ mod category; mod common; +mod dmg_bundle; mod ios_bundle; mod linux; mod msi_bundle; @@ -18,6 +19,7 @@ pub fn bundle_project(settings: Settings) -> crate::Result> { 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)?, diff --git a/src/bundle/osx_bundle.rs b/src/bundle/osx_bundle.rs index 979c646..361bf8c 100644 --- a/src/bundle/osx_bundle.rs +++ b/src/bundle/osx_bundle.rs @@ -31,12 +31,15 @@ use std::io::{self, BufWriter}; use std::path::{Path, PathBuf}; pub fn bundle_project(settings: &Settings) -> crate::Result> { + 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 { 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}"))?; @@ -70,11 +73,14 @@ pub fn bundle_project(settings: &Settings) -> crate::Result> { 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)] @@ -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 { let pixel_format = match img.color() { diff --git a/src/bundle/settings.rs b/src/bundle/settings.rs index c838256..85d4988 100644 --- a/src/bundle/settings.rs +++ b/src/bundle/settings.rs @@ -12,6 +12,7 @@ use target_build_utils::TargetInfo; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum PackageType { OsxBundle, + OsxDmg, IosBundle, WindowsMsi, WxsMsi, @@ -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, @@ -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", + ] } } @@ -115,6 +120,7 @@ struct BundleSettings { osx_minimum_system_version: Option, osx_url_schemes: Option>, osx_info_plist_exts: Option>, + osx_localizations: Option>>, // Bundles for other binaries/examples: bin: Option>, example: Option>, @@ -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]), @@ -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>> { + self.bundle_settings.osx_localizations.as_ref() + } } fn bundle_settings_from_table( @@ -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!( + 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"); + } }