icns

Easily create .icns files (Mac Icons) with this Go library or the included CLI.
Log | Files | Refs | LICENSE

readme.md (13861B)


      1 # icns
      2 
      3 [![CI](https://github.com/JackMordaunt/icns/actions/workflows/ci.yml/badge.svg)](https://github.com/JackMordaunt/icns/actions/workflows/ci.yml)
      4 [![Go Reference](https://pkg.go.dev/badge/github.com/jackmordaunt/icns/v4.svg)](https://pkg.go.dev/github.com/jackmordaunt/icns/v4)
      5 
      6 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`.
      7 
      8 `go get github.com/jackmordaunt/icns/v4`
      9 
     10 `icns` files allow for high resolution icons to make your apps look sexy. The most common ways to generate icns files are:
     11 
     12 1. `iconutil`, which is a Mac native cli utility.
     13 2. `ImageMagick` which adds a large dependency to your project for such a simple use case.
     14 
     15 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).
     16 
     17 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.
     18 
     19 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.
     20 
     21 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.
     22 
     23 ## GUI
     24 
     25 `preview` is a gui for displaying `icns` files cross-platform.
     26 
     27 ### Go Tool
     28 
     29 ```
     30 go install github.com/jackmordaunt/icns/cmd/preview@latest
     31 ```
     32 
     33 ### Clone
     34 
     35 ```
     36 git clone https://github.com/jackmordaunt/icns
     37 cd icns/cmd/preview && go install .
     38 ```
     39 
     40 Note: Gio cannot be cross-compiled right now, so there are no `preview` builds in releases.
     41 Note: `preview` has its own `go.mod` and therefore is versioned independently (unversioned).
     42 
     43 ![preview](docs/preview.png)
     44 
     45 ## Windows Explorer thumbnails
     46 
     47 `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](cmd/shell-extension/readme.md) for build and registration steps.
     48 
     49 ## Command Line
     50 
     51 ### Go Tool
     52 
     53 ```
     54 go install github.com/jackmordaunt/icns/v4/cmd/icnsify@latest
     55 ```
     56 
     57 ### [Scoop](https://scoop.sh/)
     58 
     59 ```powershell
     60 scoop bucket add extras # Ensure bucket is added first
     61 scoop install icnsify
     62 ```
     63 
     64 Or from my personal bucket:
     65 
     66 ```powershell
     67 scoop bucket add jackmordaunt https://github.com/jackmordaunt/scoop-bucket 
     68 scoop install jackmordaunt/icns # Name is defaulted to repo name. 
     69 ```
     70 
     71 ### [Winget](https://learn.microsoft.com/en-us/windows/package-manager/)
     72 
     73 ```powershell
     74 winget install icnsify
     75 ```
     76 
     77 ### [Brew](https://brew.sh)
     78 
     79 ```sh
     80 brew tap jackmordaunt/homebrew-tap # Ensure tap is added first.
     81 brew install icnsify
     82 ```
     83 
     84 ### Clone
     85 
     86 ```
     87 git clone https://github.com/jackmordaunt/icns
     88 cd icns && go install ./cmd/icnsify
     89 ```
     90 
     91 Pipe it
     92 
     93 `cat icon.png | icnsify > icon.icns`
     94 
     95 `cat icon.icns | icnsify > icon.png`
     96 
     97 Standard
     98 
     99 `icnsify -i icon.png -o icon.icns`
    100 
    101 `icnsify -i icon.icns -o icon.png`
    102 
    103 Windows icons, which the output path names, or `--format` where there is no path to name them
    104 
    105 `icnsify -i icon.png -o icon.ico`
    106 
    107 `cat icon.png | icnsify -f ico > icon.ico`
    108 
    109 Between the two icon formats, in either direction
    110 
    111 `icnsify -i icon.icns -o icon.ico`
    112 
    113 From an iconset directory, which uses each drawing where it is given instead of resizing one image for every size
    114 
    115 `icnsify -i MyIcon.iconset -o MyIcon.icns`
    116 
    117 `icnsify -i MyIcon.iconset -o MyIcon.ico`
    118 
    119 ## Library
    120 
    121 `go get github.com/jackmordaunt/icns/v4`
    122 
    123 ```go
    124 func main() {
    125         pngf, err := os.Open("path/to/icon.png")
    126         if err != nil {
    127                 log.Fatalf("opening source image: %v", err)
    128         }
    129         defer pngf.Close()
    130         srcImg, _, err := image.Decode(pngf)
    131         if err != nil {
    132                 log.Fatalf("decoding source image: %v", err)
    133         }
    134         dest, err := os.Create("path/to/icon.icns")
    135         if err != nil {
    136                 log.Fatalf("opening destination file: %v", err)
    137         }
    138         defer dest.Close()
    139         if err := icns.Encode(dest, srcImg); err != nil {
    140                 log.Fatalf("encoding icns: %v", err)
    141         }
    142 }
    143 ```
    144 
    145 ### Per-size artwork
    146 
    147 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.
    148 
    149 ```go
    150 images := map[icns.Slot]image.Image{
    151         {Points: 16, Scale: 1}:  small, // icon_16x16.png
    152         {Points: 512, Scale: 2}: large, // icon_512x512@2x.png
    153 }
    154 if err := icns.NewEncoder(dest).EncodeSlots(images); err != nil {
    155         log.Fatalf("encoding icns: %v", err)
    156 }
    157 ```
    158 
    159 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.
    160 
    161 ### Reading one size
    162 
    163 `NewDecoder` identifies the icons without decoding any of them, so reading a single size does not pay for the rest.
    164 
    165 ```go
    166 d, err := icns.NewDecoder(src)
    167 if err != nil {
    168         log.Fatalf("reading icns: %v", err)
    169 }
    170 for _, icon := range d.Icons() { // Largest first.
    171         if icon.Size > 128 {
    172                 continue
    173         }
    174         img, err := icon.Decode()
    175         ...
    176 }
    177 ```
    178 
    179 `Entry.Payload` returns the bytes the file stores, for handling an element yourself.
    180 
    181 ### Checking a file
    182 
    183 `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.
    184 
    185 ```go
    186 problems, err := icns.Validate(src)
    187 if err != nil {
    188         log.Fatalf("reading icns: %v", err)
    189 }
    190 for _, p := range problems {
    191         fmt.Println(p)
    192 }
    193 ```
    194 
    195 ```
    196 degraded: il32 32: the file holds no l8mk element, so the icon draws fully opaque
    197 degraded: icp4 16: does not render from an app bundle, and the file holds no is32 and s8mk at that size
    198 advice: the file holds no ic13 256, ic08 256, so macOS scales another icon where they are asked for
    199 ```
    200 
    201 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.
    202 
    203 `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.
    204 
    205 `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.
    206 
    207 ## Windows icons
    208 
    209 `ico` is a sibling package for the Windows `.ico` format, with the same shape as the icns API, so one mental model covers both.
    210 
    211 ```go
    212 import "github.com/jackmordaunt/icns/v4/ico"
    213 
    214 if err := ico.Encode(dest, srcImg); err != nil {
    215         log.Fatalf("encoding ico: %v", err)
    216 }
    217 ```
    218 
    219 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.
    220 
    221 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`.
    222 
    223 `icnsify` writes `.ico` too, so the format is reachable from the command line without writing a program.
    224 
    225 ## Icons inside binaries
    226 
    227 `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.
    228 
    229 ```go
    230 import "github.com/jackmordaunt/icns/v4/exe"
    231 
    232 groups, err := exe.Icons(binary) // An io.ReaderAt.
    233 if err != nil {
    234         log.Fatalf("reading icons: %v", err)
    235 }
    236 for _, group := range groups { // Lowest ordinal first, as Explorer draws them.
    237         os.WriteFile("icon.ico", group.ICO(), 0o644)
    238 }
    239 ```
    240 
    241 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.
    242 
    243 `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.
    244 
    245 ## macOS 26 icons
    246 
    247 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.
    248 
    249 ```go
    250 import "github.com/jackmordaunt/icns/v4/appicon"
    251 
    252 if err := appicon.New(art, "App").Write("App.icon"); err != nil {
    253         log.Fatalf("writing bundle: %v", err)
    254 }
    255 ```
    256 
    257 `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.
    258 
    259 `icnsify -i art.png -f icon -o App.icon` does it from the command line.
    260 
    261 ### Compiling one
    262 
    263 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.
    264 
    265 ```yaml
    266 - runs-on: macos-latest
    267   run: |
    268     xcrun actool App.icon --compile build \
    269       --app-icon App --include-all-app-icons \
    270       --output-partial-info-plist build/partial.plist \
    271       --minimum-deployment-target 26.0 \
    272       --target-device mac --platform macosx
    273 ```
    274 
    275 `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.
    276 
    277 ## Development
    278 
    279 The repository is a Go workspace of three modules:
    280 
    281 | Module | Contents | Why separate |
    282 |---|---|---|
    283 | `github.com/jackmordaunt/icns/v4` (root) | The library and `cmd/icnsify` | Depends only on `golang.org/x/image`; one tag versions both |
    284 | `github.com/jackmordaunt/icns/cmd/preview` | The Gio GUI | Keeps Gio's dependency tree out of library consumers' module graphs |
    285 | `github.com/jackmordaunt/icns/cmd/shell-extension` | The Windows DLL | Windows-only and needs cgo (mingw) |
    286 
    287 `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:
    288 
    289 ```
    290 go test ./... ./cmd/preview/... ./cmd/shell-extension/...
    291 ```
    292 
    293 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.
    294 
    295 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.
    296 
    297 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:
    298 
    299 ```powershell
    300 $env:GOWORK = 'off'; go get github.com/jackmordaunt/icns/v4@latest; go mod tidy; Remove-Item Env:GOWORK
    301 ```
    302 
    303 ## Roadmap
    304 
    305 - [x] Encoder: `image.Image -> .icns`
    306 - [x] Command Line Interface
    307   - [x] Encoding
    308   - [x] Pipe support
    309   - [x] Decoding
    310 - [x] Implement Decoder: `.icns -> image.Image`
    311 - [x] Symmetric test: `decode(encode(img)) == img`
    312 - [x] Windows Explorer thumbnails
    313 - [x] Windows `.ico` encoder and decoder
    314 - [x] Validation against what the platforms actually accept
    315 - [x] Reading icons out of Windows binaries
    316 - [x] Writing the macOS 26 icon bundle
    317 
    318 ## Coffee
    319 
    320 If this software is useful to you, consider buying me a coffee!
    321 
    322 [https://liberapay.com/JackMordaunt](https://liberapay.com/JackMordaunt)