mordaunt.dev/code

code / icns

icns

Easily create .icns files (Mac Icons) with this Go library or the included CLI.

  • Go
  • CLI
  • Library
  • Icons
  • Images
  • macOS
  • Encoding
  • Graphics
  • Apps
  • Tooling
install
brew install jackmordaunt/tap/icnsifyscoop bucket add jackmordaunt https://github.com/JackMordaunt/scoop-bucket && scoop install icns
clone
https://mordaunt.dev/code/icns
release
v4.2.02026-09-20235 commits37 commits since
license
MIT
activity
107 commitsthis year, since 2018

Easily convert .jpg and .png to .icns with the command line tool icnsify, or use the library to convert from any image.Image to .icns.

go get github.com/jackmordaunt/icns/v4

icns files allow for high resolution icons to make your apps look sexy. The most common ways to generate icns files are:

  1. iconutil, which is a Mac native cli utility.
  2. ImageMagick which adds a large dependency to your project for such a simple use case.

With this library you can use pure Go to create icns files from any source image, given that you can decode it into an image.Image, without any heavyweight dependencies or subprocessing required. You can also use it to create icns files on windows and linux (thanks Go).

A small CLI app icnsify is provided allowing you to create icns files using this library from the command line. It supports piping, which is something iconutil does not do, making it substantially easier to wrap or chuck into a shell pipeline.

Note: icns files are written with an icon at every size macOS draws, the retina OSTypes for the larger ones and the colour and mask pair Apple still uses at 16 and 32 pixels. Decoding reaches further back than writing does. Alongside PNG it understands the is32, il32, ih32 and it32 colour and mask elements, the ARGB sidebar and toolbar icons, and the 1-, 4- and 8-bit indexed icons of System 7 through Mac OS 8. Where a file holds the same icon at several depths, the richest is returned first.

Elements that hold a whole image file are passed to image.Decode, so they read in whatever formats the program has registered. That is how a JPEG 2000 icon is handled: import a decoder for it and those icons start decoding, while a program that does not carries no codec and skips past them to a size it can read.

GUI

preview is a gui for displaying icns files cross-platform.

Go Tool

go install github.com/jackmordaunt/icns/cmd/preview@latest

Clone

git clone https://github.com/jackmordaunt/icns
cd icns/cmd/preview && go install .

Note: Gio cannot be cross-compiled right now, so there are no preview builds in releases. Note: preview has its own go.mod and therefore is versioned independently (unversioned).

preview

Windows Explorer thumbnails

cmd/shell-extension is a Windows shell extension that renders .icns thumbnails in Explorer. It is a COM server written in Go and built as a DLL; see its readme for build and registration steps.

Command Line

Go Tool

go install github.com/jackmordaunt/icns/v4/cmd/icnsify@latest

Scoop

scoop bucket add extras # Ensure bucket is added first
scoop install icnsify

Or from my personal bucket:

scoop bucket add jackmordaunt https://github.com/jackmordaunt/scoop-bucket 
scoop install jackmordaunt/icns # Name is defaulted to repo name. 

Winget

winget install icnsify

Brew

brew tap jackmordaunt/homebrew-tap # Ensure tap is added first.
brew install icnsify

Clone

git clone https://github.com/jackmordaunt/icns
cd icns && go install ./cmd/icnsify

Pipe it

cat icon.png | icnsify > icon.icns

cat icon.icns | icnsify > icon.png

Standard

icnsify -i icon.png -o icon.icns

icnsify -i icon.icns -o icon.png

Windows icons, which the output path names, or --format where there is no path to name them

icnsify -i icon.png -o icon.ico

cat icon.png | icnsify -f ico > icon.ico

Between the two icon formats, in either direction

icnsify -i icon.icns -o icon.ico

From an iconset directory, which uses each drawing where it is given instead of resizing one image for every size

icnsify -i MyIcon.iconset -o MyIcon.icns

icnsify -i MyIcon.iconset -o MyIcon.ico

Library

go get github.com/jackmordaunt/icns/v4

func main() {
        pngf, err := os.Open("path/to/icon.png")
        if err != nil {
                log.Fatalf("opening source image: %v", err)
        }
        defer pngf.Close()
        srcImg, _, err := image.Decode(pngf)
        if err != nil {
                log.Fatalf("decoding source image: %v", err)
        }
        dest, err := os.Create("path/to/icon.icns")
        if err != nil {
                log.Fatalf("opening destination file: %v", err)
        }
        defer dest.Close()
        if err := icns.Encode(dest, srcImg); err != nil {
                log.Fatalf("encoding icns: %v", err)
        }
}

Per-size artwork

Icons are usually hand tuned at the small sizes rather than reduced from the large one. EncodeSlots takes a drawing per slot and resizes only the slots left empty, filling them from the largest image given.

images := map[icns.Slot]image.Image{
        {Points: 16, Scale: 1}:  small, // icon_16x16.png
        {Points: 512, Scale: 2}: large, // icon_512x512@2x.png
}
if err := icns.NewEncoder(dest).EncodeSlots(images); err != nil {
        log.Fatalf("encoding icns: %v", err)
}

A slot is a size and a display scale, because 16x16@2x and 32x32 are both 32 pixels of artwork but fill different elements. icns.Slots() lists the ten a file holds and icns.ParseSlot reads iconset file names.

Reading one size

NewDecoder identifies the icons without decoding any of them, so reading a single size does not pay for the rest.

d, err := icns.NewDecoder(src)
if err != nil {
        log.Fatalf("reading icns: %v", err)
}
for _, icon := range d.Icons() { // Largest first.
        if icon.Size > 128 {
                continue
        }
        img, err := icon.Decode()
        ...
}

Entry.Payload returns the bytes the file stores, for handling an element yourself.

Checking a file

Validate reports what the platform that owns the format will make of a file, most serious first. It finds the failures that look like success: colour planes ending on their last run lose their tail to Apple silicon, an icon whose mask is absent draws fully opaque, and icp4 renders everywhere except an app bundle.

problems, err := icns.Validate(src)
if err != nil {
        log.Fatalf("reading icns: %v", err)
}
for _, p := range problems {
        fmt.Println(p)
}
degraded: il32 32: the file holds no l8mk element, so the icon draws fully opaque
degraded: icp4 16: does not render from an app bundle, and the file holds no is32 and s8mk at that size
advice: the file holds no ic13 256, ic08 256, so macOS scales another icon where they are asked for

Each finding carries a Severity: Invisible when the icon is not drawn at all, Degraded when it is drawn but not as it was meant to be, and Advice when nothing is wrong and something usual is simply absent.

ico.Validate does the same for Windows, where the finding that bites is a PNG frame stored without a 32-bit alpha channel. Every Windows decoder skips such a frame and falls back to a smaller icon, so the file looks correct until it is viewed large.

icnsify -c icon.icns runs the same check from the command line, printing the findings and exiting non-zero when one of them changes what is drawn.

Windows icons

ico is a sibling package for the Windows .ico format, with the same shape as the icns API, so one mental model covers both.

import "github.com/jackmordaunt/icns/v4/ico"

if err := ico.Encode(dest, srcImg); err != nil {
        log.Fatalf("encoding ico: %v", err)
}

A file is written with an icon at 256, 128, 64, 48, 32, 24 and 16 pixels, skipping any larger than the source. The 256 is a PNG, since a bitmap at that size is a quarter of a megabyte on its own; the rest are 32-bit bitmaps with the one-bit mask Windows still reads. NewEncoder(dest).EncodeSizes(images) takes a drawing per size, as EncodeSlots does for icns.

Decoding reads PNG icons and bitmaps at 1, 4, 8, 24 and 32 bits per pixel, taking the colour table from the file and the alpha from the mask where the pixels carry none. NewDecoder, Icons, Entry.Decode and Entry.Payload work as their icns counterparts do, and the package registers itself with image.Decode.

icnsify writes .ico too, so the format is reachable from the command line without writing a program.

Icons inside binaries

exe is a sibling package that reads the icons a Windows executable or DLL carries. They live in the resource section as a directory in one resource and its images in others, which is an ico file taken apart, so the package puts it back together.

