-
-
Save avioli/532dced8f8aaaadfd05fed4f633979df to your computer and use it in GitHub Desktop.
Distribute your Go program (or any single binary) as a native macOS application
This file contains 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
// 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> | |
` |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment