// 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 = ` CFBundleExecutable {{.AppName}} CFBundleIconFile icon.icns CFBundleIdentifier {{.BundleIdentifier}} NSHighResolutionCapable LSUIElement `