mirror of
https://github.com/jimeh/build-emacs-for-macos.git
synced 2026-02-19 02:36:39 +00:00
feat(package): add package command to create a styled *.dmg for Emacs.app
This commit is contained in:
@@ -47,6 +47,7 @@ func New(version, commit, date string) *CLI {
|
||||
planCmd(),
|
||||
signCmd(),
|
||||
notarizeCmd(),
|
||||
packageCmd(),
|
||||
{
|
||||
Name: "version",
|
||||
Usage: "print the version",
|
||||
|
||||
@@ -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
230
pkg/cli/package.go
Normal 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 := ¬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
|
||||
}
|
||||
@@ -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
45
pkg/dmg/assets/assets.go
Normal 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
BIN
pkg/dmg/assets/bg.afdesign
Normal file
Binary file not shown.
BIN
pkg/dmg/assets/bg.png
Normal file
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
BIN
pkg/dmg/assets/bg.tif
Normal file
Binary file not shown.
BIN
pkg/dmg/assets/bg@2x.png
Normal file
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
BIN
pkg/dmg/assets/vol.icns
Normal file
Binary file not shown.
138
pkg/dmg/dmg.go
Normal file
138
pkg/dmg/dmg.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user