~eliasnaur/gio-cmd

5c14d1ba647940886d5dd63aac5370f418bc517b — Inkeliz 1 year, 10 months ago 02068d6
gogio: [MacOS] add MacOS .app compilation

This patch is a initial implementation to make
`.app` file. It supports custom icons and sign.

Signed-off-by: Inkeliz <inkeliz@inkeliz.com>
4 files changed, 226 insertions(+), 3 deletions(-)

M gogio/build_info.go
M gogio/help.go
A gogio/macosbuild.go
M gogio/main.go
M gogio/build_info.go => gogio/build_info.go +2 -0
@@ 73,6 73,8 @@ func getArchs() []string {
			goarch = runtime.GOARCH
		}
		return []string{goarch}
	case "macos":
		return []string{"arm64", "amd64"}
	default:
		// TODO: Add flag tests.
		panic("The target value has already been validated, this will never execute.")

M gogio/help.go => gogio/help.go +4 -2
@@ 18,7 18,8 @@ Compiled Java class files from jar files in the package directory are
included in Android builds.

The mandatory -target flag selects the target platform: ios or android for the
mobile platforms, tvos for Apple's tvOS, js for WebAssembly/WebGL.
mobile platforms, tvos for Apple's tvOS, js for WebAssembly/WebGL, macos for
MacOS and windows for Windows.

The -arch flag specifies a comma separated list of GOARCHs to include. The
default is all supported architectures.


@@ 63,7 64,8 @@ its deletion.

The -x flag will print all the external commands executed by the gogio tool.

The -signkey flag specifies the path of the keystore, used for signing Android apk/aab files.
The -signkey flag specifies the path of the keystore, used for signing Android apk/aab files
or specifies the name of key on Keychain to sign MacOS app.

The -signpass flag specifies the password of the keystore, ignored if -signkey is not provided.
`

A gogio/macosbuild.go => gogio/macosbuild.go +217 -0
@@ 0,0 1,217 @@
package main

import (
	"errors"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"text/template"
)

func buildMac(tmpDir string, bi *buildInfo) error {
	builder := &macBuilder{TempDir: tmpDir}
	builder.DestDir = *destPath
	if builder.DestDir == "" {
		builder.DestDir = bi.pkgPath
	}

	name := bi.name
	if *destPath != "" {
		if filepath.Ext(*destPath) != ".app" {
			return fmt.Errorf("invalid output name %q, it must end with `.app`", *destPath)
		}
		name = filepath.Base(*destPath)
	}
	name = strings.TrimSuffix(name, ".app")

	if bi.appID == "" {
		return errors.New("app id is empty; use -appid to set it")
	}

	if err := builder.setIcon(bi.iconPath); err != nil {
		return err
	}

	if err := builder.setInfo(bi, name); err != nil {
		return fmt.Errorf("can't build the resources: %v", err)
	}

	for _, arch := range bi.archs {
		if err := builder.buildProgram(bi, name, arch); err != nil {
			return err
		}

		if bi.key != "" {
			if err := builder.signProgram(bi, name, arch); err != nil {
				return err
			}
		}
	}

	return nil
}

type macBuilder struct {
	TempDir string
	DestDir string

	Icons        []byte
	Manifest     []byte
	Entitlements []byte
}

func (b *macBuilder) setIcon(path string) (err error) {
	if _, err := os.Stat(path); err != nil {
		return nil
	}

	out := filepath.Join(b.TempDir, "iconset.iconset")
	if err := os.MkdirAll(out, 0777); err != nil {
		return err
	}

	err = buildIcons(out, path, []iconVariant{
		{path: "icon_512x512@2x.png", size: 1024},
		{path: "icon_512x512.png", size: 512},
		{path: "icon_256x256@2x.png", size: 512},
		{path: "icon_256x256.png", size: 256},
		{path: "icon_128x128@2x.png", size: 256},
		{path: "icon_128x128.png", size: 128},
		{path: "icon_64x64@2x.png", size: 128},
		{path: "icon_64x64.png", size: 64},
		{path: "icon_32x32@2x.png", size: 64},
		{path: "icon_32x32.png", size: 32},
		{path: "icon_16x16@2x.png", size: 32},
		{path: "icon_16x16.png", size: 16},
	})

	if err != nil {
		return err
	}

	cmd := exec.Command("iconutil",
		"-c", "icns", out,
		"-o", filepath.Join(b.TempDir, "icon.icns"))
	if _, err := runCmd(cmd); err != nil {
		return err
	}

	b.Icons, err = os.ReadFile(filepath.Join(b.TempDir, "icon.icns"))
	return err
}

func (b *macBuilder) setInfo(buildInfo *buildInfo, name string) error {
	t, err := template.New("manifest").Parse(`<?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>{{.Name}}</string>
	<key>CFBundleIconFile</key>
	<string>icon.icns</string>
	<key>CFBundleIdentifier</key>
	<string>{{.Bundle}}</string>
	<key>NSHighResolutionCapable</key>
	<true/>
	<key>CFBundlePackageType</key>
	<string>APPL</string>