import "github.com/jackmordaunt/icns/v4/exe"

groups, err := exe.Icons(binary) // An io.ReaderAt.
if err != nil {
        log.Fatalf("reading icons: %v", err)
}
for _, group := range groups { // Lowest ordinal first, as Explorer draws them.
        os.WriteFile("icon.ico", group.ICO(), 0o644)
}

The reassembly is exact: the ico handed to the linker comes back out of the binary byte for byte, which is what the package is tested against. Group.Decode returns the largest size as an image, and exe.Decode does the same for the first group.

icnsify takes a binary wherever it takes an image, so icnsify -i app.exe -f icns converts the icon a program ships with, and icnsify -c app.exe checks what Explorer will draw for it.

macOS 26 icons

macOS 26 draws app icons from a .icon bundle compiled into an asset catalog, and reads the older .icns where that is absent, so an app targeting both ships both. appicon writes the bundle: a manifest naming layers, and the images those layers hold.

import "github.com/jackmordaunt/icns/v4/appicon"

if err := appicon.New(art, "App").Write("App.icon"); err != nil {
        log.Fatalf("writing bundle: %v", err)
}

Bundle takes groups of layers with their own shadow, translucency and glass, for an icon built from more than one drawing. Files renders the same thing as bytes keyed by path, for writing somewhere other than a directory.

icnsify -i art.png -f icon -o App.icon does it from the command line.

Compiling one

Turning a bundle into the Assets.car macOS reads is actool‘s work, and actool runs only on macOS. It is the single step that needs Apple’s tooling; writing the bundle does not, so the machine drawing the icon need not be a Mac.

- runs-on: macos-latest
  run: |
    xcrun actool App.icon --compile build \
      --app-icon App --include-all-app-icons \
      --output-partial-info-plist build/partial.plist \
      --minimum-deployment-target 26.0 \
      --target-device mac --platform macosx

actool writes three things: the Assets.car, an .icns for older systems, and a plist naming both through CFBundleIconFile and CFBundleIconName. The result is a build artifact that changes only when the icon does, so it is generated once and kept.

Development

The repository is a Go workspace of three modules:

Module Contents Why separate
github.com/jackmordaunt/icns/v4 (root) The library and cmd/icnsify Depends only on golang.org/x/image; one tag versions both
github.com/jackmordaunt/icns/cmd/preview The Gio GUI Keeps Gio’s dependency tree out of library consumers’ module graphs
github.com/jackmordaunt/icns/cmd/shell-extension The Windows DLL Windows-only and needs cgo (mingw)

go.work ties them together, so every build inside the checkout uses the working-tree library and gopls sees the whole repository. Note that ./... only matches the module you are in; to cover all three from the root, name them:

go test ./... ./cmd/preview/... ./cmd/shell-extension/...

The two command modules require the library at a published tag. Inside the workspace that requirement only shapes the module graph; the code always comes from the working tree, so the tag can lag behind without affecting development.

CI builds and tests the library on Linux, macOS and Windows, runs the encoder under the race detector, fuzzes the decoder, and reports gofmt, go vet and govulncheck. Two oracles check the formats against the systems that own them: iconutil on the macOS runner, and the Windows Imaging Component and System.Drawing on the Windows one. The shell extension is tested on Windows, where cgo can reach a C compiler, and preview is built on macOS and Windows, since Gio needs a long list of X11 and Wayland headers on Linux. Both command modules are also built with GOWORK=off, which is what go install sees.

Releasing: tag the root module (vX.Y.Z), which releases the library and icnsify together. When go install .../cmd/preview@latest, or a shell extension built outside the checkout, should pick up a newer library, bump that module’s requirement and tidy it outside the workspace:

$env:GOWORK = 'off'; go get github.com/jackmordaunt/icns/v4@latest; go mod tidy; Remove-Item Env:GOWORK

Roadmap

  • Encoder: image.Image -> .icns
  • Command Line Interface
    • Encoding
    • Pipe support
    • Decoding
  • Implement Decoder: .icns -> image.Image
  • Symmetric test: decode(encode(img)) == img
  • Windows Explorer thumbnails
  • Windows .ico encoder and decoder
  • Validation against what the platforms actually accept
  • Reading icons out of Windows binaries
  • Writing the macOS 26 icon bundle

Coffee

If this software is useful to you, consider buying me a coffee!

https://liberapay.com/JackMordaunt

Changelog

full log
v4.2.02026-09-20

v4.2.0

  • b18d6e7build: separate minting a tag from releasing one
  • 3e85d61ci: leave the winget job without an ambient token
  • b77d8f9ci: pin govulncheck to a release
  • 7ed3aefci: pin the actions to commit hashes
  • df287f9build: move the release config to goreleaser v2
  • 18ce131build: restore the shell extension's out-of-workspace build
  • and 2 more
v4.1.02026-09-18

ico sibling package, the full icon typeset and a browser build

  • 76ed5c9wasm: expose the converters to a web page
  • cadf5f4icnsify: write ico files
  • c74311cico: check the format against Windows itself
  • 9eb03ebico: add a sibling package for the Windows icon format
  • 8204a2aicns: move resampling into an internal package
  • eeb191dicns: let registered decoders read the compressed elements
  • and 32 more
v4.0.12026-09-17

cmd: record icns v4.0.0 in the command modules' go.sum

  • b2ca62fcmd: record icns v4.0.0 in the command modules' go.sum
  • 837e875build: drop the go.work replace now that v4.0.0 is published
  • 8129febdocs: give the GOWORK override as a PowerShell command
  • df50af1all: fold the old master line into the history
  • c461daeall: separate modules with workspace
  • 04ed626cmd/preview: update to latest gio
  • and 1 more
v4.0.02026-09-17

Windows shell extension and refresh

  • 2ef5d89docs: name each module when testing the workspace
  • bde1f4bdocs: describe the v4 layout, install paths and release steps
  • 14a023cbuild: ignore compiled binaries
  • 1059a2ecmd/icnsify: fold into the root module
  • dba92d5cmd/icnsify: use the standard flag package
  • 3b4821dcmd/icnsify: use the os package instead of afero
  • and 15 more
v3.0.12024-10-29

Better error reporting

  • 6c3229dicns.Decode{All}: explicitly error on unsupported formats
  • 439e61acmd/icnsify: probe icns prior to decode
  • 5519ecdcmd/icnsify: use slog instead of log
  • 8b1e856icns.Probe: allow probing for icons
  • b2a5cbdicns.IconDescription: describe each icon (without icon data)
  • 0161855icns.ImageFormat: enumerate expected image formats
  • and 5 more
v3.0.02023-12-08

module restructure

  • b6b3df9all: restructure into separate modules
  • 8f29e4acmd/preview: update deps
  • 61700cfgoreleaser: use scoop.repository instead of scoop.bucket
  • 43acd24goreleaser: use brew.repository instead of brew.tap
  • 0e6cd1fdeps: upgrade afero and text
  • 55d64d1ci: change Winget Releaser job to `ubuntu-latest`
  • and 27 more
v2.3.02023-12-06

use workspace

  • c461daeall: separate modules with workspace
v2.2.82023-12-06

upgrade Go and Gio

  • 04ed626cmd/preview: update to latest gio
  • 7bf38c4go.mod: bump go version and tidy
v2.2.72023-11-29

bump dependencies

  • 0e6cd1fdeps: upgrade afero and text
  • 55d64d1ci: change Winget Releaser job to `ubuntu-latest`
  • bf920e7docs: update homebrew tap instructions
v2.2.62023-05-04

v2.2.6

  • 0a65229cmd/preview: update deps
  • fa684bcicns: updates deps
  • a915a6cicns: fix test to include 16 as smallest size
v2.2.52023-05-04

v2.2.5

  • 0e02947cmd/preview: fixup module name
  • 725d674docs: add winget install instructions
  • 3f9a69cci: add dependabot
  • a97a4e0ci: add winget releaser workflow
  • 3aa1692docs: [brew] add homebrew tap
  • 4e1050ddocs: [scoop] add personal bucket
  • and 2 more
v2.2.22023-02-27

