Skip to content

Instantly share code, notes, and snippets.

@mholt
Last active April 8, 2024 17:54
Show Gist options
  • Save mholt/11008646c95d787c30806d3f24b2c844 to your computer and use it in GitHub Desktop.
Save mholt/11008646c95d787c30806d3f24b2c844 to your computer and use it in GitHub Desktop.
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.
//
// If code signing / notarizing, you also need:
//
// <key>CFBundlePackageType</key>
// <string>APPL</string>
//
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>
`
@mholt
Copy link
Author

mholt commented Aug 27, 2019

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

@dirslashls
Copy link

Thanks @mholt, this saves me a lot of time going through manual creation of the dmg file!

@mholt
Copy link
Author

mholt commented Dec 1, 2023

@dirslashls Awesome, glad to hear it! Thank you for your donation as well!!

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