</dict>
</plist>`)
	if err != nil {
		return err
	}

	var manifest bufferCoff
	if err := t.Execute(&manifest, struct {
		Name, Bundle string
	}{
		Name:   name,
		Bundle: buildInfo.appID,
	}); err != nil {
		return err
	}
	b.Manifest = manifest.Bytes()

	b.Entitlements = []byte(`<?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>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
</dict>
</plist>`)

	return nil
}

func (b *macBuilder) buildProgram(buildInfo *buildInfo, name string, arch string) error {
	dest := b.DestDir
	if len(buildInfo.archs) > 1 {
		dest = filepath.Join(filepath.Dir(b.DestDir), name+"_"+arch+".app")
	}

	for _, path := range []string{"/Contents/MacOS", "/Contents/Resources"} {
		if err := os.MkdirAll(filepath.Join(dest, path), 0755); err != nil {
			return err
		}
	}

	if len(b.Icons) > 0 {
		if err := os.WriteFile(filepath.Join(dest, "/Contents/Resources/icon.icns"), b.Icons, 0755); err != nil {
			return err
		}
	}

	if err := os.WriteFile(filepath.Join(dest, "/Contents/Info.plist"), b.Manifest, 0755); err != nil {
		return err
	}

	cmd := exec.Command(
		"go",
		"build",
		"-tags="+buildInfo.tags,
		"-o", filepath.Join(dest, "/Contents/MacOS/"+name),
		buildInfo.pkgPath,
	)
	cmd.Env = append(
		os.Environ(),
		"GOOS=darwin",
		"GOARCH="+arch,
		"CGO_ENABLED=1", // Required to cross-compile between AMD/ARM
	)
	_, err := runCmd(cmd)
	return err
}

func (b *macBuilder) signProgram(buildInfo *buildInfo, name string, arch string) error {
	dest := b.DestDir
	if len(buildInfo.archs) > 1 {
		dest = filepath.Join(filepath.Dir(b.DestDir), name+"_"+arch+".app")
	}

	options := filepath.Join(b.TempDir, "ent.ent")
	if err := os.WriteFile(options, b.Entitlements, 0777); err != nil {
		return err
	}

	xattr := exec.Command("xattr", "-rc", dest)
	if _, err := runCmd(xattr); err != nil {
		return err
	}

	cmd := exec.Command(
		"codesign",
		"--deep",
		"--force",
		"--options", "runtime",
		"--entitlements", options,
		"--sign", buildInfo.key,
		dest,
	)
	_, err := runCmd(cmd)
	return err
}

M gogio/main.go => gogio/main.go +3 -1
@@ 69,7 69,7 @@ func flagValidate() error {
		return errors.New("please specify -target")
	}
	switch *target {
	case "ios", "tvos", "android", "js", "windows":
	case "ios", "tvos", "android", "js", "windows", "macos":
	default:
		return fmt.Errorf("invalid -target %s", *target)
	}


@@ 100,6 100,8 @@ func build(bi *buildInfo) error {
		return buildAndroid(tmpDir, bi)
	case "windows":
		return buildWindows(tmpDir, bi)
	case "macos":
		return buildMac(tmpDir, bi)
	default:
		panic("unreachable")
	}