Skip to content

Instantly share code, notes, and snippets.

@jerson
Forked from mholt/macapp.go
Created March 2, 2020 20:02
Show Gist options
  • Select an option

  • Save jerson/dcfebdaed7282d6b18a7f73a3fdcae05 to your computer and use it in GitHub Desktop.

Select an option

Save jerson/dcfebdaed7282d6b18a7f73a3fdcae05 to your computer and use it in GitHub Desktop.

Revisions

  1. @mholt mholt created this gist May 18, 2018.
    422 changes: 422 additions & 0 deletions macapp.go
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,422 @@
    // 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>
    `