Skip to content

Instantly share code, notes, and snippets.

@robert-wallis
Created May 9, 2026 02:34
Show Gist options
  • Select an option

  • Save robert-wallis/755bb4c59d6b26b010f5b64131f58471 to your computer and use it in GitHub Desktop.

Select an option

Save robert-wallis/755bb4c59d6b26b010f5b64131f58471 to your computer and use it in GitHub Desktop.
Set a custom folder icon on macOS from a PNG or SVG file using only built-in tools
#!/usr/bin/env bash
# icon-folder — set a custom icon on a macOS folder
# Usage: icon-folder <icon.png|icon.svg> [dir]
#
# Copyright (C) 2026 Robert A. Wallis, all rights reserved
set -euo pipefail
VERSION="1.0"
# --- usage ---
usage() {
cat << EOF
Usage: icon-folder <icon.png|icon.svg> [dir]
Set a custom icon on a macOS folder.
Arguments:
icon PNG or SVG file to use as the folder icon
dir Folder to apply the icon to (default: current directory)
Options:
--help Show this help and exit
--version Show version and exit
EOF
}
# --- preflight ---
# Checks that required tools are available.
preflight() {
local missing=0
for tool in swift sips iconutil; do
if ! command -v "$tool" > /dev/null 2>&1; then
echo "Error: required tool not found: $tool (install Xcode Command Line Tools)" >&2
missing=1
fi
done
[[ "$missing" -eq 0 ]] || exit 1
}
# --- svg_to_png <src.svg> <dst.png> ---
# Renders an SVG to a 1024x1024 transparent PNG using Swift + NSImage.
# Preserves aspect ratio, centered on a transparent canvas.
# 1024px is sufficient: the largest iconset file is icon_512x512@2x.png (1024px actual).
svg_to_png() {
local src="$1" dst="$2"
swift - "$src" "$dst" << 'SWIFT'
import Cocoa
let src = CommandLine.arguments[1]
let dst = CommandLine.arguments[2]
let sz = CGFloat(1024)
guard let img = NSImage(contentsOfFile: src) else {
fputs("Error: could not load SVG: \(src)\n", stderr)
exit(1)
}
let natural = img.size
let scale = min(sz / natural.width, sz / natural.height)
let drawW = natural.width * scale
let drawH = natural.height * scale
let drawX = (sz - drawW) / 2
let drawY = (sz - drawH) / 2
guard let bmp = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: Int(sz), pixelsHigh: Int(sz),
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .calibratedRGB, bytesPerRow: Int(sz) * 4, bitsPerPixel: 32) else {
fputs("Error: could not allocate bitmap (out of memory?)\n", stderr)
exit(1)
}
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bmp)
NSColor.clear.set()
NSRect(x: 0, y: 0, width: sz, height: sz).fill()
img.draw(in: NSRect(x: drawX, y: drawY, width: drawW, height: drawH), from: .zero, operation: .sourceOver, fraction: 1.0)
NSGraphicsContext.restoreGraphicsState()
guard let data = bmp.representation(using: .png, properties: [:]) else {
fputs("Error: could not encode PNG\n", stderr)
exit(1)
}
do {
try data.write(to: URL(fileURLWithPath: dst))
} catch {
fputs("Error: could not write PNG to \(dst): \(error)\n", stderr)
exit(1)
}
SWIFT
}
# --- sips_dimension <property> <file> ---
# Reads a single sips property (pixelWidth or pixelHeight) and prints its value.
# Validates that the result is a positive integer.
sips_dimension() {
local prop="$1" file="$2"
local val
val="$(sips -g "$prop" "$file" | awk -v p="$prop" '$0 ~ p { print $2 }')"
if [[ ! "$val" =~ ^[0-9]+$ ]] || [[ "$val" -eq 0 ]]; then
echo "Error: could not read $prop from $file (got: '${val}')" >&2
return 1
fi
echo "$val"
}
# --- max_icon_size <png> ---
# Prints the largest power of 2 <= min(width, height) of the image, capped at 512.
# The largest iconset file is icon_512x512@2x.png (1024px actual); no 1x file above 512 exists in the spec.
max_icon_size() {
local icon="$1"
local w h min_dim max_size=0
w="$(sips_dimension pixelWidth "$icon")"
h="$(sips_dimension pixelHeight "$icon")"
min_dim=$(( w < h ? w : h ))
for size in 16 32 128 256 512; do
[[ "$size" -le "$min_dim" ]] && max_size="$size"
done
if [[ "$max_size" -eq 0 ]]; then
echo "Error: icon is smaller than 16x16 (min dimension: ${min_dim}px)" >&2
return 1
fi
echo "$max_size"
}
# --- build_iconset <src.png> <iconset_dir> <work_dir> <max_size> ---
# Center-crops src to a square, then generates all 1x and @2x PNG sizes
# up to max_size into iconset_dir (no upscaling).
# @2x variants are capped at 512 (their source is the 1024px 1x file).
build_iconset() {
local src="$1" iconset="$2" work_dir="$3" max_size="$4"
local w h min_dim crop_x crop_y square double src_2x
w="$(sips_dimension pixelWidth "$src")"
h="$(sips_dimension pixelHeight "$src")"
min_dim=$(( w < h ? w : h ))
echo "Input: ${w}x${h}px → cropping to ${min_dim}x${min_dim}, max icon size: ${max_size}px"
# center-crop to square
square="${work_dir}/square.png"
crop_x=$(( (w - min_dim) / 2 ))
crop_y=$(( (h - min_dim) / 2 ))
sips --cropOffset "$crop_y" "$crop_x" --cropToHeightWidth "$min_dim" "$min_dim" \
"$src" --out "$square" > /dev/null
# pass 1: generate 1x files up to max_size, plus 1024px as source for 512@2x
for size in 16 32 128 256 512 1024; do
[[ "$size" -gt "$min_dim" ]] && break
if [[ "$size" -le "$max_size" || "$size" -eq 1024 ]]; then
sips -z "$size" "$size" "$square" --out "${iconset}/icon_${size}x${size}.png" > /dev/null
fi
done
# pass 2: @2x variants — each copies the already-generated double-size 1x file
for size in 16 32 128 256 512; do
[[ "$size" -gt "$max_size" ]] && break
double=$(( size * 2 ))
src_2x="${iconset}/icon_${double}x${double}.png"
[[ -f "$src_2x" ]] && cp "$src_2x" "${iconset}/icon_${size}x${size}@2x.png"
done
# remove the 1024px intermediate — it is not part of the iconset spec
rm -f "${iconset}/icon_1024x1024.png"
}
# --- apply_icon <icns> <dir> ---
# Sets a custom icon on a folder using NSWorkspace.
# Handles the Icon\r resource fork and com.apple.FinderInfo flag automatically.
apply_icon() {
local icns="$1" dir="$2"
swift - "$icns" "$dir" << 'SWIFT'
import Cocoa
let icns = CommandLine.arguments[1]
let dir = CommandLine.arguments[2]
guard let image = NSImage(contentsOfFile: icns) else {
fputs("Error: could not load icns: \(icns)\n", stderr)
exit(1)
}
guard NSWorkspace.shared.setIcon(image, forFile: dir, options: []) else {
fputs("Error: could not set icon on \(dir) (check permissions)\n", stderr)
exit(1)
}
SWIFT
}
# --- cleanup ---
# Global work dir so the trap can reference it from script scope.
WORK_DIR=""
cleanup() {
[[ -n "$WORK_DIR" && -d "$WORK_DIR" ]] && rm -r "$WORK_DIR"
}
trap cleanup EXIT
# --- main ---
main() {
# parse flags
while [[ $# -gt 0 && "$1" == -* ]]; do
case "$1" in
--version)
echo "icon-folder ${VERSION}"
exit 0
;;
--help)
usage
exit 0
;;
*)
echo "Error: unknown option: $1" >&2
usage >&2
exit 1
;;
esac
shift
done
local icon="${1:-}" dir="${2:-.}"
if [[ $# -gt 2 ]]; then
echo "Error: unexpected argument: ${3}" >&2
usage >&2
exit 1
fi
# validate arguments
if [[ -z "$icon" ]]; then
usage >&2
exit 1
fi
if [[ ! -f "$icon" ]]; then
echo "Error: icon file not found: $icon" >&2
exit 1
fi
if [[ ! -d "$dir" ]]; then
echo "Error: target is not a directory: $dir" >&2
exit 1
fi
preflight
# resolve to absolute paths
icon="$(cd "$(dirname "$icon")" && pwd)/$(basename "$icon")"
dir="$(cd "$dir" && pwd)"
# set up work dir (cleaned up via trap on EXIT)
WORK_DIR="$(mktemp -d)"
# convert SVG to PNG if needed (sips cannot read SVG)
local ext
ext="$(echo "${icon##*.}" | tr '[:upper:]' '[:lower:]')"
if [[ "$ext" == "svg" ]]; then
svg_to_png "$icon" "${WORK_DIR}/svg-render.png"
icon="${WORK_DIR}/svg-render.png"
fi
# determine the highest icon resolution we can generate without upscaling
local max_size
max_size="$(max_icon_size "$icon")"
# build the .iconset directory
local iconset="${WORK_DIR}/icon.iconset"
mkdir "$iconset"
build_iconset "$icon" "$iconset" "$WORK_DIR" "$max_size"
# compile .iconset → .icns
local icns="${WORK_DIR}/icon.icns"
iconutil -c icns "$iconset" -o "$icns"
# apply the icon to the folder
apply_icon "$icns" "$dir"
echo "Icon set on $dir"
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment