feat(package): add package command to create a styled *.dmg for Emacs.app

This commit is contained in:
2021-05-25 02:59:40 +01:00
parent 55f35e1146
commit 87ecfbcec0
13 changed files with 448 additions and 13 deletions

View File

@@ -93,7 +93,7 @@ $(BINS): $(BINDIR)/%: $(SOURCES)
# Development
#
TEST ?= $$(go list ./... | grep -v 'sources/')
TEST ?= $$(go list ./... | grep -v 'sources/' | grep -v 'builds/')
.PHONY: clean
clean:
@@ -112,6 +112,10 @@ lint: $(TOOLDIR)/golangci-lint
format: $(TOOLDIR)/gofumpt
gofumpt -w .
.PHONY: gen
gen:
go generate $$(go list ./... | grep -v 'sources/' | grep -v 'builds/')
#
# Dependencies
#

View File

@@ -47,6 +47,7 @@ func New(version, commit, date string) *CLI {
planCmd(),
signCmd(),
notarizeCmd(),
packageCmd(),
{
Name: "version",
Usage: "print the version",

View File

@@ -16,7 +16,7 @@ func notarizeCmd() *cli2.Command {
Flags: []cli2.Flag{
&cli2.StringFlag{
Name: "bundle-id",
Usage: "Bundle",
Usage: "bundle identifier",
Value: "org.gnu.Emacs",
},
&cli2.StringFlag{
@@ -36,7 +36,7 @@ func notarizeCmd() *cli2.Command {
},
&cli2.BoolFlag{
Name: "staple",
Usage: "stable file after notarization",
Usage: "staple file after notarization",
Value: true,
},
&cli2.StringFlag{

230
pkg/cli/package.go Normal file
View File

@@ -0,0 +1,230 @@
package cli
import (
"crypto/sha256"
"fmt"
"io"
"os"
"path/filepath"
"github.com/hashicorp/go-hclog"
"github.com/jimeh/build-emacs-for-macos/pkg/dmg"
"github.com/jimeh/build-emacs-for-macos/pkg/notarize"
"github.com/jimeh/build-emacs-for-macos/pkg/plan"
"github.com/jimeh/build-emacs-for-macos/pkg/sign"
cli2 "github.com/urfave/cli/v2"
)
func packageCmd() *cli2.Command {
return &cli2.Command{
Name: "package",
Usage: "package a build directory containing Emacs.app into a dmg",
ArgsUsage: "<source-dir>",
Flags: []cli2.Flag{
&cli2.StringFlag{
Name: "volume-name",
Usage: "set volume name, defaults to basename of source dir",
Aliases: []string{"n"},
},
&cli2.BoolFlag{
Name: "sign",
Usage: "sign Emacs.app before packaging, notarize and staple " +
"dmg after packaging",
},
&cli2.StringFlag{
Name: "output",
Usage: "specify output dmg file name, if not specified the " +
"output filename is based on source directory",
Aliases: []string{"o"},
},
&cli2.BoolFlag{
Name: "sha256",
Usage: "create .sha256 checksum file for output dmg",
Aliases: []string{"s"},
Value: true,
},
&cli2.BoolFlag{
Name: "remove-source-dir",
Usage: "remove source directory after successfully " +
"creating dmg",
Aliases: []string{"rm"},
Value: false,
},
&cli2.BoolFlag{
Name: "verbose",
Usage: "verbose output",
Aliases: []string{"v"},
Value: false,
},
&cli2.StringFlag{
Name: "dmgbuild",
Usage: "specify custom path to dmgbuild executable",
},
&cli2.StringFlag{
Name: "sign-identity",
Usage: "(with --sign) signing identity passed to codesign",
EnvVars: []string{"AC_SIGN_IDENTITY"},
},
&cli2.StringFlag{
Name: "bundle-id",
Usage: "(with --sign) bundle identifier",
Value: "org.gnu.Emacs",
},
&cli2.StringFlag{
Name: "ac-username",
Usage: "(with --sign) Apple Connect username",
EnvVars: []string{"AC_USERNAME"},
},
&cli2.StringFlag{
Name: "ac-password",
Usage: "(with --sign) Apple Connect password",
Value: "@env:AC_PASSWORD",
},
&cli2.StringFlag{
Name: "ac-provider",
Usage: "(with --sign) Apple Connect provider",
EnvVars: []string{"AC_PROVIDER"},
},
&cli2.BoolFlag{
Name: "staple",
Usage: "(with --sign) stable after notarization",
Value: true,
},
&cli2.StringFlag{
Name: "plan",
Usage: "path to build plan YAML file produced by " +
"emacs-builder plan",
Aliases: []string{"p"},
EnvVars: []string{"EMACS_BUILDER_PLAN"},
TakesFile: true,
},
},
Action: actionWrapper(packageAction),
}
}
//nolint:funlen
func packageAction(c *cli2.Context, opts *Options) error {
logger := hclog.FromContext(c.Context).Named("package")
sourceDir := c.Args().Get(0)
doSign := c.Bool("sign")
var p *plan.Plan
var err error
if f := c.String("plan"); f != "" {
p, err = plan.Load(f)
if err != nil {
return err
}
}
if doSign {
app := filepath.Join(sourceDir, "Emacs.app")
signOpts := &sign.Options{
Identity: c.String("sign-identity"),
Options: []string{"runtime"},
Deep: true,
Timestamp: true,
Force: true,
Verbose: c.Bool("verbose"),
}
if p != nil {
if p.Output != nil && p.Build != nil {
app = filepath.Join(
p.Output.Directory, p.Build.Name, "Emacs.app",
)
}
}
if !opts.quiet {
signOpts.Output = os.Stdout
}
err = sign.Emacs(c.Context, app, signOpts)
if err != nil {
return err
}
}
dmgOpts := &dmg.Options{
DMGBuild: c.String("dmgbuild"),
SourceDir: sourceDir,
VolumeName: c.String("volume-name"),
OutputFile: c.String("output"),
RemoveSourceDir: c.Bool("remove-source-dir"),
Verbose: c.Bool("verbose"),
}
if p != nil && p.Output != nil && p.Build != nil {
dmgOpts.SourceDir = filepath.Join(
p.Output.Directory, p.Build.Name,
)
dmgOpts.VolumeName = p.Build.Name
dmgOpts.OutputFile = filepath.Join(
p.Output.Directory, p.Output.DiskImage,
)
}
if !opts.quiet {
dmgOpts.Output = os.Stdout
}
outputDMG, err := dmg.Create(c.Context, dmgOpts)
if err != nil {
return err
}
if doSign {
notarizeOpts := &notarize.Options{
File: outputDMG,
BundleID: c.String("bundle-id"),
Username: c.String("ac-username"),
Password: c.String("ac-password"),
Provider: c.String("ac-provider"),
Staple: c.Bool("staple"),
}
err = notarize.Notarize(c.Context, notarizeOpts)
if err != nil {
return err
}
}
if c.Bool("sha256") {
sumFile := outputDMG + ".sha256"
logger.Info("generating SHA256 checksum", "file", outputDMG)
sum, err := fileSHA256(outputDMG)
if err != nil {
return err
}
logger.Info("checksum", "sha256", sum, "file", outputDMG)
content := fmt.Sprintf("%s %s", sum, filepath.Base(outputDMG))
err = os.WriteFile(sumFile, []byte(content), 0o644) //nolint:gosec
if err != nil {
return err
}
logger.Info("wrote checksum", "file", sumFile)
}
return nil
}
func fileSHA256(filename string) (string, error) {
f, err := os.Open(filename)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return fmt.Sprintf("%x", h.Sum(nil)), nil
}

View File

@@ -18,7 +18,7 @@ func signCmd() *cli2.Command {
&cli2.StringFlag{
Name: "sign",
Aliases: []string{"s"},
Usage: "signing identity passed to codeside",
Usage: "signing identity passed to codesign",
EnvVars: []string{"AC_SIGN_IDENTITY"},
Required: true,
},

45
pkg/dmg/assets/assets.go Normal file
View File

@@ -0,0 +1,45 @@
package assets
import (
_ "embed"
"os"
)
//go:generate tiffutil -cathidpicheck bg.png bg@2x.png -out bg.tif
// Background is a raw byte slice of bytes of bg.tiff
//go:embed bg.tif
var Background []byte
// BackgroundTempFile writes Background to a temporary file on disk, returning
// the resulting file path. The returned filepath should be deleted with
// os.Remove() when no longer needed.
func BackgroundTempFile() (string, error) {
return tempFile("*-emacs-bg.tif", Background)
}
// Icon is a raw byte slice of bytes of vol.icns
//go:embed vol.icns
var Icon []byte
// IconTempFile writes Icon to a temporary file on disk, returning the resulting
// file path. The returned filepath should be deleted with os.Remove() when no
// longer needed.
func IconTempFile() (string, error) {
return tempFile("*-emacs-vol.icns", Icon)
}
func tempFile(pattern string, content []byte) (string, error) {
f, err := os.CreateTemp("", pattern)
if err != nil {
return "", err
}
defer f.Close()
_, err = f.Write(content)
if err != nil {
return "", err
}
return f.Name(), nil
}

BIN
pkg/dmg/assets/bg.afdesign Normal file

Binary file not shown.

BIN
pkg/dmg/assets/bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
pkg/dmg/assets/bg.tif Normal file

Binary file not shown.

BIN
pkg/dmg/assets/bg@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
pkg/dmg/assets/vol.icns Normal file

Binary file not shown.

138
pkg/dmg/dmg.go Normal file
View File

@@ -0,0 +1,138 @@
package dmg
import (
"context"
"io"
"os"
"path/filepath"
"github.com/hashicorp/go-hclog"
"github.com/jimeh/build-emacs-for-macos/pkg/dmg/assets"
"github.com/jimeh/build-emacs-for-macos/pkg/dmgbuild"
)
type Options struct {
DMGBuild string
SourceDir string
VolumeName string
OutputFile string
RemoveSourceDir bool
Verbose bool
Output io.Writer
}
//nolint:funlen
// Create will create a *.dmg disk image as specified by the given Options.
func Create(ctx context.Context, opts *Options) (string, error) {
logger := hclog.FromContext(ctx).Named("package")
sourceDir, err := filepath.Abs(opts.SourceDir)
if err != nil {
return "", err
}
appBundle := filepath.Join(sourceDir, "Emacs.app")
_, err = os.Stat(appBundle)
if err != nil {
return "", err
}
volIcon, err := assets.IconTempFile()
if err != nil {
return "", err
}
defer os.Remove(volIcon)
bgImg, err := assets.BackgroundTempFile()
if err != nil {
return "", err
}
defer os.Remove(bgImg)
volName := opts.VolumeName
if volName == "" {
volName = filepath.Base(sourceDir)
}
outputDMG := opts.OutputFile
if outputDMG == "" {
outputDMG = sourceDir + ".dmg"
}
settings := &dmgbuild.Settings{
Logger: logger,
Filename: outputDMG,
VolumeName: volName,
Icon: volIcon,
Format: dmgbuild.UDZOFormat,
CompressionLevel: 9,
Files: []*dmgbuild.File{
{
Path: appBundle,
PosX: 170,
PosY: 200,
},
},
Symlinks: []*dmgbuild.Symlink{
{
Name: "Applications",
Target: "/Applications",
PosX: 510,
PosY: 200,
},
},
Window: dmgbuild.Window{
Background: bgImg,
PoxX: 200,
PosY: 200,
Width: 680,
Height: 446,
DefaultView: dmgbuild.Icon,
},
IconView: dmgbuild.IconView{
IconSize: 160,
TextSize: 16,
},
}
copyingFile := filepath.Join(sourceDir, "COPYING")
fi, err := os.Stat(copyingFile)
if err != nil && !os.IsNotExist(err) {
return "", err
} else if err == nil && fi.Mode().IsRegular() {
settings.Files = append(settings.Files, &dmgbuild.File{
Path: copyingFile,
PosX: 340,
PosY: 506,
})
}
if opts.Output != nil {
settings.Stdout = opts.Output
settings.Stderr = opts.Output
}
logger.Info("creating dmg", "file", filepath.Base(outputDMG))
err = dmgbuild.Build(ctx, settings)
if err != nil {
return "", err
}
if opts.RemoveSourceDir {
dir, err := filepath.Abs(opts.SourceDir)
if err != nil {
return "", err
}
logger.Info("removing", "source-dir", dir)
err = os.RemoveAll(dir)
if err != nil {
return "", err
}
}
return outputDMG, nil
}

View File

@@ -11,8 +11,12 @@ import (
)
func Build(ctx context.Context, settings *Settings) error {
if settings == nil {
return fmt.Errorf("no settings provided")
}
logger := hclog.NewNullLogger()
if settings.Logger == nil {
if settings.Logger != nil {
logger = settings.Logger
}
@@ -20,10 +24,6 @@ func Build(ctx context.Context, settings *Settings) error {
logger = logger.Named("dmgbuild")
}
if settings == nil {
return fmt.Errorf("no settings provided")
}
_, err := os.Stat(settings.Filename)
if !os.IsNotExist(err) {
return fmt.Errorf("output dmg exists: %s", settings.Filename)
@@ -47,9 +47,9 @@ func Build(ctx context.Context, settings *Settings) error {
args := []string{"-s", file, settings.VolumeName, settings.Filename}
if logger.IsDebug() {
content, err := os.ReadFile(file)
if err != nil {
return err
content, err2 := os.ReadFile(file)
if err2 != nil {
return err2
}
logger.Debug("using settings", file, string(content))
logger.Debug("executing", "command", baseCmd, "args", args)
@@ -63,5 +63,22 @@ func Build(ctx context.Context, settings *Settings) error {
cmd.Stderr = settings.Stderr
}
return cmd.Run()
err = cmd.Run()
if err != nil {
return err
}
f, err := os.Stat(settings.Filename)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("output DMG file is missing")
}
return err
}
if !f.Mode().IsRegular() {
return fmt.Errorf("output DMG file is not a file")
}
return nil
}