diff --git a/Cargo.toml b/Cargo.toml index 2c24aed..9bf73a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,8 @@ reqwest = { version = "0.13.2", features = [ "blocking", "native-tls", ], default-features = false } +resvg = "0.45" +tempfile = "3.27.0" serde = "1.0.228" serde_derive = "1.0.228" serde_json = "1.0.149" @@ -51,9 +53,9 @@ term = "1.2.1" toml = "0.9.8" uuid = { version = "1.22.0", features = ["v5"] } walkdir = "2.5.0" +winres-edit = "0.2.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..55a3c2b --- /dev/null +++ b/src/bundle/dmg_bundle.rs @@ -0,0 +1,166 @@ +// 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 TO /Applications +// +// 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> { + 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 + 52_428_800).max(52_428_800); // 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) +} + +/// Extract the mount point path from `hdiutil attach` stdout. +fn parse_mount_point(stdout: &[u8]) -> crate::Result { + parse_mount_point_impl(stdout) +} + +fn parse_mount_point_impl(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, EXAMPLE: /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/linux/appimage_bundle.rs b/src/bundle/linux/appimage_bundle.rs index ab82491..402d0dc 100644 --- a/src/bundle/linux/appimage_bundle.rs +++ b/src/bundle/linux/appimage_bundle.rs @@ -2,12 +2,16 @@ use anyhow::Context; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use std::{ + ffi::OsStr, fs::File, io::{BufReader, BufWriter, Write}, path::PathBuf, process::Command, }; +use resvg::tiny_skia::{Pixmap, Transform}; +use resvg::usvg::{Options, Tree}; + use crate::bundle::{Settings, common}; use super::common::{generate_desktop_file, generate_icon_files}; @@ -37,9 +41,17 @@ pub fn bundle_project(settings: &Settings) -> crate::Result> { generate_icon_files(settings, &app_dir)?; generate_desktop_file(settings, &app_dir)?; - // TODO Symlinks (AppRun, .DirIcon, .desktop) common::symlink_file(&binary_dest_rel, &app_dir.join("AppRun"))?; + // Symlink the .desktop file to the AppDir root. + let desktop_filename = format!("{}.desktop", settings.binary_name()); + let desktop_rel = PathBuf::from("usr/share/applications").join(&desktop_filename); + common::symlink_file(&desktop_rel, &app_dir.join(&desktop_filename))?; + + // Generate a .DirIcon PNG from the first SVG icon, or symlink the first + // PNG icon so AppImage tools can pick it up. + generate_dir_icon(settings, &app_dir)?; + // Download the AppImage runtime let runtime = fetch_runtime(settings.binary_arch())?; @@ -70,6 +82,47 @@ pub fn bundle_project(settings: &Settings) -> crate::Result> { Ok(vec![package_path]) } +fn generate_dir_icon(settings: &Settings, app_dir: &std::path::Path) -> crate::Result<()> { + for icon_path in settings.icon_files() { + let icon_path = icon_path?; + if icon_path.extension() == Some(OsStr::new("svg")) { + let svg_data = std::fs::read_to_string(&icon_path) + .with_context(|| format!("Failed to read SVG icon {icon_path:?}"))?; + + let opt = Options::default(); + let tree = Tree::from_data(svg_data.as_bytes(), &opt) + .with_context(|| "Failed to parse SVG for .DirIcon")?; + + let size: u32 = 256; + let mut pixmap = + Pixmap::new(size, size).with_context(|| "Failed to create pixmap for .DirIcon")?; + resvg::render( + &tree, + Transform::from_scale( + size as f32 / tree.size().width(), + size as f32 / tree.size().height(), + ), + &mut pixmap.as_mut(), + ); + + pixmap + .save_png(app_dir.join(".DirIcon")) + .with_context(|| "Failed to save .DirIcon PNG")?; + + // Also symlink the SVG into the AppDir root so tools that prefer + // the vector version can find it. + let svg_filename = format!("{}.svg", settings.binary_name()); + let svg_rel = + PathBuf::from("usr/share/icons/hicolor/scalable/apps").join(&svg_filename); + let _ = common::symlink_file(&svg_rel, &app_dir.join(&svg_filename)); + + return Ok(()); + } + } + + Ok(()) +} + fn fetch_runtime(arch: &str) -> crate::Result> { let url = format!( "https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-{arch}" diff --git a/src/bundle/mod.rs b/src/bundle/mod.rs index 001d09a..ff9dbfd 100644 --- a/src/bundle/mod.rs +++ b/src/bundle/mod.rs @@ -1,10 +1,12 @@ mod category; mod common; +mod dmg_bundle; mod ios_bundle; mod linux; mod msi_bundle; mod osx_bundle; mod settings; +mod windows; mod wxsmsi_bundle; pub use self::common::{print_error, print_finished}; @@ -18,9 +20,11 @@ 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)?, + PackageType::WindowsBundle => windows::exe_bundle::bundle_project(&settings)?, PackageType::Deb => deb_bundle::bundle_project(&settings)?, PackageType::Rpm => rpm_bundle::bundle_project(&settings)?, PackageType::AppImage => appimage_bundle::bundle_project(&settings)?, diff --git a/src/bundle/osx_bundle.rs b/src/bundle/osx_bundle.rs index 979c646..6c04bac 100644 --- a/src/bundle/osx_bundle.rs +++ b/src/bundle/osx_bundle.rs @@ -23,6 +23,8 @@ use crate::Settings; use anyhow::Context; use image::imageops::FilterType::Lanczos3; use image::{self, GenericImageView}; +use resvg::tiny_skia::{Pixmap, Transform}; +use resvg::usvg::{Options, Tree}; use std::cmp::min; use std::ffi::OsStr; use std::fs::{self, File}; @@ -31,12 +33,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 +75,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)] @@ -427,6 +435,20 @@ fn create_icns_file( return Ok(None); } + // If one of the icon files is an SVG, convert it via resvg + iconutil. + for icon_path in settings.icon_files() { + let icon_path = icon_path?; + if icon_path.extension() == Some(OsStr::new("svg")) { + fs::create_dir_all(resources_dir)?; + let mut dest_path = resources_dir.clone(); + dest_path.push(settings.bundle_name()); + dest_path.set_extension("icns"); + create_icns_from_svg(&icon_path, resources_dir, &dest_path) + .with_context(|| format!("Failed to convert SVG icon {icon_path:?} to ICNS"))?; + return Ok(Some(dest_path)); + } + } + // If one of the icon files is already an ICNS file, just use that. for icon_path in settings.icon_files() { let icon_path = icon_path?; @@ -468,7 +490,7 @@ fn create_icns_file( for icon_path in settings.icon_files() { let icon_path = icon_path?; if icon_path.extension() == Some(OsStr::new("svg")) { - continue; // TODO: convert svg to appropriate format? + continue; } let icon = image::open(&icon_path)?; let density = if common::is_retina(&icon_path) { 2 } else { 1 }; @@ -500,6 +522,106 @@ fn create_icns_file( anyhow::bail!("No usable icon files found."); } +/// Renders an SVG icon at standard macOS iconset sizes, then runs `iconutil` +/// to produce an ICNS file at `dest_path`. +fn create_icns_from_svg( + svg_path: &Path, + resources_dir: &Path, + dest_path: &Path, +) -> crate::Result<()> { + let svg_data = fs::read_to_string(svg_path) + .with_context(|| format!("Failed to read SVG file {svg_path:?}"))?; + + let temp_dir = tempfile::tempdir().with_context(|| "Failed to create temp dir for iconset")?; + let iconset_dir = temp_dir.path().join("icon.iconset"); + fs::create_dir_all(&iconset_dir)?; + + let opt = Options::default(); + let tree = + Tree::from_data(svg_data.as_bytes(), &opt).with_context(|| "Failed to parse SVG data")?; + + // Standard macOS iconset sizes: (logical_size, scale_factor) + let sizes: &[(u32, u32)] = &[ + (16, 1), + (16, 2), + (32, 1), + (32, 2), + (128, 1), + (128, 2), + (256, 1), + (256, 2), + (512, 1), + (512, 2), + ]; + + for &(size, scale) in sizes { + let pixel_size = size * scale; + let filename = if scale == 1 { + format!("icon_{size}x{size}.png") + } else { + format!("icon_{size}x{size}@{scale}x.png") + }; + let output_path = iconset_dir.join(&filename); + + let mut pixmap = Pixmap::new(pixel_size, pixel_size) + .with_context(|| format!("Failed to create {pixel_size}x{pixel_size} pixmap"))?; + resvg::render( + &tree, + Transform::from_scale( + pixel_size as f32 / tree.size().width(), + pixel_size as f32 / tree.size().height(), + ), + &mut pixmap.as_mut(), + ); + pixmap + .save_png(&output_path) + .with_context(|| format!("Failed to save PNG {output_path:?}"))?; + } + + let status = std::process::Command::new("iconutil") + .current_dir(temp_dir.path()) + .arg("-c") + .arg("icns") + .arg("icon.iconset") + .status() + .with_context(|| "Failed to run iconutil (is Xcode command-line tools installed?)")?; + + if !status.success() { + anyhow::bail!("iconutil failed to create ICNS file"); + } + + fs::create_dir_all(resources_dir)?; + fs::copy(temp_dir.path().join("icon.icns"), dest_path) + .with_context(|| "Failed to copy generated icon.icns")?; + + Ok(()) +} + +/// 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..455980e 100644 --- a/src/bundle/settings.rs +++ b/src/bundle/settings.rs @@ -12,9 +12,11 @@ use target_build_utils::TargetInfo; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum PackageType { OsxBundle, + OsxDmg, IosBundle, WindowsMsi, WxsMsi, + WindowsBundle, Deb, Rpm, AppImage, @@ -63,8 +65,10 @@ 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), + "exe" => Some(PackageType::WindowsBundle), _ => None, } } @@ -76,13 +80,17 @@ impl PackageType { PackageType::WindowsMsi => "msi", PackageType::WxsMsi => "wxsmsi", PackageType::OsxBundle => "osx", + PackageType::OsxDmg => "dmg", PackageType::Rpm => "rpm", PackageType::AppImage => "appimage", + PackageType::WindowsBundle => "exe", } } pub const fn all() -> &'static [&'static str] { - &["deb", "ios", "msi", "wxsmsi", "osx", "rpm", "appimage"] + &[ + "deb", "ios", "msi", "wxsmsi", "osx", "dmg", "rpm", "appimage", "exe", + ] } } @@ -115,6 +123,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>, @@ -200,7 +209,9 @@ impl Settings { ), }; let binary_extension = match package_type { - Some(PackageType::WindowsMsi) | Some(PackageType::WxsMsi) => ".exe", + Some(PackageType::WindowsMsi) + | Some(PackageType::WxsMsi) + | Some(PackageType::WindowsBundle) => ".exe", _ => "", }; binary_name += binary_extension; @@ -357,10 +368,10 @@ 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]), + "windows" => Ok(vec![PackageType::WindowsMsi, PackageType::WindowsBundle]), os => anyhow::bail!("Native {} bundles not yet supported.", os), } } @@ -561,6 +572,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 +728,48 @@ mod tests { let baz: &BundleSettings = examples.get("baz").unwrap(); assert_eq!(baz.name, Some("Baz Example".to_string())); } + + #[test] + fn dmg_and_exe_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) + ); + assert_eq!( + PackageType::from_short_name("exe"), + Some(PackageType::WindowsBundle) + ); + + // And Display / short_name should survive the round-trip. + assert_eq!(PackageType::OsxDmg.short_name(), "dmg"); + assert_eq!(PackageType::WindowsBundle.short_name(), "exe"); + assert_eq!(PackageType::OsxDmg.to_string(), "dmg"); + assert_eq!(PackageType::WindowsBundle.to_string(), "exe"); + } + + #[test] + fn all_package_types_are_listed() { + use super::PackageType; + let all = PackageType::all(); + assert!(all.contains(&"dmg"), "dmg missing from PackageType::all()"); + assert!(all.contains(&"exe"), "exe 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"); + } } diff --git a/src/bundle/windows/exe_bundle.rs b/src/bundle/windows/exe_bundle.rs new file mode 100644 index 0000000..7346a58 --- /dev/null +++ b/src/bundle/windows/exe_bundle.rs @@ -0,0 +1,395 @@ +// Resource embedding is only performed when cargo-bundle runs on Windows. +// On other hosts the executable is still copied, but no icon or version info +// is injected into the PE binary. + +use crate::Settings; +use crate::bundle::common; +use anyhow::Context; +use std::ffi::OsStr; +use std::fs; +use std::path::PathBuf; +use winres_edit::Resources; + +use super::group_icon::GroupIcon; +use super::icon::Icon; +use resvg::tiny_skia::{Pixmap, Transform}; +use resvg::usvg::{Options, Tree}; + +const ICON_PIXEL_SIZES: &[u32] = &[16, 24, 32, 48, 64, 96, 128, 256, 512]; + +const LANGUAGE_ID_ENGLISH_US: u16 = 0x0409; + +const VERSION_NODE_DATA_TYPE_BINARY: u16 = 0; +const VERSION_NODE_DATA_TYPE_TEXT: u16 = 1; + +/// Windows PE resource type identifier for RT_GROUP_ICON. +const RT_GROUP_ICON_RESOURCE_TYPE: u16 = 14; + +const APPLICATION_ICON_GROUP_RESOURCE_ID: u16 = 1; +const FIRST_INDIVIDUAL_ICON_RESOURCE_ID: u16 = 1; +const VERSION_RESOURCE_ID: u16 = 1; + +/// String table key encoding language 0x0409 (en-US) and code page 0x04B0 (Unicode UTF-16). +const STRING_TABLE_LOCALE_ENGLISH_US_UNICODE: &str = "040904B0"; + +/// Translation record for English (US), code page 1200 (Unicode UTF-16 LE). +/// Layout: [language_id_low, language_id_high, codepage_low, codepage_high] +const TRANSLATION_ENTRY_ENGLISH_US_UNICODE: [u8; 4] = [0x09, 0x04, 0xB0, 0x04]; + +const WINDOWS_VERSION_COMPONENT_COUNT: usize = 4; + +pub fn bundle_project(settings: &Settings) -> crate::Result> { + let exe_name = format!("{}.exe", settings.binary_name()); + common::print_bundling(&exe_name)?; + + let base_dir = settings.project_out_directory().join("bundle/exe"); + fs::create_dir_all(&base_dir) + .with_context(|| format!("Failed to create output directory {base_dir:?}"))?; + + let output_exe = base_dir.join(&exe_name); + fs::copy(settings.binary_path(), &output_exe) + .with_context(|| format!("Failed to copy executable to {output_exe:?}"))?; + + let svg_icon_path = settings + .icon_files() + .filter_map(|icon_path_result| icon_path_result.ok()) + .find(|path| path.extension() == Some(OsStr::new("svg"))); + + embed_resources(settings, &output_exe, svg_icon_path.as_deref())?; + + Ok(vec![output_exe]) +} + +fn embed_resources( + settings: &Settings, + exe_path: &std::path::Path, + svg_icon: Option<&std::path::Path>, +) -> crate::Result<()> { + #[cfg(target_os = "windows")] + { + embed_resources_windows(settings, exe_path, svg_icon) + } + #[cfg(not(target_os = "windows"))] + { + let _ = (settings, exe_path, svg_icon); + common::print_warning( + "Windows PE resource embedding (icons, version info) is only performed \ + when cargo-bundle itself is run on Windows.", + )?; + Ok(()) + } +} + +fn embed_resources_windows( + settings: &Settings, + exe_path: &std::path::Path, + svg_icon: Option<&std::path::Path>, +) -> crate::Result<()> { + let mut resources = Resources::new(exe_path); + resources + .load() + .with_context(|| "Failed to load PE resources")?; + resources + .open() + .with_context(|| "Failed to open PE resources for writing")?; + + embed_version_info(settings, &resources)?; + + if let Some(svg_path) = svg_icon { + embed_svg_icons(svg_path, &resources)?; + } + + resources.close(); + + Ok(()) +} + +fn embed_version_info( + settings: &Settings, + resources: &winres_edit::Resources, +) -> crate::Result<()> { + use winres_edit::{Id, Resource, resource_type}; + + let version_string = settings.version_string().to_string(); + let string_pairs: &[(&str, String)] = &[ + ("ProductName", settings.bundle_name().to_owned()), + ("FileDescription", settings.short_description().to_owned()), + ("FileVersion", version_string.clone()), + ("ProductVersion", version_string), + ( + "LegalCopyright", + settings.copyright_string().unwrap_or("").to_owned(), + ), + ( + "CompanyName", + settings.authors_comma_separated().unwrap_or_default(), + ), + ("InternalName", settings.binary_name().to_owned()), + ( + "OriginalFilename", + format!("{}.exe", settings.binary_name()), + ), + ]; + + let version_info_data = build_version_info_resource(settings, string_pairs) + .with_context(|| "Failed to build VS_VERSIONINFO resource")?; + + let version_resource = Resource::new( + resources, + resource_type::VERSION.into(), + Id::Integer(VERSION_RESOURCE_ID).into(), + LANGUAGE_ID_ENGLISH_US, + &version_info_data, + ); + + version_resource + .update() + .with_context(|| "Failed to update version info resource")?; + + Ok(()) +} + +fn embed_svg_icons( + svg_path: &std::path::Path, + resources: &winres_edit::Resources, +) -> crate::Result<()> { + use winres_edit::{Id, Resource, resource_type}; + + let svg_text = std::fs::read_to_string(svg_path) + .with_context(|| format!("Failed to read SVG icon {svg_path:?}"))?; + let svg_parse_options = Options::default(); + let svg_tree = Tree::from_data(svg_text.as_bytes(), &svg_parse_options) + .with_context(|| "Failed to parse SVG icon data")?; + + let mut group_icon = GroupIcon::default(); + + for (size_index, &pixel_size) in ICON_PIXEL_SIZES.iter().enumerate() { + let icon_resource_id = FIRST_INDIVIDUAL_ICON_RESOURCE_ID + size_index as u16; + + let mut pixmap = Pixmap::new(pixel_size, pixel_size) + .with_context(|| format!("Failed to create {pixel_size}×{pixel_size} pixmap"))?; + + let scale_x = pixel_size as f32 / svg_tree.size().width(); + let scale_y = pixel_size as f32 / svg_tree.size().height(); + resvg::render( + &svg_tree, + Transform::from_scale(scale_x, scale_y), + &mut pixmap.as_mut(), + ); + + let icon = Icon::new_from_rgba( + pixel_size, + pixel_size, + icon_resource_id, + pixmap.data().to_vec(), + ); + let encoded_icon = icon + .encode() + .with_context(|| format!("Failed to encode {pixel_size}×{pixel_size} icon"))?; + + group_icon.push_icon( + icon.group_icon_entry() + .with_context(|| "Failed to build group icon entry")?, + ); + + let icon_resource = Resource::new( + resources, + resource_type::ICON.into(), + Id::Integer(icon_resource_id).into(), + LANGUAGE_ID_ENGLISH_US, + &encoded_icon, + ); + + icon_resource + .update() + .with_context(|| format!("Failed to embed {pixel_size}×{pixel_size} icon resource"))?; + } + + let group_icon_data = group_icon + .encode() + .with_context(|| "Failed to encode RT_GROUP_ICON")?; + + let group_icon_resource = Resource::new( + resources, + Id::Integer(RT_GROUP_ICON_RESOURCE_TYPE).into(), + Id::Integer(APPLICATION_ICON_GROUP_RESOURCE_ID).into(), + LANGUAGE_ID_ENGLISH_US, + &group_icon_data, + ); + + group_icon_resource + .update() + .with_context(|| "Failed to embed RT_GROUP_ICON resource")?; + + Ok(()) +} + +fn pad_to_four_byte_alignment(buffer: &mut Vec) { + const FOUR_BYTE_ALIGNMENT: usize = 4; + while !buffer.len().is_multiple_of(FOUR_BYTE_ALIGNMENT) { + buffer.push(0); + } +} + +fn encode_null_terminated_utf16_le(text: &str) -> Vec { + text.encode_utf16() + .chain(std::iter::once(0u16)) + .flat_map(|utf16_code_unit| utf16_code_unit.to_le_bytes()) + .collect() +} + +fn pack_windows_version(major: u64, minor: u64, patch: u64, build: u64) -> u64 { + (major << 48) | (minor << 32) | (patch << 16) | build +} + +fn build_fixed_file_info(file_version: u64, product_version: u64) -> Vec { + /// Magic signature that identifies a VS_FIXEDFILEINFO structure. + const VS_FIXED_FILE_INFO_SIGNATURE: u32 = 0xFEEF_04BD; + + /// VS_FIXEDFILEINFO structure layout version 1.0. + const VS_FIXED_FILE_INFO_STRUCT_VERSION_1_0: u32 = 0x0001_0000; + + const VS_FILE_FLAGS_MASK_ALL: u32 = 0xFFFF_FFFF; + const VS_FILE_FLAGS_NONE: u32 = 0x0000_0000; + + /// Operating system identifier: Windows NT Win32 subsystem. + const VOS_NT_WINDOWS32: u32 = 0x0000_0004; + + /// File type identifier: application executable. + const VFT_APPLICATION: u32 = 0x0000_0001; + + const VFT2_UNKNOWN_SUBTYPE: u32 = 0x0000_0000; + const VS_FILE_DATE_UNUSED: u32 = 0x0000_0000; + + let mut buffer = Vec::new(); + for field_value in [ + VS_FIXED_FILE_INFO_SIGNATURE, + VS_FIXED_FILE_INFO_STRUCT_VERSION_1_0, + (file_version >> 32) as u32, + file_version as u32, + (product_version >> 32) as u32, + product_version as u32, + VS_FILE_FLAGS_MASK_ALL, + VS_FILE_FLAGS_NONE, + VOS_NT_WINDOWS32, + VFT_APPLICATION, + VFT2_UNKNOWN_SUBTYPE, + VS_FILE_DATE_UNUSED, + VS_FILE_DATE_UNUSED, + ] { + buffer.extend_from_slice(&field_value.to_le_bytes()); + } + buffer +} + +fn parse_version(version_string: &str) -> u64 { + let mut version_components = version_string + .splitn(WINDOWS_VERSION_COMPONENT_COUNT, '.') + .map(|component| component.parse::().unwrap_or(0)); + + let major = version_components.next().unwrap_or(0) as u64; + let minor = version_components.next().unwrap_or(0) as u64; + let patch = version_components.next().unwrap_or(0) as u64; + let build = version_components.next().unwrap_or(0) as u64; + + pack_windows_version(major, minor, patch, build) +} + +/// Builds a VS_VERSIONINFO node per the Windows SDK specification: +/// +fn build_version_info_node( + key: &str, + value_bytes: &[u8], + data_type: u16, + children: &[u8], +) -> Vec { + let key_encoded = encode_null_terminated_utf16_le(key); + let header_byte_size = 2 + 2 + 2 + key_encoded.len(); + let total_byte_size = header_byte_size + value_bytes.len() + children.len(); + + let mut buffer = Vec::new(); + buffer.extend_from_slice(&(total_byte_size as u16).to_le_bytes()); + buffer.extend_from_slice(&(value_bytes.len() as u16).to_le_bytes()); + buffer.extend_from_slice(&data_type.to_le_bytes()); + buffer.extend_from_slice(&key_encoded); + + pad_to_four_byte_alignment(&mut buffer); + buffer.extend_from_slice(value_bytes); + + pad_to_four_byte_alignment(&mut buffer); + buffer.extend_from_slice(children); + buffer +} + +fn build_string_entry(key: &str, value: &str) -> Vec { + let key_encoded = encode_null_terminated_utf16_le(key); + let value_encoded = encode_null_terminated_utf16_le(value); + let value_char_count = (value.encode_utf16().count() + 1) as u16; + let node_byte_length = (2 + 2 + 2 + key_encoded.len() + value_encoded.len()) as u16; + + let mut buffer = Vec::new(); + buffer.extend_from_slice(&node_byte_length.to_le_bytes()); + buffer.extend_from_slice(&value_char_count.to_le_bytes()); + buffer.extend_from_slice(&VERSION_NODE_DATA_TYPE_TEXT.to_le_bytes()); + buffer.extend_from_slice(&key_encoded); + + pad_to_four_byte_alignment(&mut buffer); + buffer.extend_from_slice(&value_encoded); + buffer +} + +fn build_string_file_info(pairs: &[(&str, String)]) -> Vec { + let mut string_entries = Vec::new(); + for (key, value) in pairs { + pad_to_four_byte_alignment(&mut string_entries); + string_entries.extend(build_string_entry(key, value)); + } + + let string_table = build_version_info_node( + STRING_TABLE_LOCALE_ENGLISH_US_UNICODE, + &[], + VERSION_NODE_DATA_TYPE_TEXT, + &string_entries, + ); + build_version_info_node( + "StringFileInfo", + &[], + VERSION_NODE_DATA_TYPE_TEXT, + &string_table, + ) +} + +fn build_var_file_info() -> Vec { + let translation_node = build_version_info_node( + "Translation", + &TRANSLATION_ENTRY_ENGLISH_US_UNICODE, + VERSION_NODE_DATA_TYPE_BINARY, + &[], + ); + build_version_info_node( + "VarFileInfo", + &[], + VERSION_NODE_DATA_TYPE_TEXT, + &translation_node, + ) +} + +fn build_version_info_resource( + settings: &Settings, + string_pairs: &[(&str, String)], +) -> crate::Result> { + let version = parse_version(&settings.version_string().to_string()); + let fixed_file_info = build_fixed_file_info(version, version); + let string_file_info = build_string_file_info(string_pairs); + let var_file_info = build_var_file_info(); + + let children = [string_file_info.as_slice(), var_file_info.as_slice()].concat(); + let root_node = build_version_info_node( + "VS_VERSION_INFO", + &fixed_file_info, + VERSION_NODE_DATA_TYPE_BINARY, + &children, + ); + + Ok(root_node) +} diff --git a/src/bundle/windows/group_icon.rs b/src/bundle/windows/group_icon.rs new file mode 100644 index 0000000..3a42a2b --- /dev/null +++ b/src/bundle/windows/group_icon.rs @@ -0,0 +1,62 @@ +use std::io::{self, Cursor, Write}; + +/// Reserved field value required by the GRPICONDIR specification. +const ICON_DIRECTORY_RESERVED: u16 = 0; + +/// Type identifier distinguishing icon resources from cursor resources. +const ICON_DIRECTORY_TYPE_ICON: u16 = 1; + +/// An RT_GROUP_ICON resource that groups together individual RT_ICON entries. +pub struct GroupIcon { + id_reserved: u16, + id_type: u16, + entries: Vec, +} + +pub struct GroupIconEntry { + pub width: u8, + pub height: u8, + pub color_count: u8, + pub reserved: u8, + pub planes: u16, + pub bit_count: u16, + pub bytes_in_resource: u32, + pub id: u16, +} + +impl Default for GroupIcon { + fn default() -> Self { + GroupIcon { + id_reserved: ICON_DIRECTORY_RESERVED, + id_type: ICON_DIRECTORY_TYPE_ICON, + entries: Vec::new(), + } + } +} + +impl GroupIcon { + pub fn push_icon(&mut self, entry: GroupIconEntry) { + self.entries.push(entry); + } + + pub fn encode(&self) -> io::Result> { + let mut buffer = Cursor::new(Vec::new()); + + buffer.write_all(&self.id_reserved.to_le_bytes())?; + buffer.write_all(&self.id_type.to_le_bytes())?; + buffer.write_all(&(self.entries.len() as u16).to_le_bytes())?; + + for entry in &self.entries { + buffer.write_all(&entry.width.to_le_bytes())?; + buffer.write_all(&entry.height.to_le_bytes())?; + buffer.write_all(&entry.color_count.to_le_bytes())?; + buffer.write_all(&entry.reserved.to_le_bytes())?; + buffer.write_all(&entry.planes.to_le_bytes())?; + buffer.write_all(&entry.bit_count.to_le_bytes())?; + buffer.write_all(&entry.bytes_in_resource.to_le_bytes())?; + buffer.write_all(&entry.id.to_le_bytes())?; + } + + Ok(buffer.into_inner()) + } +} diff --git a/src/bundle/windows/icon.rs b/src/bundle/windows/icon.rs new file mode 100644 index 0000000..0e7a3d5 --- /dev/null +++ b/src/bundle/windows/icon.rs @@ -0,0 +1,181 @@ +use std::io::{self, Cursor, Write}; + +use super::group_icon::GroupIconEntry; + +const BITMAP_INFO_HEADER_BYTE_SIZE: u32 = 40; +const DIB_SINGLE_COLOR_PLANE: u16 = 1; +const BITS_PER_BGRA_PIXEL: u16 = 32; +const BYTES_PER_BGRA_PIXEL: u32 = 4; +const BI_RGB_NO_COMPRESSION: u32 = 0; + +/// Bit width of one DWORD, used to compute the AND mask row stride. +const AND_MASK_ROW_ALIGNMENT_BITS: u32 = 32; +const BYTES_PER_DWORD: u32 = 4; + +const UNUSED_PELS_PER_METER: i32 = 0; +const UNUSED_COLOR_TABLE_ENTRIES: u32 = 0; + +/// Offsets into the BITMAPINFOHEADER for the bits-per-pixel field, used in tests. +const BITMAPINFOHEADER_BITS_PER_PIXEL_OFFSET: usize = 14; + +/// A single icon entry stored as a BITMAPINFOHEADER + bottom-up BGRA pixels + +/// AND mask, ready to be embedded as an RT_ICON resource in a PE file. +pub struct Icon { + width: u32, + height: u32, + planes: u16, + bits_per_pixel: u16, + compression: u32, + horizontal_pixels_per_meter: i32, + vertical_pixels_per_meter: i32, + color_table_size: u32, + important_color_count: u32, + image_data: Vec, + pub icon_id: u16, +} + +impl Icon { + pub fn new(width: u32, height: u32, icon_id: u16, image_data: Vec) -> Self { + Icon { + width, + height, + planes: DIB_SINGLE_COLOR_PLANE, + bits_per_pixel: BITS_PER_BGRA_PIXEL, + compression: BI_RGB_NO_COMPRESSION, + horizontal_pixels_per_meter: UNUSED_PELS_PER_METER, + vertical_pixels_per_meter: UNUSED_PELS_PER_METER, + color_table_size: UNUSED_COLOR_TABLE_ENTRIES, + important_color_count: UNUSED_COLOR_TABLE_ENTRIES, + image_data, + icon_id, + } + } + + /// Build an `Icon` from raw RGBA pixel data (top-to-bottom row order). + /// The data is converted to bottom-up BGRA order as required by the DIB + /// format used in PE icon resources. + pub fn new_from_rgba(width: u32, height: u32, icon_id: u16, rgba_pixels: Vec) -> Self { + let mut bgra_data = Vec::with_capacity((width * height * BYTES_PER_BGRA_PIXEL) as usize); + + for row in (0..height).rev() { + for column in 0..width { + let pixel_byte_offset = ((row * width + column) * BYTES_PER_BGRA_PIXEL) as usize; + bgra_data.push(rgba_pixels[pixel_byte_offset + 2]); // B + bgra_data.push(rgba_pixels[pixel_byte_offset + 1]); // G + bgra_data.push(rgba_pixels[pixel_byte_offset]); // R + bgra_data.push(rgba_pixels[pixel_byte_offset + 3]); // A + } + } + + Icon::new(width, height, icon_id, bgra_data) + } + + /// Encode this icon as a DIB (BITMAPINFOHEADER + pixel data + AND mask). + pub fn encode(&self) -> io::Result> { + let mut buffer = Cursor::new(Vec::new()); + + let and_mask_row_stride = + self.width.div_ceil(AND_MASK_ROW_ALIGNMENT_BITS) * BYTES_PER_DWORD; + let and_mask_byte_size = and_mask_row_stride * self.height; + let pixel_data_byte_size = self.width * self.height * BYTES_PER_BGRA_PIXEL; + + // BITMAPINFOHEADER + buffer.write_all(&BITMAP_INFO_HEADER_BYTE_SIZE.to_le_bytes())?; + buffer.write_all(&self.width.to_le_bytes())?; + buffer.write_all(&(self.height * 2).to_le_bytes())?; // doubled: XOR plane + AND plane + buffer.write_all(&self.planes.to_le_bytes())?; + buffer.write_all(&self.bits_per_pixel.to_le_bytes())?; + buffer.write_all(&self.compression.to_le_bytes())?; + buffer.write_all(&(pixel_data_byte_size + and_mask_byte_size).to_le_bytes())?; + buffer.write_all(&self.horizontal_pixels_per_meter.to_le_bytes())?; + buffer.write_all(&self.vertical_pixels_per_meter.to_le_bytes())?; + buffer.write_all(&self.color_table_size.to_le_bytes())?; + buffer.write_all(&self.important_color_count.to_le_bytes())?; + + // XOR (colour) plane + buffer.write_all(&self.image_data)?; + + // AND mask — all zeros because transparency is carried in the alpha channel + buffer.write_all(&vec![0u8; and_mask_byte_size as usize])?; + + Ok(buffer.into_inner()) + } + + /// Return the `GroupIconEntry` that describes this icon in an `RT_GROUP_ICON` resource. + pub fn group_icon_entry(&self) -> io::Result { + Ok(GroupIconEntry { + width: self.width as u8, + height: self.height as u8, + color_count: 0, + reserved: 0, + planes: self.planes, + bit_count: self.bits_per_pixel, + bytes_in_resource: self.encode()?.len() as u32, + id: self.icon_id, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn blank_rgba(size: u32) -> Vec { + vec![0u8; (size * size * BYTES_PER_BGRA_PIXEL) as usize] + } + + #[test] + fn icon_encode_length_is_deterministic() { + let size = 16u32; + let icon = Icon::new_from_rgba(size, size, 1, blank_rgba(size)); + let encoded = icon.encode().unwrap(); + + let and_mask_row_stride = ((size + AND_MASK_ROW_ALIGNMENT_BITS - 1) + / AND_MASK_ROW_ALIGNMENT_BITS) + * BYTES_PER_DWORD; + let expected = BITMAP_INFO_HEADER_BYTE_SIZE + + (size * size * BYTES_PER_BGRA_PIXEL) + + and_mask_row_stride * size; + assert_eq!(encoded.len() as u32, expected); + } + + #[test] + fn icon_encode_header_signature() { + let icon = Icon::new_from_rgba(32, 32, 2, blank_rgba(32)); + let encoded = icon.encode().unwrap(); + + let header_size = u32::from_le_bytes(encoded[0..4].try_into().unwrap()); + assert_eq!(header_size, BITMAP_INFO_HEADER_BYTE_SIZE); + + let bits_per_pixel = u16::from_le_bytes( + encoded[BITMAPINFOHEADER_BITS_PER_PIXEL_OFFSET + ..BITMAPINFOHEADER_BITS_PER_PIXEL_OFFSET + 2] + .try_into() + .unwrap(), + ); + assert_eq!(bits_per_pixel, BITS_PER_BGRA_PIXEL); + } + + #[test] + fn rgba_to_bgra_conversion() { + let opaque_red_rgba = vec![0xFF, 0x00, 0x00, 0xFF]; + let icon = Icon::new_from_rgba(1, 1, 1, opaque_red_rgba); + let encoded = icon.encode().unwrap(); + + let first_pixel_offset = BITMAP_INFO_HEADER_BYTE_SIZE as usize; + assert_eq!(encoded[first_pixel_offset], 0x00); // B + assert_eq!(encoded[first_pixel_offset + 1], 0x00); // G + assert_eq!(encoded[first_pixel_offset + 2], 0xFF); // R + assert_eq!(encoded[first_pixel_offset + 3], 0xFF); // A + } + + #[test] + fn group_icon_entry_reflects_icon_metadata() { + let icon = Icon::new_from_rgba(48, 48, 5, blank_rgba(48)); + let entry = icon.group_icon_entry().unwrap(); + assert_eq!(entry.width, 48); + assert_eq!(entry.height, 48); + assert_eq!(entry.id, 5); + assert_eq!(entry.bit_count, BITS_PER_BGRA_PIXEL); + } +} diff --git a/src/bundle/windows/mod.rs b/src/bundle/windows/mod.rs new file mode 100644 index 0000000..b3b2707 --- /dev/null +++ b/src/bundle/windows/mod.rs @@ -0,0 +1,4 @@ +pub mod exe_bundle; + +mod group_icon; +pub(crate) mod icon;