v2.2.2 upgrade dependencies

    v2.2.32023-02-27

    goreleaser: produce zip archives for windows

      v2.2.42023-02-27

      goreleaser: produce zip archives for windows

      • 473b2fdgoreleaser: produce zip archives for windows
      • df756fadeps: update to Go 1.17
      • 541477ddeps: upgrade gio version
      • a017c6fdeps: upgrade
      • a7361c8docs: add scoop installation instructions
      • c1113a3Bump golang.org/x/text from 0.3.6 to 0.3.8 in /cmd/preview
      • and 2 more
      v2.2.12022-04-08

      Remove icon type icp4,icp5,icp6

      • 1e2a23aRemove icon type icp4,icp5,icp6
      v2.2.02022-03-30

      Update the codes about generated icns files. (#10)

      • 19366bfUpdate the codes about generated icns files. (#10)
      v2.1.32021-12-22

      Decouple icns from cmd/preview to trim down dependencies.

      • 4522a09cmd/preview: decouple preview from icns
      • 24b6242Drop pkg/errors in favour of fmt error wrapping (#7)
      v2.1.22021-05-29

      fix: sort images largest-first

      • 9f7f2eefix: sort images largest-first
      • 535d9c9docs: fix install command
      v2.1.12021-05-28

      migrate to v2 import path

      • 307d2e8build: migrate to v2
      v2.1.02021-05-28

      release preview utility

      • bff2bdbbuild: update deps
      • 01d9bc5fix: update releaser configuration
      • 8d9b7b6docs: remove completed todo
      • fc3feb0feat: save as icns with ctrl-s hotkey
      • 2c1ad13docs: screenshot
      • 9194b50feat(ux): cooporate with 'open with' entry point
      • and 13 more
      v2.0.32018-12-31

      [+] Create resized images in parallel.

      • 4f16af7[+] Create resized images in parallel.
      • bcdb459[~] Compile for windows. Ignore 32 bit architectures.
      v2.0.12018-11-15

      [~] Stay up to date with goreleaser. (fpm was deprecated)

        v2.0.22018-11-15

        [~] Stay up to date with goreleaser. (fpm was deprecated)

        • b5fa7ea[~] Stay up to date with goreleaser. (fpm was deprecated)
        v2.0.02018-11-15

        Go Modules.

        • d352e0b[!] Migrate to go modules.
        • 98e1c42[~] Added 128*128 size to the size list.
        v1.0.02018-02-18

        ICNS does not support embedding standard jpeg, only jpeg 2000. In response to this, the 'support' for choosing the embedding format has been removed causing a breaking change to the API.

        • 7cc95a8[+] Updated readme examples. Removed deprecated roadmap item.
        • 7f59306[+] Encode direction based on input (icns -> png or png|jpg -> icns).
        • 332bd5f[-] ICNS does not support standard JPEG.
        • 033c0cb[*] Shorten the function call.
        • fe8561e[*] Default to jpeg quality of 100 for best icon quality.
        • 4f1b88a[+] Added errors as sanity checks.
        • and 4 more
        v0.1.22018-02-14

        Using goreleaser to release binaries.

        • a57a0ac[*] Targeting the package instead of main.go
        • c63623c[+] Added version variable to be filled in by ldflags.
        • 90f485d[+] Ignoring dist folders which contain build artifacts (goreleaser).
        • 64961de[*] Corrected author name and email.
        • 669f320[*] Moved .goreleaser.yml to top level directory.
        • 1048361[+] Using goreleaser to release binaries.
        v0.1.12018-02-14

        Using dep for dependency management.

        • 62c5799[+] Using dep for dependency management.
        • 81c49d6[-] Removed dependency on a local lib that isn't hosted anyhere yet.
        v0.1.02018-02-12

        Encoder API is frozen.

        • 3075591[*] Fixed spacing in usage example.
        • d7aac8c[*] Typo fix.
        • ad28a01Merge branch 'master' of https://github.com/jackmordaunt/icns
        • e4ff63aMerged in testing branch.
        • 5cd42f5Initial commit
        • 0ca38bd[+] Added CLI usage and extra documentation.
        • and 33 more

        Mirrored to sourcehut and GitHub.