Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Distribute your Go program (or any single binary) as a native macOS application
// Package main is a sample macOS-app-bundling program to demonstrate how to
// automate the process described in this tutorial:
//
// https://medium.com/@mattholt/packaging-a-go-application-for-macos-f7084b00f6b5
//
// Bundling the .app is the first thing it does, and creating the DMG is the
// second. Making the DMG is optional, and is only done if you provide
// the template DMG file, which you have to create beforehand.
//
// Example use:
//
// $ go run macapp.go \
// -assets ./folder_with_binary_and_any_resources \
// -bin yourbinary \
// -icon ./appicon1024.png \
// -identifier com.example.whatever
// -name "My App"
// -dmg "My App template.dmg" \
// -o ~/Desktop
//
// You may use this whole program or bits and pieces for whatever you want,
// but it comes without warranty or support -- I have no idea what I'm doing,
// as it is, so don't ask me. Sorry. But feel free to learn from it; it's a
// pretty minimal automation of the whole process for simple, single-binary
// applications that aren't native Cocoa, and I think I would have found
// this helpful to have when I was trying to figure it out.
//
// NOTE: This program *very likely has obvious bugs*. Feel free to suggest
// improvements to this gist and comment below, but I don't make any
// guarantees; it worked for me and you're on your own beyond that.
//
// I learned from these pages/posts - thanks, whomever you may be:
// - https://developer.apple.com/library/content/documentation/Porting/Conceptual/PortingUnix/distributing/distibuting.html#//apple_ref/doc/uid/TP40002855-TPXREF101
// - https://github.com/Xeoncross/macappshell
// - https://el-tramo.be/blog/fancy-dmg/
// - https://github.com/remko/fancy-dmg/blob/master/Makefile
// - https://github.com/shurcooL/trayhost
package main
import (
"bytes"
"flag"
"fmt"
"io"
"io/ioutil"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
)
var (
assetsDir string
binaryName string
iconFile string
appName string
outputDir string
bundleIdentifier string
templateDMG string
)
func init() {
flag.StringVar(&assetsDir, "assets", "", "The folder path that contains all the application assets")
flag.StringVar(&binaryName, "bin", "", "The name of the binary file, relative to the assets folder")
flag.StringVar(&iconFile, "icon", "", "The file of the icon to use for the application")
flag.StringVar(&appName, "name", "", "The user-facing name of the application")
flag.StringVar(&outputDir, "o", ".", "The folder into which to output the artefacts")
flag.StringVar(&bundleIdentifier, "identifier", "com.example.unknown", "The bundle identifier (make it your own)")
flag.StringVar(&templateDMG, "dmg", "", "If set, will package the app in a DMG based on this template")
}
func main() {
flag.Parse()
if assetsDir == "" || iconFile == "" || binaryName == "" || appName == "" {
log.Println("[ERROR] Assets directory, binary name, icon file, and application name are required.")
flag.PrintDefaults()
return
}
// make and fill out the .app bundle
appName = strings.TrimSuffix(appName, ".app")
appFilename := appName + ".app"
appBundleName := filepath.Join(outputDir, appFilename)
err := makeAppBundle(appBundleName)
if err != nil {
log.Fatalf("[ERROR] Making .app folder: %v", err)
}
// make the .dmg image from a template
if templateDMG != "" {
err := makeDMGFromTemplate(templateDMG, appBundleName)
if err != nil {
log.Fatalf("[ERROR] Making DMG from template: %v", err)
}
}
}
func makeAppBundle(appFilename string) error {
// make the basic directory structure
for _, dirName := range []string{
filepath.Join(appFilename, "Contents", "MacOS"),
filepath.Join(appFilename, "Contents", "Resources"),
} {
err := os.MkdirAll(dirName, 0755)
if err != nil {
return fmt.Errorf("making app folder structure: %v", err)
}
}
// write the Info.plist file into the bundle
infoPlist := strings.Replace(infoPlistTpl, "{{.AppName}}", binaryName, -1)
infoPlist = strings.Replace(infoPlist, "{{.BundleIdentifier}}", bundleIdentifier, -1)
infoPlistPath := filepath.Join(appFilename, "Contents", "Info.plist")
err := ioutil.WriteFile(infoPlistPath, []byte(infoPlist), 0644)
if err != nil {
return fmt.Errorf("writing plist file: %v", err)
}
// set the icons
err = makeAppIcons(appFilename)
if err != nil {
return fmt.Errorf("making icons: %v", err)
}
// copy the binary into the bundle
binarySrc := filepath.Join(assetsDir, binaryName)
binaryDest := filepath.Join(appFilename, "Contents", "MacOS", binaryName)
err = copyFile(binarySrc, binaryDest, nil)
if err != nil {
return fmt.Errorf("copying the binary into the bundle: %v", err)
}
// get the list of assets to copy
assetsDirFile, err := os.Open(assetsDir)
if err != nil {
return fmt.Errorf("opening assets directory: %v", err)
}
dirEntries, err := assetsDirFile.Readdirnames(100000)
if err != nil {
return fmt.Errorf("reading list of assets directory contents: %v", err)
}
// copy the assets into the bundle
for _, entry := range dirEntries {
if entry == binaryName {
continue // we already copied the binary, and it went into a different folder
}
src := filepath.Join(assetsDir, entry)
dest := filepath.Join(appFilename, "Contents", "Resources")
err = deepCopy(src, dest)
if err != nil {
return fmt.Errorf("copying assets '%s': %v", entry, err)
}
}
return nil
}
func makeAppIcons(appFolder string) error {
// start by copying the icon into the bundle
iconFilename := filepath.Base(iconFile)
resFolder := filepath.Join(appFolder, "Contents", "Resources")
copyTo := filepath.Join(resFolder, iconFilename)
err := copyFile(iconFile, copyTo, nil)
if err != nil {
return err
}
useIcon := iconFile // usable icon files are of type .png, .jpg, .gif, or .tiff - and we handle .svg
tmpFolder := filepath.Join(resFolder, "tmp")
err = os.MkdirAll(tmpFolder, 0755)
if err != nil {
return err
}
defer os.RemoveAll(tmpFolder)
// lazy way to convert SVG files to PNG, by using QuickLook
// -z displays generation performance info (instead of showing thumbnail)
// -t Computes the thumbnail
// -s sets the size of the thumbnail
// -o sets the output directory (NOT the actual output file)
if filepath.Ext(iconFile) == ".svg" {
cmd := exec.Command("qlmanage", "-z", "-t", "-s", "1024", "-o", tmpFolder, iconFile)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
if err != nil {
return fmt.Errorf("running qlmanage: %v", err)
}
useIcon = filepath.Join(tmpFolder, iconFile+".png")
}
// make the various icon sizes
// see https://developer.apple.com/library/content/documentation/GraphicsAnimation/Conceptual/HighResolutionOSX/Optimizing/Optimizing.html
iconset := filepath.Join(tmpFolder, "icon.iconset")
err = os.Mkdir(iconset, 0755)
if err != nil {
return err
}
sizes := []int{16, 32, 64, 128, 256, 512, 1024}
for i, size := range sizes {
nameSize := size
var suffix string
if i > 0 {
nameSize = sizes[i-1]
suffix = "@2x"
}
iconName := fmt.Sprintf("icon_%dx%d%s.png", nameSize, nameSize, suffix)
outIconFile := filepath.Join(iconset, iconName)
sizeStr := fmt.Sprintf("%d", size)
cmd := exec.Command("sips", "-z", sizeStr, sizeStr, useIcon, "--out", outIconFile)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
if err != nil {
return fmt.Errorf("running sips: %v", err)
}
// make standard-DPI version if we didn't already
if i > 0 && i < len(sizes)-1 {
stdName := fmt.Sprintf("icon_%dx%d.png", size, size)
err := copyFile(outIconFile, filepath.Join(iconset, stdName), nil)
if err != nil {
return fmt.Errorf("copying icon file: %v", err)
}
}
}
// create the final .icns file
icnsFile := filepath.Join(resFolder, "icon.icns")
cmd := exec.Command("iconutil", "-c", "icns", "-o", icnsFile, iconset)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err = cmd.Run()
if err != nil {
return fmt.Errorf("running iconutil: %v", err)
}
return nil
}
func makeDMGFromTemplate(templateDMG, appBundleName string) error {
tmpDir := "./tmp"
err := os.Mkdir(tmpDir, 0755)
if err != nil {
return fmt.Errorf("making temporary directory: %v", err)
}
defer os.RemoveAll(tmpDir)
// copy the template image, since we'll be modifying it
tmpDMG := "./tmp.dmg"
err = copyFile(templateDMG, tmpDMG, nil)
if err != nil {
return fmt.Errorf("making copy of template DMG: %v", err)
}
defer os.Remove(tmpDMG)
// attach the template dmg
cmd := exec.Command("hdiutil", "attach", tmpDMG, "-noautoopen", "-mountpoint", tmpDir)
attachBuf := new(bytes.Buffer)
cmd.Stdout = attachBuf
cmd.Stderr = os.Stderr
err = cmd.Run()
if err != nil {
return fmt.Errorf("running hdiutil attach: %v", err)
}
// move bundle file into it
err = deepCopy(appBundleName, tmpDir)
if err != nil {
return fmt.Errorf("copying app into dmg: %v", err)
}
// get attached image's device; it should be the
// first device that is outputted
hdiutilOutFields := strings.Fields(attachBuf.String())
if len(hdiutilOutFields) == 0 {
return fmt.Errorf("no device output by hdiutil attach")
}
dmgDevice := hdiutilOutFields[0]
// detach image
cmd = exec.Command("hdiutil", "detach", dmgDevice)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err = cmd.Run()
if err != nil {
return fmt.Errorf("running hdiutil detach: %v", err)
}
// convert to compressed image
outputDMG := filepath.Join(outputDir, appName+".dmg")
cmd = exec.Command("hdiutil", "convert", tmpDMG, "-format", "UDZO", "-imagekey", "zlib-level=9", "-o", outputDMG)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err = cmd.Run()
if err != nil {
return fmt.Errorf("running hdiutil convert: %v", err)
}
return nil
}
func copyFile(from, to string, fromInfo os.FileInfo) error {
log.Printf("[INFO] Copying %s to %s", from, to)
if fromInfo == nil {
var err error
fromInfo, err = os.Stat(from)
if err != nil {
return err
}
}
// open source file
fsrc, err := os.Open(from)
if err != nil {
return err
}
// create destination file, with identical permissions
fdest, err := os.OpenFile(to, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fromInfo.Mode()&os.ModePerm)
if err != nil {
fsrc.Close()
if _, err2 := os.Stat(to); err2 == nil {
return fmt.Errorf("opening destination (which already exists): %v", err)
}
return err
}
// copy the file and ensure it gets flushed to disk
if _, err = io.Copy(fdest, fsrc); err != nil {
fsrc.Close()
fdest.Close()
return err
}
if err = fdest.Sync(); err != nil {
fsrc.Close()
fdest.Close()
return err
}
// close both files
if err = fsrc.Close(); err != nil {
fdest.Close()
return err
}
if err = fdest.Close(); err != nil {
return err
}
return nil
}
// deepCopy makes a deep copy of from into to.
func deepCopy(from, to string) error {
if from == "" || to == "" {
return fmt.Errorf("no source or no destination; both required")
}
// traverse the source directory and copy each file
return filepath.Walk(from, func(path string, info os.FileInfo, err error) error {
// error accessing current file
if err != nil {
return err
}
// skip files/folders without a name
if info.Name() == "" {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
// if directory, create destination directory (if not
// already created by our pre-walk)
if info.IsDir() {
subdir := strings.TrimPrefix(path, filepath.Dir(from))
destDir := filepath.Join(to, subdir)
if _, err := os.Stat(destDir); os.IsNotExist(err) {
err := os.Mkdir(destDir, info.Mode()&os.ModePerm)
if err != nil {
return err
}
}
return nil
}
destPath := filepath.Join(to, strings.TrimPrefix(path, filepath.Dir(from)))
err = copyFile(path, destPath, info)
if err != nil {
return fmt.Errorf("copying file %s: %v", path, err)
}
return nil
})
}
// See https://developer.apple.com/library/content/documentation/CoreFoundation/Conceptual/CFBundles/BundleTypes/BundleTypes.html#//apple_ref/doc/uid/10000123i-CH101-SW19
// for information about the Info.plist and bundling an application.
const infoPlistTpl = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>{{.AppName}}</string>
<key>CFBundleIconFile</key>
<string>icon.icns</string>
<key>CFBundleIdentifier</key>
<string>{{.BundleIdentifier}}</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>LSUIElement</key>
<true/>
</dict>
</plist>
`
@zyfdegh

This comment has been minimized.

Copy link

@zyfdegh zyfdegh commented May 30, 2018

thanks for your work

@reedwade

This comment has been minimized.

Copy link

@reedwade reedwade commented Sep 5, 2018

line 193, iconFile wants to be iconFilename or it makes trouble if you pass something other than a plain file name for the icon.

also, thanks!, this is really very helpful

@ghost

This comment has been minimized.

Copy link

@ghost ghost commented Mar 26, 2019

anyone work out how to handle .framework packages that are symlinked ?

@reedwade

This comment has been minimized.

Copy link

@reedwade reedwade commented Aug 27, 2019

Adding:

    <key>CFBundlePackageType</key>
    <string>APPL</string>

to the plist template was needed for signing and notarising to work for me.

@mholt

This comment has been minimized.

Copy link
Owner Author

@mholt mholt commented Aug 27, 2019

@reedwade Yes, I can confirm that is correct. We added that later, after getting code-signed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment