diff --git a/Cargo.toml b/Cargo.toml index ac4a82f..b67cf9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" @@ -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" @@ -58,3 +70,7 @@ winit = "0.30.13" [[example]] name = "hello" path = "examples/hello/main.rs" + +[[example]] +name = "goodbye" +path = "examples/goodbye/main.rs" diff --git a/examples/goodbye/icon128x128.png b/examples/goodbye/icon128x128.png new file mode 100644 index 0000000..213de63 Binary files /dev/null and b/examples/goodbye/icon128x128.png differ diff --git a/examples/goodbye/icon32x32.png b/examples/goodbye/icon32x32.png new file mode 100644 index 0000000..f170e80 Binary files /dev/null and b/examples/goodbye/icon32x32.png differ diff --git a/examples/goodbye/main.rs b/examples/goodbye/main.rs new file mode 100644 index 0000000..372443b --- /dev/null +++ b/examples/goodbye/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Goodbye, World!"); +} diff --git a/src/bundle/linux/appimage_bundle.rs b/src/bundle/linux/appimage_bundle.rs index ab82491..402d0dc 100644 --- a/src/bundle/linux/appimage_bundle.rs +++ b/src/bundle/linux/appimage_bundle.rs @@ -2,12 +2,16 @@ use anyhow::Context; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use std::{ + ffi::OsStr, fs::File, io::{BufReader, BufWriter, Write}, path::PathBuf, process::Command, }; +use resvg::tiny_skia::{Pixmap, Transform}; +use resvg::usvg::{Options, Tree}; + use crate::bundle::{Settings, common}; use super::common::{generate_desktop_file, generate_icon_files}; @@ -37,9 +41,17 @@ pub fn bundle_project(settings: &Settings) -> crate::Result> { generate_icon_files(settings, &app_dir)?; generate_desktop_file(settings, &app_dir)?; - // TODO Symlinks (AppRun, .DirIcon, .desktop) common::symlink_file(&binary_dest_rel, &app_dir.join("AppRun"))?; + // Symlink the .desktop file to the AppDir root. + let desktop_filename = format!("{}.desktop", settings.binary_name()); + let desktop_rel = PathBuf::from("usr/share/applications").join(&desktop_filename); + common::symlink_file(&desktop_rel, &app_dir.join(&desktop_filename))?; + + // Generate a .DirIcon PNG from the first SVG icon, or symlink the first + // PNG icon so AppImage tools can pick it up. + generate_dir_icon(settings, &app_dir)?; + // Download the AppImage runtime let runtime = fetch_runtime(settings.binary_arch())?; @@ -70,6 +82,47 @@ pub fn bundle_project(settings: &Settings) -> crate::Result> { Ok(vec![package_path]) } +fn generate_dir_icon(settings: &Settings, app_dir: &std::path::Path) -> crate::Result<()> { + for icon_path in settings.icon_files() { + let icon_path = icon_path?; + if icon_path.extension() == Some(OsStr::new("svg")) { + let svg_data = std::fs::read_to_string(&icon_path) + .with_context(|| format!("Failed to read SVG icon {icon_path:?}"))?; + + let opt = Options::default(); + let tree = Tree::from_data(svg_data.as_bytes(), &opt) + .with_context(|| "Failed to parse SVG for .DirIcon")?; + + let size: u32 = 256; + let mut pixmap = + Pixmap::new(size, size).with_context(|| "Failed to create pixmap for .DirIcon")?; + resvg::render( + &tree, + Transform::from_scale( + size as f32 / tree.size().width(), + size as f32 / tree.size().height(), + ), + &mut pixmap.as_mut(), + ); + + pixmap + .save_png(app_dir.join(".DirIcon")) + .with_context(|| "Failed to save .DirIcon PNG")?; + + // Also symlink the SVG into the AppDir root so tools that prefer + // the vector version can find it. + let svg_filename = format!("{}.svg", settings.binary_name()); + let svg_rel = + PathBuf::from("usr/share/icons/hicolor/scalable/apps").join(&svg_filename); + let _ = common::symlink_file(&svg_rel, &app_dir.join(&svg_filename)); + + return Ok(()); + } + } + + Ok(()) +} + fn fetch_runtime(arch: &str) -> crate::Result> { let url = format!( "https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-{arch}" diff --git a/src/bundle/osx_bundle.rs b/src/bundle/osx_bundle.rs index 361bf8c..575aa2c 100644 --- a/src/bundle/osx_bundle.rs +++ b/src/bundle/osx_bundle.rs @@ -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> { @@ -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?; @@ -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 }; @@ -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<()> { diff --git a/tests/common.rs b/tests/common.rs new file mode 100644 index 0000000..d40bd6d --- /dev/null +++ b/tests/common.rs @@ -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 { + stdout + .lines() + .filter(|line| line.starts_with(" ")) + .map(|line| PathBuf::from(line.trim())) + .collect() +} + +/// Places a valid-enough binary at `target/debug/examples/` 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(); + } +} diff --git a/tests/goodbye.rs b/tests/goodbye.rs new file mode 100644 index 0000000..b7fe12f --- /dev/null +++ b/tests/goodbye.rs @@ -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 + ); +} diff --git a/tests/hello.rs b/tests/hello.rs new file mode 100644 index 0000000..be165e6 --- /dev/null +++ b/tests/hello.rs @@ -0,0 +1,77 @@ +mod common; + +use std::process::Command; + +#[test] +#[cfg(target_os = "macos")] +fn osx() { + use std::fs; + common::setup_example_binary("hello"); + + let root = common::project_root(); + let output = Command::new(common::cargo_bundle_bin()) + .args(["bundle", "--example", "hello", "--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/hello.app"); + assert!(app_path.exists(), "App bundle not found at {:?}", app_path); + + // SVG icon should have been converted to ICNS. + let icns_path = app_path.join("Contents/Resources/hello.icns"); + assert!(icns_path.exists(), "ICNS not found at {:?}", icns_path); + assert!( + fs::metadata(&icns_path).unwrap().len() > 0, + "ICNS file is empty" + ); +} + +#[test] +fn deb() { + common::setup_example_binary("hello"); + + let root = common::project_root(); + let output = Command::new(common::cargo_bundle_bin()) + .args(["bundle", "--example", "hello", "--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 + ); + + // SVG should be copied to the scalable hicolor directory. + let package_dir = deb_path + .parent() + .unwrap() + .join(deb_path.file_stem().unwrap()); + let svg_path = package_dir.join("data/usr/share/icons/hicolor/scalable/apps/hello.svg"); + assert!( + svg_path.exists(), + "SVG icon not found in deb data at {:?}", + svg_path + ); +}