From d154bce696310d3f6a5d819a0d79de508be1a476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 19:22:06 -0400 Subject: [PATCH 01/33] added resvg dep --- Cargo.toml | 1 + src/bundle/dmg_bundle.rs | 167 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 src/bundle/dmg_bundle.rs diff --git a/Cargo.toml b/Cargo.toml index 2c24aed..d8577e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ reqwest = { version = "0.13.2", features = [ "blocking", "native-tls", ], default-features = false } +resvg = "0.45" serde = "1.0.228" serde_derive = "1.0.228" serde_json = "1.0.149" diff --git a/src/bundle/dmg_bundle.rs b/src/bundle/dmg_bundle.rs new file mode 100644 index 0000000..7437a6e --- /dev/null +++ b/src/bundle/dmg_bundle.rs @@ -0,0 +1,167 @@ +// 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::bundle::osx_bundle; +use crate::Settings; +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) +} + +/// Public re-export used only by tests in sibling modules. +#[cfg(test)] +pub fn parse_mount_point_pub(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, 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") +} From 48ad9e7b62e43f08887edc65ddf8e9e84f7bad4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 19:22:15 -0400 Subject: [PATCH 02/33] added tempfile to main deps --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index d8577e7..4a73ba0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ reqwest = { version = "0.13.2", features = [ "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" @@ -54,7 +55,6 @@ uuid = { version = "1.22.0", features = ["v5"] } walkdir = "2.5.0" [dev-dependencies] -tempfile = "3.27.0" winit = "0.30.13" [[example]] From 811dd19abac5e02a9e31f3bea048f4b1d3592870 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 19:22:28 -0400 Subject: [PATCH 03/33] added winres edit --- Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 4a73ba0..2c21842 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,9 @@ toml = "0.9.8" uuid = { version = "1.22.0", features = ["v5"] } walkdir = "2.5.0" +[target.'cfg(target_os = "windows")'.dependencies] +winres-edit = "0.2.0" + [dev-dependencies] winit = "0.30.13" From 93a774ddfbdc862546c217384e89a4bcd971812f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 19:32:19 -0400 Subject: [PATCH 04/33] Svg dir icon generation --- src/bundle/linux/appimage_bundle.rs | 44 +++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/bundle/linux/appimage_bundle.rs b/src/bundle/linux/appimage_bundle.rs index ab82491..e8a8989 100644 --- a/src/bundle/linux/appimage_bundle.rs +++ b/src/bundle/linux/appimage_bundle.rs @@ -8,6 +8,9 @@ use std::{ 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}; @@ -70,6 +73,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}" From 45e3f4d424beb8f65e4d379e6f072e990763b4fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 19:33:34 -0400 Subject: [PATCH 05/33] generate the dir icon (png or svg) --- src/bundle/linux/appimage_bundle.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/bundle/linux/appimage_bundle.rs b/src/bundle/linux/appimage_bundle.rs index e8a8989..aec2aa0 100644 --- a/src/bundle/linux/appimage_bundle.rs +++ b/src/bundle/linux/appimage_bundle.rs @@ -2,6 +2,7 @@ use anyhow::Context; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use std::{ + ffi::OsStr, fs::File, io::{BufReader, BufWriter, Write}, path::PathBuf, @@ -40,9 +41,12 @@ 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"))?; + // 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())?; From bfc571b8d4139bd70ccdf7de719fca871a51e9af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 19:34:21 -0400 Subject: [PATCH 06/33] Full symlink support Fixes the todo --- src/bundle/linux/appimage_bundle.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/bundle/linux/appimage_bundle.rs b/src/bundle/linux/appimage_bundle.rs index aec2aa0..402d0dc 100644 --- a/src/bundle/linux/appimage_bundle.rs +++ b/src/bundle/linux/appimage_bundle.rs @@ -43,6 +43,11 @@ pub fn bundle_project(settings: &Settings) -> crate::Result> { 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)?; From 7661661242a4fde592e94d9dd17f3e6c0b60ec97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 19:36:12 -0400 Subject: [PATCH 07/33] windows module documentation --- src/bundle/windows/exe_bundle.rs | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/bundle/windows/exe_bundle.rs diff --git a/src/bundle/windows/exe_bundle.rs b/src/bundle/windows/exe_bundle.rs new file mode 100644 index 0000000..13532b8 --- /dev/null +++ b/src/bundle/windows/exe_bundle.rs @@ -0,0 +1,8 @@ +// A Windows application bundle copies the compiled executable and embeds +// version metadata and a multi-resolution icon directly into the PE binary +// using the Windows resource section. +// +// Resource embedding requires the `winres-edit` crate which only compiles on +// Windows, so the embedding step is unconditionally skipped on other platforms. +// Producing the output executable (a plain copy) works everywhere. + From f647ba4a6910d42afa4f3cd7808605d6ccab06dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 20:40:33 -0400 Subject: [PATCH 08/33] writing localizations on macos --- src/bundle/osx_bundle.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/bundle/osx_bundle.rs b/src/bundle/osx_bundle.rs index 979c646..a21e94f 100644 --- a/src/bundle/osx_bundle.rs +++ b/src/bundle/osx_bundle.rs @@ -500,6 +500,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() { From bf07ba3069ed4df1d88cc27b930f8a9d90e65d38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 20:43:10 -0400 Subject: [PATCH 09/33] creating macos icons from an svg source --- Cargo.toml | 2 -- src/bundle/osx_bundle.rs | 75 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2c21842..9bf73a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,8 +53,6 @@ term = "1.2.1" toml = "0.9.8" uuid = { version = "1.22.0", features = ["v5"] } walkdir = "2.5.0" - -[target.'cfg(target_os = "windows")'.dependencies] winres-edit = "0.2.0" [dev-dependencies] diff --git a/src/bundle/osx_bundle.rs b/src/bundle/osx_bundle.rs index a21e94f..5564dff 100644 --- a/src/bundle/osx_bundle.rs +++ b/src/bundle/osx_bundle.rs @@ -500,6 +500,81 @@ 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<()> { From 14eb9adc4a76bf0da339bfe447522de1b3a16520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 20:43:51 -0400 Subject: [PATCH 10/33] simplified the bundle writing process to handle both it and dmg --- src/bundle/osx_bundle.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/bundle/osx_bundle.rs b/src/bundle/osx_bundle.rs index 5564dff..a149d97 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)] From e0f3d5f7e41c20074a3e66510df24f4adc517c8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 20:44:35 -0400 Subject: [PATCH 11/33] Integrating the svg support into the bundling process --- src/bundle/osx_bundle.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/bundle/osx_bundle.rs b/src/bundle/osx_bundle.rs index a149d97..cc72ed0 100644 --- a/src/bundle/osx_bundle.rs +++ b/src/bundle/osx_bundle.rs @@ -433,6 +433,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?; @@ -474,7 +488,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 }; From a60e9537cf1cba3f93a330fdeb26bf0d27dde359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 20:44:53 -0400 Subject: [PATCH 12/33] Integrating windows and dmg bundle --- src/bundle/dmg_bundle.rs | 167 --------------------------------------- src/bundle/mod.rs | 4 + 2 files changed, 4 insertions(+), 167 deletions(-) delete mode 100644 src/bundle/dmg_bundle.rs diff --git a/src/bundle/dmg_bundle.rs b/src/bundle/dmg_bundle.rs deleted file mode 100644 index 7437a6e..0000000 --- a/src/bundle/dmg_bundle.rs +++ /dev/null @@ -1,167 +0,0 @@ -// 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::bundle::osx_bundle; -use crate::Settings; -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) -} - -/// Public re-export used only by tests in sibling modules. -#[cfg(test)] -pub fn parse_mount_point_pub(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, 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..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)?, From 83c654a59289d639c4aec197a55c6e8b458ee33c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 20:47:26 -0400 Subject: [PATCH 13/33] dmg_bundle header text --- src/bundle/dmg_bundle.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/bundle/dmg_bundle.rs diff --git a/src/bundle/dmg_bundle.rs b/src/bundle/dmg_bundle.rs new file mode 100644 index 0000000..3f9c077 --- /dev/null +++ b/src/bundle/dmg_bundle.rs @@ -0,0 +1,13 @@ +// 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. + From 0f1db684248680888c144915aff71e0b39a9eb1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 20:48:27 -0400 Subject: [PATCH 14/33] helper functions --- src/bundle/dmg_bundle.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/bundle/dmg_bundle.rs b/src/bundle/dmg_bundle.rs index 3f9c077..d022cfe 100644 --- a/src/bundle/dmg_bundle.rs +++ b/src/bundle/dmg_bundle.rs @@ -11,3 +11,20 @@ // Building requires macOS because the `hdiutil` command is used to create and // convert the disk image. +/// 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) +} + From 8ace82a33609798cd1ba55d330da48c8896dde36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 20:49:32 -0400 Subject: [PATCH 15/33] DMG BUNDLING PIPELINE --- src/bundle/dmg_bundle.rs | 142 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/src/bundle/dmg_bundle.rs b/src/bundle/dmg_bundle.rs index d022cfe..4bfa30a 100644 --- a/src/bundle/dmg_bundle.rs +++ b/src/bundle/dmg_bundle.rs @@ -11,6 +11,127 @@ // 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; @@ -28,3 +149,24 @@ fn parse_mount_point(stdout: &[u8]) -> crate::Result { parse_mount_point_impl(stdout) } +/// Public re-export used only by tests in sibling modules. +#[cfg(test)] +pub fn parse_mount_point_pub(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, 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") +} From e954c0ec44b704ededdaff2c21711a8bd34a505c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 20:50:09 -0400 Subject: [PATCH 16/33] osx localizaiton helper func --- src/bundle/settings.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/bundle/settings.rs b/src/bundle/settings.rs index c838256..faa16d0 100644 --- a/src/bundle/settings.rs +++ b/src/bundle/settings.rs @@ -561,6 +561,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( From 899dea44d1ae1ec51809490a9af251e35a87a6de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 20:50:22 -0400 Subject: [PATCH 17/33] added new supported formats --- src/bundle/settings.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/bundle/settings.rs b/src/bundle/settings.rs index faa16d0..72f0b51 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), } } From 424e856ddee3e8057f76acd38baa3c1d0f98cf18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 20:53:47 -0400 Subject: [PATCH 18/33] constants --- src/bundle/windows/exe_bundle.rs | 46 +++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/src/bundle/windows/exe_bundle.rs b/src/bundle/windows/exe_bundle.rs index 13532b8..4604b01 100644 --- a/src/bundle/windows/exe_bundle.rs +++ b/src/bundle/windows/exe_bundle.rs @@ -1,8 +1,40 @@ -// A Windows application bundle copies the compiled executable and embeds -// version metadata and a multi-resolution icon directly into the PE binary -// using the Windows resource section. -// -// Resource embedding requires the `winres-edit` crate which only compiles on -// Windows, so the embedding step is unconditionally skipped on other platforms. -// Producing the output executable (a plain copy) works everywhere. +// 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; From 7ba341b68ca908128da1d6b9c399712e54c3ac9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 20:54:10 -0400 Subject: [PATCH 19/33] build var info function --- src/bundle/windows/exe_bundle.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/bundle/windows/exe_bundle.rs b/src/bundle/windows/exe_bundle.rs index 4604b01..42db956 100644 --- a/src/bundle/windows/exe_bundle.rs +++ b/src/bundle/windows/exe_bundle.rs @@ -38,3 +38,17 @@ const TRANSLATION_ENTRY_ENGLISH_US_UNICODE: [u8; 4] = [0x09, 0x04, 0xB0, 0x04]; const WINDOWS_VERSION_COMPONENT_COUNT: usize = 4; +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, + ) +} From 0298e86e8256b6f4e24e644c3d9adfc249c79f68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 20:54:25 -0400 Subject: [PATCH 20/33] build string file info function --- src/bundle/windows/exe_bundle.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/bundle/windows/exe_bundle.rs b/src/bundle/windows/exe_bundle.rs index 42db956..8e6f9f8 100644 --- a/src/bundle/windows/exe_bundle.rs +++ b/src/bundle/windows/exe_bundle.rs @@ -38,6 +38,27 @@ const TRANSLATION_ENTRY_ENGLISH_US_UNICODE: [u8; 4] = [0x09, 0x04, 0xB0, 0x04]; const WINDOWS_VERSION_COMPONENT_COUNT: usize = 4; +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", From 2df65216e08f77ae8a96e1175c8e5d45a3606f22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 20:54:49 -0400 Subject: [PATCH 21/33] function for building version ifno --- src/bundle/windows/exe_bundle.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/bundle/windows/exe_bundle.rs b/src/bundle/windows/exe_bundle.rs index 8e6f9f8..b366f32 100644 --- a/src/bundle/windows/exe_bundle.rs +++ b/src/bundle/windows/exe_bundle.rs @@ -38,6 +38,32 @@ const TRANSLATION_ENTRY_ENGLISH_US_UNICODE: [u8; 4] = [0x09, 0x04, 0xB0, 0x04]; const WINDOWS_VERSION_COMPONENT_COUNT: usize = 4; +/// 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_file_info(pairs: &[(&str, String)]) -> Vec { let mut string_entries = Vec::new(); for (key, value) in pairs { From a67d2b5438a41b78d1a15468bc2c15b179559434 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 20:55:05 -0400 Subject: [PATCH 22/33] fixed file info support --- src/bundle/windows/exe_bundle.rs | 39 ++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/bundle/windows/exe_bundle.rs b/src/bundle/windows/exe_bundle.rs index b366f32..b456ee6 100644 --- a/src/bundle/windows/exe_bundle.rs +++ b/src/bundle/windows/exe_bundle.rs @@ -38,6 +38,45 @@ const TRANSLATION_ENTRY_ENGLISH_US_UNICODE: [u8; 4] = [0x09, 0x04, 0xB0, 0x04]; const WINDOWS_VERSION_COMPONENT_COUNT: usize = 4; +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 +} /// Builds a VS_VERSIONINFO node per the Windows SDK specification: /// fn build_version_info_node( From a09e803cd18a500ca7e5ac62df02b078b970e5c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 20:55:30 -0400 Subject: [PATCH 23/33] helper functions --- src/bundle/windows/exe_bundle.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/bundle/windows/exe_bundle.rs b/src/bundle/windows/exe_bundle.rs index b456ee6..85b5bce 100644 --- a/src/bundle/windows/exe_bundle.rs +++ b/src/bundle/windows/exe_bundle.rs @@ -38,6 +38,24 @@ const TRANSLATION_ENTRY_ENGLISH_US_UNICODE: [u8; 4] = [0x09, 0x04, 0xB0, 0x04]; const WINDOWS_VERSION_COMPONENT_COUNT: usize = 4; +fn pad_to_four_byte_alignment(buffer: &mut Vec) { + const FOUR_BYTE_ALIGNMENT: usize = 4; + while buffer.len() % FOUR_BYTE_ALIGNMENT != 0 { + 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; From fba9d2ed696e5396baa00b37be937d37d507eed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 20:55:55 -0400 Subject: [PATCH 24/33] parsing n' packing --- src/bundle/windows/exe_bundle.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/bundle/windows/exe_bundle.rs b/src/bundle/windows/exe_bundle.rs index 85b5bce..9292d1f 100644 --- a/src/bundle/windows/exe_bundle.rs +++ b/src/bundle/windows/exe_bundle.rs @@ -95,6 +95,19 @@ fn build_fixed_file_info(file_version: u64, product_version: u64) -> Vec { } 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( From 853b157e824b73cd8ad66beb767ca5a588a9ea65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 20:56:12 -0400 Subject: [PATCH 25/33] full version info support --- src/bundle/windows/exe_bundle.rs | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/bundle/windows/exe_bundle.rs b/src/bundle/windows/exe_bundle.rs index 9292d1f..c022579 100644 --- a/src/bundle/windows/exe_bundle.rs +++ b/src/bundle/windows/exe_bundle.rs @@ -134,6 +134,23 @@ fn build_version_info_node( 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 { @@ -169,3 +186,23 @@ fn build_var_file_info() -> Vec { &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) +} From 60fd14e7663d2b5887807558429cbaf47cdc7829 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 20:56:17 -0400 Subject: [PATCH 26/33] WINDOWS BUNDLING --- src/bundle/windows/exe_bundle.rs | 187 +++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) diff --git a/src/bundle/windows/exe_bundle.rs b/src/bundle/windows/exe_bundle.rs index c022579..e725450 100644 --- a/src/bundle/windows/exe_bundle.rs +++ b/src/bundle/windows/exe_bundle.rs @@ -38,6 +38,192 @@ 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() % FOUR_BYTE_ALIGNMENT != 0 { @@ -95,6 +281,7 @@ fn build_fixed_file_info(file_version: u64, product_version: u64) -> Vec { } buffer } + fn parse_version(version_string: &str) -> u64 { let mut version_components = version_string .splitn(WINDOWS_VERSION_COMPONENT_COUNT, '.') From 80eb1954993a33bcf26396b13cf2a3818bc7fefb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 20:56:28 -0400 Subject: [PATCH 27/33] Group icon type --- src/bundle/windows/group_icon.rs | 62 ++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/bundle/windows/group_icon.rs 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()) + } +} From 1d5e84ef628becf42be9d2caa3025c1b2a21465c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 20:56:58 -0400 Subject: [PATCH 28/33] windows icon --- src/bundle/windows/icon.rs | 119 +++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 src/bundle/windows/icon.rs diff --git a/src/bundle/windows/icon.rs b/src/bundle/windows/icon.rs new file mode 100644 index 0000000..d6febe6 --- /dev/null +++ b/src/bundle/windows/icon.rs @@ -0,0 +1,119 @@ +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 + AND_MASK_ROW_ALIGNMENT_BITS - 1) + / 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, + }) + } +} + From 88c356dbcbc1d0990e786b192fb70ab8c0e17da2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 20:57:01 -0400 Subject: [PATCH 29/33] windows icon tests --- src/bundle/windows/icon.rs | 63 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/bundle/windows/icon.rs b/src/bundle/windows/icon.rs index d6febe6..5bff15b 100644 --- a/src/bundle/windows/icon.rs +++ b/src/bundle/windows/icon.rs @@ -117,3 +117,66 @@ impl Icon { } } +#[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); + } +} From d6f8d91ea962e5ef01666a50de426fb60f43e3fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 20:57:59 -0400 Subject: [PATCH 30/33] testing dmgs and exe round trips --- src/bundle/settings.rs | 44 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/bundle/settings.rs b/src/bundle/settings.rs index 72f0b51..455980e 100644 --- a/src/bundle/settings.rs +++ b/src/bundle/settings.rs @@ -728,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"); + } } From ee2be8b0c3b1db20aa9fb3aa5e3a1285b8589da5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 20:58:14 -0400 Subject: [PATCH 31/33] linking --- src/bundle/osx_bundle.rs | 2 ++ src/bundle/windows/mod.rs | 4 ++++ 2 files changed, 6 insertions(+) create mode 100644 src/bundle/windows/mod.rs diff --git a/src/bundle/osx_bundle.rs b/src/bundle/osx_bundle.rs index cc72ed0..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}; 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; From 726ed82ea9d6db8d0557e8521c2c84e1f33a1254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 26 Mar 2026 21:00:40 -0400 Subject: [PATCH 32/33] clippy fixes --- src/bundle/dmg_bundle.rs | 10 ++-------- src/bundle/windows/exe_bundle.rs | 2 +- src/bundle/windows/icon.rs | 3 +-- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/bundle/dmg_bundle.rs b/src/bundle/dmg_bundle.rs index 4bfa30a..55a3c2b 100644 --- a/src/bundle/dmg_bundle.rs +++ b/src/bundle/dmg_bundle.rs @@ -6,7 +6,7 @@ // // .dmg (read-only compressed UDZO image) // .app # the application bundle -// Applications -> /Applications # drag-and-drop install target +// Applications TO /Applications // // Building requires macOS because the `hdiutil` command is used to create and // convert the disk image. @@ -149,16 +149,10 @@ fn parse_mount_point(stdout: &[u8]) -> crate::Result { parse_mount_point_impl(stdout) } -/// Public re-export used only by tests in sibling modules. -#[cfg(test)] -pub fn parse_mount_point_pub(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, e.g.: /dev/disk2s1 Apple_HFS /Volumes/MyApp + // 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() { diff --git a/src/bundle/windows/exe_bundle.rs b/src/bundle/windows/exe_bundle.rs index e725450..7346a58 100644 --- a/src/bundle/windows/exe_bundle.rs +++ b/src/bundle/windows/exe_bundle.rs @@ -226,7 +226,7 @@ fn embed_svg_icons( fn pad_to_four_byte_alignment(buffer: &mut Vec) { const FOUR_BYTE_ALIGNMENT: usize = 4; - while buffer.len() % FOUR_BYTE_ALIGNMENT != 0 { + while !buffer.len().is_multiple_of(FOUR_BYTE_ALIGNMENT) { buffer.push(0); } } diff --git a/src/bundle/windows/icon.rs b/src/bundle/windows/icon.rs index 5bff15b..436a16e 100644 --- a/src/bundle/windows/icon.rs +++ b/src/bundle/windows/icon.rs @@ -74,8 +74,7 @@ impl Icon { pub fn encode(&self) -> io::Result> { let mut buffer = Cursor::new(Vec::new()); - let and_mask_row_stride = ((self.width + AND_MASK_ROW_ALIGNMENT_BITS - 1) - / AND_MASK_ROW_ALIGNMENT_BITS) + 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; From 5fd871a1a33dde4f0fd394de2238cea7410346ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Mon, 20 Apr 2026 22:48:38 -0400 Subject: [PATCH 33/33] Formatting issues after adding comments --- src/bundle/windows/icon.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/bundle/windows/icon.rs b/src/bundle/windows/icon.rs index 436a16e..0e7a3d5 100644 --- a/src/bundle/windows/icon.rs +++ b/src/bundle/windows/icon.rs @@ -62,7 +62,7 @@ impl Icon { 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]); // R bgra_data.push(rgba_pixels[pixel_byte_offset + 3]); // A } } @@ -74,8 +74,8 @@ impl Icon { 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_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; @@ -130,9 +130,9 @@ mod tests { 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 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; @@ -163,7 +163,7 @@ mod tests { 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], 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