Created
May 9, 2026 02:34
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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