Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ description = "Wrap rust executables in OS-specific app bundles"
edition = "2024"
readme = "Readme.md"

[package.metadata.bundle.example.goodbye]
name = "Goodbye"
identifier = "io.github.burtonageo.cargo-bundle.goodbye"
icon = ["examples/goodbye/icon32x32.png", "examples/goodbye/icon128x128.png"]
category = "Developer Tool"
short_description = "An example CLI application showing PNG icon bundling"
long_description = """
A simple command-line application that demonstrates bundling a Rust binary
with PNG icons using cargo-bundle.
"""

[package.metadata.bundle.example.hello]
name = "hello"
identifier = "io.github.burtonageo.cargo-bundle.hello"
Expand Down Expand Up @@ -41,6 +52,7 @@ reqwest = { version = "0.13.2", features = [
"native-tls",
], default-features = false }
tempfile = "3.27.0"
resvg = "0.45"
serde = "1.0.228"
serde_derive = "1.0.228"
serde_json = "1.0.149"
Expand All @@ -58,3 +70,7 @@ winit = "0.30.13"
[[example]]
name = "hello"
path = "examples/hello/main.rs"

[[example]]
name = "goodbye"
path = "examples/goodbye/main.rs"
Binary file added examples/goodbye/icon128x128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added examples/goodbye/icon32x32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions examples/goodbye/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
fn main() {
println!("Goodbye, World!");
}
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
86 changes: 84 additions & 2 deletions src/bundle/osx_bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ 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};
use std::io::prelude::*;
use std::io::{self, BufWriter};
use std::io::{self, BufWriter, Cursor};
use std::path::{Path, PathBuf};

pub fn bundle_project(settings: &Settings) -> crate::Result<Vec<PathBuf>> {
Expand Down Expand Up @@ -433,6 +435,20 @@ fn create_icns_file(
return Ok(None);
}

// If one of the icon files is an SVG, convert it via resvg + icns crate.
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?;
Expand Down Expand Up @@ -474,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 };
Expand Down Expand Up @@ -506,6 +522,72 @@ fn create_icns_file(
anyhow::bail!("No usable icon files found.");
}

/// Renders an SVG icon at standard macOS iconset sizes and produces 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 opt = Options::default();
let tree =
Tree::from_data(svg_data.as_bytes(), &opt).with_context(|| "Failed to parse SVG data")?;

let mut family = icns::IconFamily::new();

// 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 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(),
);

let png_data = pixmap
.encode_png()
.with_context(|| format!("Failed to encode {pixel_size}x{pixel_size} pixmap to PNG"))?;

let icon_image = icns::Image::read_png(Cursor::new(&png_data))
.with_context(|| format!("Failed to read PNG for {pixel_size}x{pixel_size} icon"))?;

let icon_type = icns::IconType::from_pixel_size_and_density(pixel_size, pixel_size, scale)
.with_context(|| format!("No ICNS icon type for size {size} and scale {scale}"))?;

family
.add_icon_with_type(&icon_image, icon_type)
.with_context(|| format!("Failed to add {size}x{size}@{scale}x icon to ICNS family"))?;
}

fs::create_dir_all(resources_dir)?;
let icns_file = BufWriter::new(File::create(dest_path)?);
family
.write(icns_file)
.with_context(|| format!("Failed to write ICNS file to {dest_path:?}"))?;

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<()> {
Expand Down
37 changes: 37 additions & 0 deletions tests/common.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
use std::fs;
use std::path::PathBuf;

pub fn project_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}

pub fn cargo_bundle_bin() -> PathBuf {
project_root().join("target/debug/cargo-bundle")
}

#[cfg(test)]
#[allow(dead_code)] // Used in tests
pub fn parse_bundle_paths(stdout: &str) -> Vec<PathBuf> {
stdout
.lines()
.filter(|line| line.starts_with(" "))
.map(|line| PathBuf::from(line.trim()))
.collect()
}

/// Places a valid-enough binary at `target/debug/examples/<name>` so that
/// cargo-bundle (running with CARGO_BUNDLE_SKIP_BUILD) has something to package.
pub fn setup_example_binary(name: &str) {
let bin_dir = project_root().join("target/debug/examples");
fs::create_dir_all(&bin_dir).unwrap();
let bin_path = bin_dir.join(name);
#[cfg(target_os = "macos")]
{
// macOS copies the binary into the .app; use a for realsies Mach-O so the copy runs.
fs::copy(cargo_bundle_bin(), &bin_path).unwrap();
}
#[cfg(not(target_os = "macos"))]
{
fs::write(&bin_path, b"dummy binary content").unwrap();
}
}
78 changes: 78 additions & 0 deletions tests/goodbye.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
mod common;

use std::process::Command;

#[test]
#[cfg(target_os = "macos")]
fn osx() {
use std::fs;
common::setup_example_binary("goodbye");

let root = common::project_root();
let output = Command::new(common::cargo_bundle_bin())
.args(["bundle", "--example", "goodbye", "--format", "osx"])
.current_dir(&root)
.env("CARGO_BUNDLE_SKIP_BUILD", "1")
.output()
.expect("Failed to execute cargo-bundle");

assert!(
output.status.success(),
"cargo-bundle failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);

let app_path = root.join("target/debug/examples/bundle/osx/Goodbye.app");
assert!(app_path.exists(), "App bundle not found at {:?}", app_path);

// PNG icons should have been packed into an ICNS file.
let icns_path = app_path.join("Contents/Resources/Goodbye.icns");
assert!(icns_path.exists(), "ICNS not found at {:?}", icns_path);
assert!(
fs::metadata(&icns_path).unwrap().len() > 0,
"ICNS file is empty"
);
}

#[test]
#[cfg(target_os = "linux")]
fn deb() {
common::setup_example_binary("goodbye");

let root = common::project_root();
let output = Command::new(common::cargo_bundle_bin())
.args(["bundle", "--example", "goodbye", "--format", "deb"])
.current_dir(&root)
.env("CARGO_BUNDLE_SKIP_BUILD", "1")
.output()
.expect("Failed to execute cargo-bundle");

assert!(
output.status.success(),
"cargo-bundle failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);

let bundle_paths = common::parse_bundle_paths(&String::from_utf8_lossy(&output.stdout));
assert_eq!(bundle_paths.len(), 1, "Expected exactly one bundle path");
let deb_path = &bundle_paths[0];
assert!(
deb_path.exists(),
"Debian package not found at {:?}",
deb_path
);

// The 128x128 PNG should have been placed in the hicolor icon tree.
let package_dir = deb_path
.parent()
.unwrap()
.join(deb_path.file_stem().unwrap());
let icon_path = package_dir.join("data/usr/share/icons/hicolor/128x128/apps/goodbye.png");
assert!(
icon_path.exists(),
"PNG icon not found in deb data at {:?}",
icon_path
);
}
Loading
Loading