Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
d154bce
added resvg dep
philocalyst Mar 26, 2026
48ad9e7
added tempfile to main deps
philocalyst Mar 26, 2026
811dd19
added winres edit
philocalyst Mar 26, 2026
93a774d
Svg dir icon generation
philocalyst Mar 26, 2026
45e3f4d
generate the dir icon (png or svg)
philocalyst Mar 26, 2026
bfc571b
Full symlink support
philocalyst Mar 26, 2026
7661661
windows module documentation
philocalyst Mar 26, 2026
f647ba4
writing localizations on macos
philocalyst Mar 27, 2026
bf07ba3
creating macos icons from an svg source
philocalyst Mar 27, 2026
14eb9ad
simplified the bundle writing process
philocalyst Mar 27, 2026
e0f3d5f
Integrating the svg support into the bundling process
philocalyst Mar 27, 2026
a60e953
Integrating windows and dmg bundle
philocalyst Mar 27, 2026
83c654a
dmg_bundle header text
philocalyst Mar 27, 2026
0f1db68
helper functions
philocalyst Mar 27, 2026
8ace82a
DMG BUNDLING PIPELINE
philocalyst Mar 27, 2026
e954c0e
osx localizaiton helper func
philocalyst Mar 27, 2026
899dea4
added new supported formats
philocalyst Mar 27, 2026
424e856
constants
philocalyst Mar 27, 2026
7ba341b
build var info function
philocalyst Mar 27, 2026
0298e86
build string file info function
philocalyst Mar 27, 2026
2df6521
function for building version ifno
philocalyst Mar 27, 2026
a67d2b5
fixed file info support
philocalyst Mar 27, 2026
a09e803
helper functions
philocalyst Mar 27, 2026
fba9d2e
parsing n' packing
philocalyst Mar 27, 2026
853b157
full version info support
philocalyst Mar 27, 2026
60fd14e
WINDOWS BUNDLING
philocalyst Mar 27, 2026
80eb195
Group icon type
philocalyst Mar 27, 2026
1d5e84e
windows icon
philocalyst Mar 27, 2026
88c356d
windows icon tests
philocalyst Mar 27, 2026
d6f8d91
testing dmgs and exe round trips
philocalyst Mar 27, 2026
ee2be8b
linking
philocalyst Mar 27, 2026
726ed82
clippy fixes
philocalyst Mar 27, 2026
5fd871a
Formatting issues after adding comments
philocalyst Apr 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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]]
Expand Down
166 changes: 166 additions & 0 deletions src/bundle/dmg_bundle.rs
Original file line number Diff line number Diff line change
@@ -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:
//
// <AppName>.dmg (read-only compressed UDZO image)
// <AppName>.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<Vec<PathBuf>> {
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<u64> {
let mut total: u64 = 0;
for entry in walkdir::WalkDir::new(dir) {
let entry = entry?;
if entry.file_type().is_file() {
total += entry.metadata()?.len();
}
}
Ok(total)
}

/// Extract the mount point path from `hdiutil attach` stdout.
fn parse_mount_point(stdout: &[u8]) -> crate::Result<PathBuf> {
parse_mount_point_impl(stdout)
}

fn parse_mount_point_impl(stdout: &[u8]) -> crate::Result<PathBuf> {
let text = std::str::from_utf8(stdout)?;
// hdiutil attach prints a tab-separated line whose last field is the mount
// point, 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")
}
55 changes: 54 additions & 1 deletion src/bundle/linux/appimage_bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -37,9 +41,17 @@ pub fn bundle_project(settings: &Settings) -> crate::Result<Vec<PathBuf>> {
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())?;

Expand Down Expand Up @@ -70,6 +82,47 @@ pub fn bundle_project(settings: &Settings) -> crate::Result<Vec<PathBuf>> {
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<Vec<u8>> {
let url = format!(
"https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-{arch}"
Expand Down
4 changes: 4 additions & 0 deletions src/bundle/mod.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -18,9 +20,11 @@ pub fn bundle_project(settings: Settings) -> crate::Result<Vec<PathBuf>> {
for package_type in settings.package_types()? {
paths.append(&mut match package_type {
PackageType::OsxBundle => osx_bundle::bundle_project(&settings)?,
PackageType::OsxDmg => dmg_bundle::bundle_project(&settings)?,
PackageType::IosBundle => ios_bundle::bundle_project(&settings)?,
PackageType::WindowsMsi => msi_bundle::bundle_project(&settings)?,
PackageType::WxsMsi => wxsmsi_bundle::bundle_project(&settings)?,
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)?,
Expand Down
Loading
Loading