Developing macOS Applications in Rust
For training AI models, I use an Ubuntu server with 16 cores alongside a MacBook Pro M4 Max. During training, I frequently monitor CPU and memory usage to ensure all cores are utilized efficiently and to estimate memory consumption. However, I missed having on macOS a system monitor that provides a visualization similar to what I’m used to on Ubuntu. So, I built one — a simple System Monitor application that displays CPU and memory usage.
Although it’s easy to develop such a Rust-based application, I wanted to go a step further and create a fully native macOS app, complete with an icon, bundle metadata, and Launchpad integration. Fortunately, this only takes a few additional steps once your Rust application is working.
Step 1: Install cargo-bundle cargo install cargo-bundle This tool packages your Rust application into a proper .app bundle for macOS.
Step 2: Prepare an App Icon Create or download a 1024×1024 icon. In my case, I generated one using ChatGPT. Save it as
Step 3: Add Bundle Metadata to Cargo.toml [package.metadata.bundle] name = "SysMonitor" identifier = "xx.yyy.sysmonitor" icon = ["assets/SysMonitor.icns"]
- resources = ["assets/"]
Here you define the app name, bundle identifier, and icon set. Optional: you can later add a resources section for assets.
Step 4: Generate the .icns Icon Set Use the following shell script (make_icns.sh) to convert your 1024×1024 PNG into an Apple .icns bundle.
- !/usr/bin/env bash
set -euo pipefail
- make_icns.sh
- Usage: ./make_icns.sh <input-image> <output-dir>
- Example: ./make_icns.sh SysMonitor_1024.png ./assets
if $# -ne 2 ; then
echo "Usage: $0 <input-image> <output-dir>" exit 1
fi
INPUT="$1" OUTDIR="$2"
- ---- Preflight checks -------------------------------------------------------
if ! -f "$INPUT" ; then
echo "Error: input file not found: $INPUT" >&2 exit 1
fi
if ! command -v sips >/dev/null 2>&1; then
echo "Error: 'sips' not found (macOS only). Install Xcode command line tools." >&2 exit 1
fi
if ! command -v iconutil >/dev/null 2>&1; then
echo "Error: 'iconutil' not found. Install Xcode or its command line tools." >&2 exit 1
fi
mkdir -p "$OUTDIR"
- Derive names
BASENAME="$(basename "$INPUT")" STEM="${BASENAME%.*}" ICONSET_DIR="$OUTDIR/${STEM}.iconset" ICNS_PATH="$OUTDIR/$STEM.icns"
- ---- Validate input dimensions ---------------------------------------------
- We want at least 1024x1024 for best results.
WIDTH=$(sips -g pixelWidth "$INPUT" 2>/dev/null | awk '/pixelWidth:/ {print $2}') HEIGHT=$(sips -g pixelHeight "$INPUT" 2>/dev/null | awk '/pixelHeight:/ {print $2}')
if [[ -z "${WIDTH:-}" || -z "${HEIGHT:-}" ]]; then
echo "Error: couldn't read image dimensions from: $INPUT" >&2 exit 1
fi
if "$WIDTH" -ne "$HEIGHT" ; then
echo "Error: input image must be square. Got ${WIDTH}x${HEIGHT}." >&2
exit 1
fi
if "$WIDTH" -lt 1024 ; then
echo "Error: input image must be at least 1024x1024. Got ${WIDTH}x${HEIGHT}." >&2
exit 1
fi
- Create a 1024x1024 working copy (downscale if larger, keep quality)
TMPDIR="$(mktemp -d)" trap 'rm -rf "$TMPDIR"' EXIT WORK_1024="$TMPDIR/icon_1024.png" sips -s format png -z 1024 1024 "$INPUT" --out "$WORK_1024" >/dev/null
- ---- Build iconset ----------------------------------------------------------
rm -rf "$ICONSET_DIR" mkdir -p "$ICONSET_DIR"
sips -z 16 16 "$WORK_1024" --out "$ICONSET_DIR/icon_16x16.png" >/dev/null sips -z 32 32 "$WORK_1024" --out "$ICONSET_DIR/[email protected]" >/dev/null sips -z 32 32 "$WORK_1024" --out "$ICONSET_DIR/icon_32x32.png" >/dev/null sips -z 64 64 "$WORK_1024" --out "$ICONSET_DIR/[email protected]" >/dev/null sips -z 128 128 "$WORK_1024" --out "$ICONSET_DIR/icon_128x128.png" >/dev/null sips -z 256 256 "$WORK_1024" --out "$ICONSET_DIR/[email protected]" >/dev/null sips -z 256 256 "$WORK_1024" --out "$ICONSET_DIR/icon_256x256.png" >/dev/null sips -z 512 512 "$WORK_1024" --out "$ICONSET_DIR/[email protected]" >/dev/null sips -z 512 512 "$WORK_1024" --out "$ICONSET_DIR/icon_512x512.png" >/dev/null cp "$WORK_1024" "$ICONSET_DIR/[email protected]" # 1024x1024
- ---- Create .icns -----------------------------------------------------------
iconutil -c icns "$ICONSET_DIR" -o "$ICNS_PATH"
echo "Iconset: $ICONSET_DIR" echo "ICNS: $ICNS_PATH" echo
Then, generate the icon set: ./icons/mkicon.sh ./icons/SysMonitor.png ./assets
Step 5: Build the App Bundle cargo bundle --release APP=./target/release/bundle/osx/SysMonitor.app
Step 6: Test the Application open $APP
Step 7: Troubleshooting
If you run into issues, here are some quick diagnostics:
- Quick way to edit/check the plist
/usr/libexec/PlistBuddy -c "Print" $APP/Contents/Info.plist
- Check that the .icns is inside the bundle
ls -l $APP/Contents/Resources
- Verify Info.plist points to the icon
/usr/libexec/PlistBuddy -c "Print :CFBundleIconFile" $APP/Contents/Info.plist
- If it prints AppIcon.icns, it's good.
If needed, extract the .icns to inspect it:
- Validate the .icns itself (most common cause)
mkdir -p tmp/SM.iconset iconutil -c iconset $APP/Contents/Resources/SysMonitor.icns -o tmp/SM.iconset ls -1 tmp/SM.iconset
Step 8: Install the App You can copy the app bundle to either your system or user Applications folder: cp -R target/release/bundle/osx/SysMonitor.app /Applications/
- or
cp -R target/release/bundle/osx/SysMonitor.app ~/Applications/
Now it appears in Launchpad, Spotlight search, and you can pin it to the Dock.
Step 9: Adding Resources If your app uses additional resources (icons, images, fonts, configuration files, etc.), add them in Cargo.toml: [package.metadata.bundle] ... resources = ["assets", "images/**/*.png", "data/config_defaults.json"]
These files will be copied into your bundle at SysMonitor.app/Contents/Resources/...
Typical examples include:
- Images, icons, cursors
- Fonts (for custom UI frameworks like Iced)
- Shaders, CSV/JSON, templates, localization files
- Default configuration files
- LICENSE, README, etc.
All resources are read-only inside the bundle. Accessing Resources at Runtime (Rust) When running from the bundle, resources live under .../Contents/Resources. When running with cargo run, they’re in your project directory.
A small helper function can make this transparent: use std::{
env,
path::{Path, PathBuf},
};
- [allow(dead_code)]
fn resource_path(rel: impl AsRef<Path>) -> PathBuf {
// Path to the running executable
let exe = env::current_exe().unwrap_or_else(|_| PathBuf::from("."));
// If we’re inside My.app/Contents/MacOS, hop to Contents/Resources
if let Some(macos_dir) = exe.parent() {
if macos_dir.ends_with("MacOS") {
if let Some(contents_dir) = macos_dir.parent() {
let res = contents_dir.join("Resources");
return res.join(rel.as_ref());
}
}
}
// Fallback for `cargo run`: use repo-relative path
PathBuf::from(rel.as_ref())
}
Usage example: let icon_path = resource_path("assets/SysMonitor.icns"); let out=format!("IconPath: {:?}", icon_path); let _ =std::fs::write("sys_monitor/output.txt", out);
When you run the bundle: open $APP
- in output.txt:
- IconPath: "xxxxx/target/release/bundle/osx/SysMonitor.app/Contents/Resources/assets/SysMonitor.icns"
And when you run via Cargo: cargo run --release
- in output.txt:
- IconPath: "assets/SysMonitor.icns"
Notes on Writable Files Only include read-only assets inside the .app bundle. For writable files like logs or user settings, use a per-user directory such as: ~/Library/Application Support/SysMonitor/ You can conveniently locate this path via the directories crate.
Summary Building a native macOS app in Rust is straightforward with cargo-bundle. Once your application logic is ready, adding a proper icon, bundle metadata, and resource management takes just a few steps — resulting in a polished, fully integrated macOS experience.