diff --git a/Makefile b/Makefile index 9d47da3..dfb3a8a 100644 --- a/Makefile +++ b/Makefile @@ -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 # diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go index b1eeea9..10a4fbe 100644 --- a/pkg/cli/cli.go +++ b/pkg/cli/cli.go @@ -47,6 +47,7 @@ func New(version, commit, date string) *CLI { planCmd(), signCmd(), notarizeCmd(), + packageCmd(), { Name: "version", Usage: "print the version", diff --git a/pkg/cli/notarize.go b/pkg/cli/notarize.go index f619d92..1abc44f 100644 --- a/pkg/cli/notarize.go +++ b/pkg/cli/notarize.go @@ -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{ diff --git a/pkg/cli/package.go b/pkg/cli/package.go new file mode 100644 index 0000000..bc6243c --- /dev/null +++ b/pkg/cli/package.go @@ -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: "", + 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 := ¬arize.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 +} diff --git a/pkg/cli/sign.go b/pkg/cli/sign.go index cd54876..4f430d2 100644 --- a/pkg/cli/sign.go +++ b/pkg/cli/sign.go @@ -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, }, diff --git a/pkg/dmg/assets/assets.go b/pkg/dmg/assets/assets.go new file mode 100644 index 0000000..0b5b596 --- /dev/null +++ b/pkg/dmg/assets/assets.go @@ -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 +} diff --git a/pkg/dmg/assets/bg.afdesign b/pkg/dmg/assets/bg.afdesign new file mode 100644 index 0000000..88deb58 Binary files /dev/null and b/pkg/dmg/assets/bg.afdesign differ diff --git a/pkg/dmg/assets/bg.png b/pkg/dmg/assets/bg.png new file mode 100644 index 0000000..2bef62d Binary files /dev/null and b/pkg/dmg/assets/bg.png differ diff --git a/pkg/dmg/assets/bg.tif b/pkg/dmg/assets/bg.tif new file mode 100644 index 0000000..38347c1 Binary files /dev/null and b/pkg/dmg/assets/bg.tif differ diff --git a/pkg/dmg/assets/bg@2x.png b/pkg/dmg/assets/bg@2x.png new file mode 100644 index 0000000..2990df6 Binary files /dev/null and b/pkg/dmg/assets/bg@2x.png differ diff --git a/pkg/dmg/assets/vol.icns b/pkg/dmg/assets/vol.icns new file mode 100644 index 0000000..01265a2 Binary files /dev/null and b/pkg/dmg/assets/vol.icns differ diff --git a/pkg/dmg/dmg.go b/pkg/dmg/dmg.go new file mode 100644 index 0000000..8ea665f --- /dev/null +++ b/pkg/dmg/dmg.go @@ -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 +} diff --git a/pkg/dmgbuild/dmgbuild.go b/pkg/dmgbuild/dmgbuild.go index 3348512..57e72ef 100644 --- a/pkg/dmgbuild/dmgbuild.go +++ b/pkg/dmgbuild/dmgbuild.go @@ -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 }